Compare commits

...

4 Commits

Author SHA1 Message Date
Xavier Roche
ef17a145dd prototype: 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 07:47:44 +02:00
Xavier Roche
6eaa4cf36f prototype: 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 07:47:11 +02:00
Xavier Roche
e5ba565b46 prototype: port the singlefile self-test fixture to marked references
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 recorded in the diff
(quoting is now preserved rather than normalised, and a page with nothing to
inline 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 07:39:07 +02:00
Xavier Roche
639690d46e prototype: --single-file marks references in htsparse instead of rescanning
htsparse appends a "#!htsinline" fragment to every reference it saves that
--single-file may inline, so the end-of-mirror pass is a substitution over
those marks and parses no HTML and no CSS. The fragment keeps the mirror
valid if the pass never runs.

Measurement prototype for #749. Not for merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
2026-07-27 07:35:37 +02:00
4 changed files with 180 additions and 677 deletions

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
@@ -3043,6 +3044,17 @@ 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. The tag is a fragment, so the
mirror stays valid if that pass never runs. */
if (opt->single_file && !in_media && p_type == 0 &&
!p_searchMETAURL &&
singlefile_may_inline(
intag_start_valid ? intag_start : NULL,
tag_attr_start)) {
strcatbuff(tempo, SINGLEFILE_MARK);
}
// écrire le lien modifié, relatif
// Note: escape all chars, even >127 (no UTF)
HT_ADD_HTMLESCAPED_FULL(tempo);

View File

@@ -5045,45 +5045,48 @@ 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=\"css/main.css#!htsinline\">\n"
"<link rel=\"canonical\" href=\"other.html#!htsinline\">\n"
"<title>t</title>\n"
"<style>body { background: url(\"img/a%20b.png\"); }</style>\n"
"<style>body { background: url(\"img/a%20b.png#!htsinline\"); }</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=\"img/a%20b.png#!htsinline\" srcset=\"img/a%20b.png#!htsinline "
"1x, img/big.png#!htsinline 2x\">\n"
"<link rel=\"icon\" href=\"icon.png#!htsinline\">\n"
"<link rel=\"preload\" as=\"font\" href=\"font/f.woff2#!htsinline\">\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=\"img/in.png#!htsinline\">\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=\"img/ph.png#!htsinline\" data-src=\"img/lz.png#!htsinline\" "
"data-srcset=\"img/lz2.png#!htsinline 2x\" "
"lowsrc=\"img/low.png#!htsinline\">\n"
"<object data=\"img/ob.png#!htsinline\"></object>\n"
"<embed src=\"img/em.png#!htsinline\">\n"
"<img data-src=\"other.html#!htsinline\">\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=\"img/po.png#!htsinline\" controls>"
"<source src=\"v.mp4#!htsinline\" type=\"video/mp4\"></video>\n"
"<svg><image href=\"img/sv.png#!htsinline\"/></svg>\n"
"<table background=\"img/bg.png#!htsinline\"><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=\"../escape.png#!htsinline\">\n"
"<img src=\"../img/a%20b.png#!htsinline\">\n"
"<a href=\"img/a%20b.png\">link</a>\n"
"<script src=\"js/app.js\"></script>\n"
"<script src=\"js/app.js#!htsinline\"></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=\"missing.png#!htsinline\" >\n"
"<!--><img src=\"img/a%20b.png#!htsinline\">\n"
"<div style=\"background:url(img/a%20b.png#!htsinline)\"></div>\n"
"<div style='content:\"x\"; "
"background:url(img/a%20b.png#!htsinline)'></div>\n"
"</body></html>\n";
/* Lay a small mirror down under root. */
@@ -5092,18 +5095,20 @@ static void sf_fixture(const char *root) {
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 \"sub/nested.css#!htsinline\";\n"
"@import url(\"sub/two.css#!htsinline\");\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(../font/f.woff2#!htsinline); }\n"
"body { background: url(../img/a%20b.png#!htsinline); }\n"
"div { background: url(../img/big.png#!htsinline); }\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(../../img/a%20b.png#!htsinline); }\n";
static const char two[] =
"p { background: url(../../img/a%20b.png#!htsinline); }\n";
static const char deep[] = "<html><head><link rel=\"stylesheet\" "
"href=\"../../css/main.css#!htsinline\">\n"
"</head><body>d</body></html>\n";
static const char js[] = "var app = 1;\n";
char big[4096];
@@ -5245,13 +5250,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");
@@ -5271,7 +5273,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");
@@ -5360,10 +5362,14 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
(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,
/* Mark-transparent, not byte-transparent: a reference that cannot be
inlined loses its mark and keeps everything else. */
sf_check(StringLength(verbatim) == sizeof(sf_page) - 1 -
sf_count(sf_page, SINGLEFILE_MARK) *
(int) strlen(SINGLEFILE_MARK),
"a page with nothing to inline was re-serialized differently");
sf_check(strstr(StringBuff(verbatim), SINGLEFILE_MARK) == NULL,
"an un-inlinable reference kept its mark");
StringFree(verbatim);
}
(void) outlen;
@@ -5372,11 +5378,13 @@ 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";
static const char bomb_css[] =
"@import \"b.css#!htsinline\";@import \"b.css#!htsinline\";"
"@import \"b.css#!htsinline\";@import \"b.css#!htsinline\";\n";
static const char bomb_html[] =
"<html><head>"
"<link rel=\"stylesheet\" href=\"b.css#!htsinline\">"
"</head></html>\n";
const size_t css_len = sizeof(bomb_css) - 1;
String small = STRING_EMPTY, large = STRING_EMPTY;

View File

@@ -27,9 +27,11 @@ Please visit our Website: http://www.httrack.com
/* ------------------------------------------------------------ */
/* --single-file asset inliner. See htssinglefile.h.
Runs on the finished tree, so resolving a reference is path arithmetic
clamped to the mirror root; anything else (absolute, scheme-bearing, already
data:) is left alone, which is what makes a second --update run a no-op. */
htsparse marked every inlinable reference while saving, so this is a
substitution over those marks: no HTML and no CSS is parsed here. Resolving
a mark is path arithmetic clamped to the mirror root; anything the mark
cannot be resolved to keeps its link, which is what makes a second --update
run a no-op. */
/* ------------------------------------------------------------ */
#define HTS_INTERNAL_BYTECODE
@@ -50,14 +52,9 @@ Please visit our Website: http://www.httrack.com
#define SF_C_FONT 2
#define SF_C_CSS 4
#define SF_C_JS 8
#define SF_C_ANY (SF_C_IMAGE | SF_C_FONT | SF_C_CSS | SF_C_JS)
/* Shape of an attribute value. */
#define SF_V_URL 0 /* one URL */
#define SF_V_SRCSET 1 /* HTML srcset candidate list */
#define SF_V_CSS 2 /* CSS declarations (style="...") */
/* @import chains deeper than this keep their links. */
/* An asset that carries marks of its own (a stylesheet importing another) is
expanded before encoding; deeper than this it keeps its links. */
#define SF_MAX_CSS_DEPTH 4
/* Bounds on hostile input: a longer reference, or one resolving to more
@@ -65,9 +62,6 @@ Please visit our Website: http://www.httrack.com
#define SF_MAX_REF 4096
#define SF_MAX_COMPONENTS 128
/* Attributes collected from one start tag before it is re-emitted. */
#define SF_MAX_ATTRS 64
/* Over-cap assets reported per pass; beyond that the log would carry one line
per referencing page. */
#define SF_MAX_WARN 32
@@ -83,7 +77,41 @@ typedef struct sf_ctx {
} sf_ctx;
/* ------------------------------------------------------------ */
/* Spans */
/* Marks */
/* ------------------------------------------------------------ */
/* (tag, attribute) pairs htsparse detects that name a page or a media stream
rather than an asset; a NULL tag matches any. Everything else it detects is
offered to the pass, which decides from the referenced file's own MIME
type, so a new row in hts_detect[] is covered without a change here. */
static const struct sf_deny_rule {
const char *tag;
const char *attr;
} sf_deny_rules[] = {
{"a", "href"}, {"area", "href"}, {"iframe", "src"},
{"frame", "src"}, {"applet", "code"}, {NULL, "longdesc"},
{NULL, "usemap"}, {NULL, "archive"}, {NULL, "profile"},
{NULL, "codebase"},
};
hts_boolean singlefile_may_inline(const char *tag_start, const char *attr) {
size_t i;
if (attr == NULL)
return HTS_FALSE;
for (i = 0; i < sizeof(sf_deny_rules) / sizeof(sf_deny_rules[0]); i++) {
const struct sf_deny_rule *const r = &sf_deny_rules[i];
if (r->tag != NULL && (tag_start == NULL || !check_tag(tag_start, r->tag)))
continue;
if (rech_tageq(attr, r->attr))
return HTS_FALSE;
}
return HTS_TRUE;
}
/* ------------------------------------------------------------ */
/* Paths */
/* ------------------------------------------------------------ */
static int sf_is_space(int c) {
@@ -92,45 +120,6 @@ static int sf_is_space(int c) {
static int sf_is_sep(int c) { return c == '/' || c == '\\'; }
/* Case-insensitive equality between the span [p,p+n) and a lowercase literal.
*/
static hts_boolean sf_span_eq(const char *p, size_t n, const char *lit) {
size_t i;
for (i = 0; i < n; i++) {
if (lit[i] == '\0' || tolower((unsigned char) p[i]) != lit[i])
return HTS_FALSE;
}
return lit[n] == '\0' ? HTS_TRUE : HTS_FALSE;
}
/* Case-insensitive "does the span start with the lowercase literal". */
static hts_boolean sf_span_starts(const char *p, size_t n, const char *lit) {
size_t i;
for (i = 0; lit[i] != '\0'; i++) {
if (i >= n || tolower((unsigned char) p[i]) != lit[i])
return HTS_FALSE;
}
return HTS_TRUE;
}
/* Case-insensitive search for a lowercase literal inside a span. */
static hts_boolean sf_span_has(const char *p, size_t n, const char *lit) {
const size_t l = strlen(lit);
size_t i;
for (i = 0; l <= n && i + l <= n; i++) {
if (sf_span_starts(p + i, n - i, lit))
return HTS_TRUE;
}
return HTS_FALSE;
}
/* ------------------------------------------------------------ */
/* Paths */
/* ------------------------------------------------------------ */
/* Copy path into out with '/' separators and no trailing one. */
static void sf_normalize_path(const char *path, String *out) {
size_t i;
@@ -382,24 +371,25 @@ static void sf_warn_oversize(sf_ctx *ctx, const char *path, LLint size,
path, size, cap);
}
static void sf_rewrite_css(sf_ctx *ctx, const char *base_dir, int depth,
const char *css, size_t len, String *out);
/* ------------------------------------------------------------ */
/* Substitution */
/* ------------------------------------------------------------ */
static void sf_expand(sf_ctx *ctx, const char *base_dir, int depth,
const char *body, size_t len, String *out);
/* Replace the reference [ref,ref+reflen), resolved against base_dir, with its
data: URI appended to out. classes gates the acceptable MIME classes;
fallback_mime types an asset whose class cannot be guessed (NULL: give up).
rebase_dir re-expresses an un-inlinable asset relative to that directory.
A data: URL's path is opaque, so nothing relative inside an inlined
stylesheet resolves anyway; this only aims it at a lenient resolver's base.
Returns HTS_TRUE if out received a replacement. */
data: URI appended to out. rebase_dir re-expresses an un-inlinable asset
relative to that directory: a data: URL's path is opaque, so nothing
relative inside an inlined stylesheet resolves anyway; this only aims it at
a lenient resolver's base. Returns HTS_TRUE if out received a data: URI. */
static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
size_t reflen, int classes,
const char *fallback_mime, const char *rebase_dir,
int depth, String *out) {
size_t reflen, const char *rebase_dir, int depth,
String *out) {
String path = STRING_EMPTY;
char mime[HTS_MIMETYPE_SIZE];
char *body;
size_t body_len = 0;
char *file;
size_t file_len = 0;
LLint size, cap;
int cls;
hts_boolean done = HTS_FALSE;
@@ -409,13 +399,9 @@ static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
return HTS_FALSE;
}
cls = sf_mime_class(ctx->opt, StringBuff(path), mime, sizeof(mime));
if (cls == 0 && fallback_mime != NULL) {
cls = classes;
strlcpybuff(mime, fallback_mime, sizeof(mime));
}
cap = ctx->opt->single_file_max_size;
size = fsize_utf8(StringBuff(path));
if ((cls & classes) == 0 || size < 0)
if (cls == 0 || size < 0)
goto fallback;
if (size > cap || size > SINGLEFILE_HARD_MAX_SIZE) {
sf_warn_oversize(ctx, StringBuff(path), size, cap);
@@ -425,11 +411,11 @@ static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
sf_warn_oversize(ctx, StringBuff(path), size, ctx->budget);
goto fallback;
}
body = sf_readfile(StringBuff(path), &body_len);
if (body == NULL)
file = sf_readfile(StringBuff(path), &file_len);
if (file == NULL)
goto fallback;
/* Charged before the nested rewrite: an @import chain otherwise spends what
its ancestors already claimed, and the budget ends up negative. */
/* Charged before the nested expansion: an @import chain otherwise spends
what its ancestors already claimed, and the budget ends up negative. */
ctx->budget -= size;
/* Encode into a scratch String: a failed encode must leave out untouched,
not a truncated "data:...;base64," with no payload. */
@@ -437,20 +423,20 @@ static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
String payload = STRING_EMPTY;
StringClear(payload);
if ((cls & SF_C_CSS) != 0 && depth < SF_MAX_CSS_DEPTH) {
if ((cls & (SF_C_CSS | SF_C_JS)) != 0 && depth < SF_MAX_CSS_DEPTH) {
String nested = STRING_EMPTY;
String nested_dir = STRING_EMPTY;
sf_dirname(StringBuff(path), &nested_dir);
StringClear(nested);
sf_rewrite_css(ctx, StringBuff(nested_dir), depth + 1, body, body_len,
&nested);
sf_expand(ctx, StringBuff(nested_dir), depth + 1, file, file_len,
&nested);
done = sf_append_base64(&payload, StringBuffRW(nested),
StringLength(nested));
StringFree(nested);
StringFree(nested_dir);
} else {
done = sf_append_base64(&payload, body, body_len);
done = sf_append_base64(&payload, file, file_len);
}
if (done) {
StringCat(*out, "data:");
@@ -460,7 +446,7 @@ static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
}
StringFree(payload);
}
freet(body);
freet(file);
if (done) {
ctx->inlined++;
StringFree(path);
@@ -484,547 +470,43 @@ fallback:
return done;
}
/* ------------------------------------------------------------ */
/* CSS */
/* ------------------------------------------------------------ */
/* Rewrite the CSS in [css,css+len) into out, resolving its references against
base_dir. depth counts the @import nesting. */
static void sf_rewrite_css(sf_ctx *ctx, const char *base_dir, int depth,
const char *css, size_t len, String *out) {
size_t i = 0;
int importing = 0; /* set for exactly one iteration: see the @import branch */
while (i < len) {
const int is_import = importing;
importing = 0;
/* A comment or a string is copied verbatim: a url( inside either is not a
reference. */
if (css[i] == '/' && i + 1 < len && css[i + 1] == '*') {
const size_t start = i;
i += 2;
while (i + 1 < len && !(css[i] == '*' && css[i + 1] == '/'))
i++;
i = i + 1 < len ? i + 2 : len;
StringMemcat(*out, css + start, i - start);
continue;
}
if (css[i] == '@' && sf_span_starts(css + i, len - i, "@import")) {
size_t j = i + 7;
while (j < len && sf_is_space((unsigned char) css[j]))
j++;
/* url(...) here names a stylesheet, so hand it to the url( branch with
the import's class rather than the image/font one. */
if (j < len && sf_span_starts(css + j, len - j, "url(")) {
StringMemcat(*out, css + i, j - i);
i = j;
importing = 1;
continue;
}
if (j < len && (css[j] == '"' || css[j] == '\'')) {
const char quote = css[j];
const size_t vstart = j + 1;
size_t vend = vstart;
while (vend < len && css[vend] != quote && css[vend] != '\n') {
if (css[vend] == '\\' && vend + 1 < len)
vend++; /* an escaped quote does not end the string */
vend++;
}
if (vend < len && css[vend] == quote) {
String repl = STRING_EMPTY;
StringClear(repl);
StringMemcat(*out, css + i, j - i);
if (sf_inline(ctx, base_dir, css + vstart, vend - vstart, SF_C_CSS,
"text/css", NULL, depth, &repl)) {
StringCat(*out, "\"");
StringMemcat(*out, StringBuff(repl), StringLength(repl));
StringCat(*out, "\"");
} else {
StringMemcat(*out, css + j, vend + 1 - j);
}
StringFree(repl);
i = vend + 1;
continue;
}
}
StringMemcat(*out, css + i, 7);
i += 7;
continue;
}
if ((css[i] == 'u' || css[i] == 'U') &&
sf_span_starts(css + i, len - i, "url(") &&
(i == 0 || (!isalnum((unsigned char) css[i - 1]) && css[i - 1] != '-' &&
css[i - 1] != '_'))) {
const size_t start = i;
size_t j = i + 4, vstart, vend;
char quote = '\0';
while (j < len && sf_is_space((unsigned char) css[j]))
j++;
if (j < len && (css[j] == '"' || css[j] == '\'')) {
quote = css[j];
j++;
}
vstart = j;
while (j < len && (quote != '\0' ? css[j] != quote : css[j] != ')') &&
css[j] != '\n') {
if (quote != '\0' && css[j] == '\\' && j + 1 < len)
j++; /* an escaped quote does not end the string */
j++;
}
vend = j;
if (quote != '\0' && j < len && css[j] == quote) {
j++;
while (j < len && sf_is_space((unsigned char) css[j]))
j++;
}
if (j < len && css[j] == ')') {
String repl = STRING_EMPTY;
j++;
StringClear(repl);
/* Emit unquoted: the CSS could be a style="..." attribute value, where
a quote would end the attribute. A data: payload and an escaped path
both stay inside the unquoted url-token alphabet. */
if (sf_inline(ctx, base_dir, css + vstart, vend - vstart,
is_import ? SF_C_CSS : SF_C_IMAGE | SF_C_FONT,
is_import ? "text/css" : NULL,
!is_import && depth > 0 ? ctx->page_dir : NULL, depth,
&repl)) {
StringCat(*out, "url(");
StringMemcat(*out, StringBuff(repl), StringLength(repl));
StringCat(*out, ")");
} else {
StringMemcat(*out, css + start, j - start);
}
StringFree(repl);
i = j;
continue;
}
StringMemcat(*out, css + start, 4); /* unterminated url(: leave it */
i = start + 4;
continue;
}
if (css[i] == '"' || css[i] == '\'') {
const char quote = css[i];
const size_t start = i;
i++;
while (i < len && css[i] != quote && css[i] != '\n') {
if (css[i] == '\\' && i + 1 < len)
i++;
i++;
}
if (i < len && css[i] == quote)
i++;
StringMemcat(*out, css + start, i - start);
continue;
}
StringAddchar(*out, css[i]);
i++;
}
/* The mark ends a reference; these end the token the mark was appended to. */
static int sf_is_ref_delim(int c) {
return sf_is_space(c) || c == '"' || c == '\'' || c == '(' || c == ')' ||
c == '=' || c == ',' || c == '<' || c == '>' || c == ';';
}
/* ------------------------------------------------------------ */
/* HTML */
/* ------------------------------------------------------------ */
/* Copy [body,body+len) to out, replacing each marked reference by its data:
URI, or by the bare reference when it cannot be inlined. */
static void sf_expand(sf_ctx *ctx, const char *base_dir, int depth,
const char *body, size_t len, String *out) {
const size_t marklen = strlen(SINGLEFILE_MARK);
size_t i = 0, flushed = 0;
/* srcset is "url [descriptor]" candidates separated by commas. Per the HTML
candidate-parsing rules the URL token runs to the next whitespace and may
carry trailing commas, so an emitted "data:...;base64,AAAA 2x" round-trips.
*/
static void sf_rewrite_srcset(sf_ctx *ctx, const char *v, size_t len,
String *out) {
size_t i = 0;
int first = 1;
while (i + marklen <= len) {
size_t start, tail;
while (i < len) {
size_t ustart, uend, dstart, dend;
while (i < len && (sf_is_space((unsigned char) v[i]) || v[i] == ','))
i++;
if (i >= len)
break;
ustart = i;
while (i < len && !sf_is_space((unsigned char) v[i]))
i++;
uend = i;
while (uend > ustart && v[uend - 1] == ',')
uend--;
dstart = dend = i;
if (uend == i) { /* no trailing comma, so a descriptor may follow */
while (i < len && v[i] != ',')
i++;
dstart = uend;
dend = i;
while (dend > dstart && sf_is_space((unsigned char) v[dend - 1]))
dend--;
while (dstart < dend && sf_is_space((unsigned char) v[dstart]))
dstart++;
}
if (!first)
StringCat(*out, ", ");
first = 0;
if (!sf_inline(ctx, ctx->page_dir, v + ustart, uend - ustart, SF_C_IMAGE,
NULL, NULL, 0, out))
StringMemcat(*out, v + ustart, uend - ustart);
if (dend > dstart) {
StringAddchar(*out, ' ');
StringMemcat(*out, v + dstart, dend - dstart);
}
}
}
/* (tag, attribute) pairs naming an inlinable asset; a NULL tag matches any.
Deliberately absent though hts_detect[] downloads them: a@href, iframe@src
and longdesc name pages, dynsrc and non-image source@src name media,
track@src has no inlinable MIME class, usemap is a same-document fragment,
archive lists applet jars. link@href is decided from its rel
(sf_link_classes); base@href needs no rule, htsparse drops it when saving. */
static const struct sf_attr_rule {
const char *tag;
const char *attr;
int classes;
int shape;
} sf_attr_rules[] = {
{"img", "src", SF_C_IMAGE, SF_V_URL},
{"img", "srcset", SF_C_IMAGE, SF_V_SRCSET},
{"source", "src", SF_C_IMAGE, SF_V_URL},
{"source", "srcset", SF_C_IMAGE, SF_V_SRCSET},
{"input", "src", SF_C_IMAGE, SF_V_URL},
{"video", "poster", SF_C_IMAGE, SF_V_URL},
{"image", "href", SF_C_IMAGE, SF_V_URL},
{"image", "xlink:href", SF_C_IMAGE, SF_V_URL},
{"object", "data", SF_C_IMAGE, SF_V_URL},
{"embed", "src", SF_C_IMAGE, SF_V_URL},
{"script", "src", SF_C_JS, SF_V_URL},
/* Lazy loading: src is a placeholder and the real image rides one of
these. The image class keeps a lazy <iframe> or <script> a link. */
{NULL, "data-src", SF_C_IMAGE, SF_V_URL},
{NULL, "data-srcset", SF_C_IMAGE, SF_V_SRCSET},
{NULL, "lowsrc", SF_C_IMAGE, SF_V_URL},
{NULL, "background", SF_C_IMAGE, SF_V_URL},
{NULL, "style", 0, SF_V_CSS},
};
typedef struct sf_attr {
const char *pre; /* whitespace before the attribute */
size_t pre_len;
const char *raw; /* name plus value, with the original quoting */
size_t raw_len;
const char *name;
size_t name_len;
const char *value;
size_t value_len;
hts_boolean has_value;
} sf_attr;
/* Accepted classes and fallback type for <link href>, read from its rel: a
navigational rel (canonical, alternate, next) yields 0 and keeps the link. */
static int sf_link_classes(const sf_attr *attrs, int nattrs,
const char **fallback) {
int i;
*fallback = NULL;
for (i = 0; i < nattrs; i++) {
if (!sf_span_eq(attrs[i].name, attrs[i].name_len, "rel"))
continue;
if (sf_span_has(attrs[i].value, attrs[i].value_len, "stylesheet")) {
*fallback = "text/css";
return SF_C_CSS;
}
if (sf_span_has(attrs[i].value, attrs[i].value_len, "icon"))
return SF_C_IMAGE;
if (sf_span_has(attrs[i].value, attrs[i].value_len, "preload") ||
sf_span_has(attrs[i].value, attrs[i].value_len, "prefetch"))
return SF_C_ANY;
return 0;
}
return 0;
}
static const struct sf_attr_rule *sf_find_rule(const char *tag, size_t tag_len,
const char *attr,
size_t attr_len) {
size_t i;
for (i = 0; i < sizeof(sf_attr_rules) / sizeof(sf_attr_rules[0]); i++) {
const struct sf_attr_rule *const r = &sf_attr_rules[i];
if (r->tag != NULL && !sf_span_eq(tag, tag_len, r->tag))
continue;
if (sf_span_eq(attr, attr_len, r->attr))
return r;
}
return NULL;
}
/* Emit one attribute, substituting its value when a rule applies. */
static void sf_emit_attr(sf_ctx *ctx, const char *tag, size_t tag_len,
const sf_attr *a, int link_classes,
const char *link_fallback, String *out) {
const struct sf_attr_rule *rule = NULL;
const char *fallback = NULL;
String repl = STRING_EMPTY;
hts_boolean done = HTS_FALSE;
int classes;
StringMemcat(*out, a->pre, a->pre_len);
if (!a->has_value) {
StringMemcat(*out, a->raw, a->raw_len);
return;
}
if (sf_span_eq(tag, tag_len, "link") &&
sf_span_eq(a->name, a->name_len, "href")) {
classes = link_classes;
fallback = link_fallback;
} else {
rule = sf_find_rule(tag, tag_len, a->name, a->name_len);
classes = rule != NULL ? rule->classes : 0;
if (rule != NULL && rule->classes == SF_C_JS)
fallback = "text/javascript";
}
if (rule == NULL && classes == 0) {
StringMemcat(*out, a->raw, a->raw_len);
return;
}
StringClear(repl);
if (rule != NULL && (rule->shape == SF_V_SRCSET || rule->shape == SF_V_CSS)) {
/* These always produce a value; substitute only when it really differs, so
an untouched attribute keeps its original spelling and quoting. */
if (rule->shape == SF_V_SRCSET)
sf_rewrite_srcset(ctx, a->value, a->value_len, &repl);
else
sf_rewrite_css(ctx, ctx->page_dir, 0, a->value, a->value_len, &repl);
done = StringLength(repl) != a->value_len ||
memcmp(StringBuff(repl), a->value, a->value_len) != 0
? HTS_TRUE
: HTS_FALSE;
} else {
done = sf_inline(ctx, ctx->page_dir, a->value, a->value_len, classes,
fallback, NULL, 0, &repl);
}
if (done) {
size_t n;
/* Always re-quote with '"', so a value carrying a literal quote (a CSS
declaration that was single-quoted in the source) must be escaped. */
StringMemcat(*out, a->name, a->name_len);
StringCat(*out, "=\"");
for (n = 0; n < StringLength(repl); n++) {
const char c = StringSub(repl, n);
if (c == '"')
StringCat(*out, "&quot;");
else
StringAddchar(*out, c);
}
StringCat(*out, "\"");
} else {
StringMemcat(*out, a->raw, a->raw_len);
}
StringFree(repl);
}
/* Elements whose content is raw text, never markup. */
static const char *const sf_rawtext_tags[] = {"script", "style", "textarea",
"title"};
/* The raw-text tag matching [tag,tag+len), or NULL. */
static const char *sf_rawtext_tag(const char *tag, size_t len) {
size_t i;
for (i = 0; i < sizeof(sf_rawtext_tags) / sizeof(sf_rawtext_tags[0]); i++) {
if (sf_span_eq(tag, len, sf_rawtext_tags[i]))
return sf_rawtext_tags[i];
}
return NULL;
}
static void sf_rewrite_html_(sf_ctx *ctx, const char *p, size_t len,
String *out) {
size_t i = 0;
while (i < len) {
sf_attr attrs[SF_MAX_ATTRS];
const char *tag, *rawtext, *link_fallback = NULL;
size_t tag_len, tag_start;
int nattrs = 0, k, link_classes = 0;
hts_boolean selfclose = HTS_FALSE, overflow = HTS_FALSE;
if (p[i] != '<') {
StringAddchar(*out, p[i]);
if (memcmp(body + i, SINGLEFILE_MARK, marklen) != 0) {
i++;
continue;
}
if (i + 4 <= len && memcmp(p + i, "<!--", 4) == 0) {
const size_t start = i;
i += 4;
/* "<!-->" and "<!--->" are empty comments, not unterminated ones. */
if (i < len && p[i] == '>')
i++;
else if (i + 2 <= len && memcmp(p + i, "->", 2) == 0)
i += 2;
else {
while (i + 3 <= len && memcmp(p + i, "-->", 3) != 0)
i++;
i = i + 3 <= len ? i + 3 : len;
}
StringMemcat(*out, p + start, i - start);
continue;
}
if (i + 1 >= len || !isalpha((unsigned char) p[i + 1])) {
const size_t start = i; /* doctype, processing instruction, end tag */
i++;
while (i < len && p[i] != '>') {
/* A '>' inside a quoted value does not close the tag. */
if (p[i] == '"' || p[i] == '\'') {
const char q = p[i++];
while (i < len && p[i] != q)
i++;
}
if (i < len)
i++;
}
if (i < len)
i++;
StringMemcat(*out, p + start, i - start);
continue;
}
/* Start tag. Collect every attribute before deciding: <link href> depends
on the rel, which may come after it. */
tag_start = i;
i++;
tag = p + i;
while (i < len && !sf_is_space((unsigned char) p[i]) && p[i] != '>' &&
p[i] != '/')
i++;
tag_len = (size_t) (p + i - tag);
while (i < len) {
const char *const pre = p + i;
size_t pre_len;
sf_attr *a;
while (i < len && sf_is_space((unsigned char) p[i]))
i++;
pre_len = (size_t) (p + i - pre);
if (i >= len || p[i] == '>' ||
(p[i] == '/' && i + 1 < len && p[i + 1] == '>')) {
i = (size_t) (pre - p); /* the caller copies this whitespace */
break;
}
if (nattrs == SF_MAX_ATTRS) {
overflow = HTS_TRUE; /* keep parsing, but stop recording */
nattrs--;
}
a = &attrs[nattrs++];
a->pre = pre;
a->pre_len = pre_len;
a->name = p + i;
a->raw = p + i;
a->has_value = HTS_FALSE;
a->value = NULL;
a->value_len = 0;
while (i < len && !sf_is_space((unsigned char) p[i]) && p[i] != '=' &&
p[i] != '>' && !(p[i] == '/' && i + 1 < len && p[i + 1] == '>'))
i++;
a->name_len = (size_t) (p + i - a->name);
if (a->name_len == 0) { /* a stray '=' or '/': copy it and move on */
i++;
a->name_len = 1;
a->raw_len = 1;
continue;
}
{
const size_t save = i;
while (i < len && sf_is_space((unsigned char) p[i]))
i++;
if (i >= len || p[i] != '=') {
i = save;
a->raw_len = a->name_len;
continue;
}
}
i++;
while (i < len && sf_is_space((unsigned char) p[i]))
i++;
if (i < len && (p[i] == '"' || p[i] == '\'')) {
const char quote = p[i];
i++;
a->value = p + i;
while (i < len && p[i] != quote)
i++;
a->value_len = (size_t) (p + i - a->value);
if (i < len)
i++;
} else {
a->value = p + i;
while (i < len && !sf_is_space((unsigned char) p[i]) && p[i] != '>')
i++;
a->value_len = (size_t) (p + i - a->value);
}
a->has_value = HTS_TRUE;
a->raw_len = (size_t) (p + i - a->raw);
}
if (overflow) { /* pathological tag: copy the whole of it untouched */
StringMemcat(*out, p + tag_start, i - tag_start);
} else {
if (sf_span_eq(tag, tag_len, "link"))
link_classes = sf_link_classes(attrs, nattrs, &link_fallback);
StringAddchar(*out, '<');
StringMemcat(*out, tag, tag_len);
for (k = 0; k < nattrs; k++)
sf_emit_attr(ctx, tag, tag_len, &attrs[k], link_classes, link_fallback,
out);
}
while (i < len && sf_is_space((unsigned char) p[i])) {
StringAddchar(*out, p[i]);
i++;
}
if (i < len && p[i] == '/') {
selfclose = HTS_TRUE;
StringAddchar(*out, '/');
i++;
}
if (i < len && p[i] == '>') {
StringAddchar(*out, '>');
i++;
}
rawtext = selfclose ? NULL : sf_rawtext_tag(tag, tag_len);
if (rawtext != NULL) {
const size_t body = i;
size_t end = i;
while (end < len) {
if (p[end] == '<' && end + 2 < len && p[end + 1] == '/' &&
sf_span_starts(p + end + 2, len - end - 2, rawtext)) {
const size_t after = end + 2 + strlen(rawtext);
if (after >= len || sf_is_space((unsigned char) p[after]) ||
p[after] == '>' || p[after] == '/')
break;
}
end++;
}
if (sf_span_eq(tag, tag_len, "style"))
sf_rewrite_css(ctx, ctx->page_dir, 0, p + body, end - body, out);
else
StringMemcat(*out, p + body, end - body);
i = end;
for (start = i; start > flushed && !sf_is_ref_delim(body[start - 1]);
start--)
;
/* htsparse writes the fragment and the kept query string after the mark;
a data: URI has no use for either. */
for (tail = i + marklen; tail < len && !sf_is_ref_delim(body[tail]); tail++)
;
StringMemcat(*out, body + flushed, start - flushed);
if (!sf_inline(ctx, base_dir, body + start, i - start,
depth > 0 ? ctx->page_dir : NULL, depth, out)) {
StringMemcat(*out, body + start, i - start);
StringMemcat(*out, body + i + marklen, tail - i - marklen);
}
i = tail;
flushed = i;
}
StringMemcat(*out, body + flushed, len - flushed);
}
/* ------------------------------------------------------------ */
@@ -1051,15 +533,7 @@ hts_boolean singlefile_rewrite_html(httrackp *opt, const char *root,
ctx.warn_budget = &budget;
ctx.budget = page_budget;
ctx.inlined = 0;
/* A UTF-16/32 page is not ASCII-delimited, so byte scanning would corrupt
it; every ASCII-compatible charset is safe. */
if (html_len >= 2 &&
(((unsigned char) html[0] == 0xff && (unsigned char) html[1] == 0xfe) ||
((unsigned char) html[0] == 0xfe && (unsigned char) html[1] == 0xff))) {
StringMemcat(*out, html, html_len);
} else {
sf_rewrite_html_(&ctx, html, html_len, out);
}
sf_expand(&ctx, StringBuff(dir), 0, html, html_len, out);
StringFree(nroot);
StringFree(npage);
StringFree(dir);

View File

@@ -55,18 +55,27 @@ extern "C" {
so a few hundred bytes of hostile CSS can otherwise ask for gigabytes. */
#define SINGLEFILE_MAX_PAGE_SIZE (64 * 1024 * 1024)
/* Fragment htsparse appends to a saved reference the pass may inline. A
fragment and not a scheme, so the mirror still resolves if the pass never
runs, and so a mirrored .css or .js keeps its marks across an --update. */
#define SINGLEFILE_MARK "#!htsinline"
/* HTS_TRUE if a reference htsparse detected in this context may become a
data: URI. tag_start points at 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. */
hts_boolean singlefile_may_inline(const char *tag_start, const char *attr);
/* 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
/* 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,