Compare commits

..

9 Commits

Author SHA1 Message Date
Xavier Roche
0b0a409717 Merge branch 'master' into fix/inline-fragment-766
Fix the TESTS-tail union merge's missing continuation backslash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 17:30:23 +02:00
Xavier Roche
076fa7a75c Merge remote-tracking branch 'origin/master' into fix/inline-fragment-766
Signed-off-by: Xavier Roche <roche@httrack.com>

# Conflicts:
#	tests/Makefile.am
2026-07-27 16:51:18 +02:00
Xavier Roche
3886e7855c Merge remote-tracking branch 'origin/master' into fix/inline-fragment-766
Signed-off-by: Xavier Roche <roche@httrack.com>

# Conflicts:
#	tests/Makefile.am
2026-07-27 16:17:42 +02:00
Xavier Roche
efdb73084b Pin every member of the fragment escape set
Only ')' and the '%'/'&' pass-through were exercised, so dropping '"', a
quote, a paren, a backslash, '<'/'>', whitespace or the high-byte rule from
sf_append_escaped left the suite green. The two that would be a real
injection are the '"' that ends the attribute the rewriter re-quotes and the
whitespace that ends an unquoted url() token; the fixture now carries one
fragment per class, and each one dies to its own mutant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 15:05:47 +02:00
Xavier Roche
384a3b0ad9 Merge origin/master into fix/inline-fragment-766 2026-07-27 14:58:48 +02:00
Xavier Roche
f5abb4f706 Trim the test header and drop an unused top_srcdir
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 13:56:02 +02:00
Xavier Roche
4da6d2dd19 Merge origin/master into fix/inline-fragment-766 2026-07-27 13:52:18 +02:00
Xavier Roche
8ab185df01 Do not re-encode escapes the fragment already carried
The fragment is copied out of the document, so its '%' and '&' are the
document's own escapes; percent-encoding them again turns "#a&amp;b" into a
lookup for "a&amp;b". A mirrored path is the opposite case, a raw filesystem
name that has to be escaped, '#' included now that a fragment can follow it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 13:51:29 +02:00
Xavier Roche
fc2cef5e87 --single-file dropped the fragment on an inlined reference
sf_resolve cuts a reference at '#' to find the mirrored file, and sf_inline
then replaced the whole reference with the data: URI it built, so the fragment
never came back. On an SVG that changes what renders: "sprite.svg#icon" selects
one element, and without the selector the browser draws the whole sheet.

Re-attach it to both replacements, the data: URI and the rebased path an
over-cap asset falls back to. The query stays dropped: it named the remote
resource, not the mirrored file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 13:41:51 +02:00
4 changed files with 212 additions and 14 deletions

View File

@@ -5601,12 +5601,20 @@ static int sf_nesting(const char *hay, const char *mime) {
static const char sf_png[] = "\x89PNG\r\n\x1a\n\x00\x01\x02\xff";
#define SF_PNG_LEN 12
static const char sf_svg[] = "<svg><g id=\"icon-a\"/></svg>";
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"
"<style>body { background: url(\"img/a%20b.png\"); }\n"
/* Everything the escape set has to neutralise before a fragment reaches an
unquoted url() token: whitespace, '<'/'>', then a quote, a paren, a
backslash and a high byte together. */
"b { background: url('img/sprite.svg#w x'); }\n"
"i { background: url('img/sprite.svg#lt<s>gt'); }\n"
"u { background: url(\"img/sprite.svg#z'(\\\xC3\xA9\"); }</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"
@@ -5638,6 +5646,19 @@ static const char sf_page[] =
"<script src=\"js/app.js\"></script>\n"
"<script>var s = \"</scripting>\"; var t = \"<img src='img/a%20b.png'>\";"
"</script>\n"
/* A fragment selects inside the asset, so it has to survive onto the
data: URI; the query named the remote resource and must not. */
"<img src=\"img/sprite.svg#icon-a\">\n"
"<img srcset=\"img/sprite.svg#icon-b 2x\">\n"
"<svg><image xlink:href=\"img/sprite.svg#icon-c\"/></svg>\n"
"<div style=\"background:url(img/sprite.svg#icon-d)\"></div>\n"
"<div style=\"background:url('img/sprite.svg#i)e')\"></div>\n"
"<img src=\"img/sprite.svg?v=1#icon-f\">\n"
/* Already carries the document's own escapes: re-encoding either would
make the browser look for a different id. */
"<img src=\"img/sprite.svg#g&amp;h%2Di\">\n"
/* A '"' left raw here would end the attribute the rewriter re-quotes. */
"<img src='img/sprite.svg#q\"z'>\n"
"<img src=\"missing.png\" >\n"
"<!--><img src=\"img/a%20b.png\">\n"
"<div style=\"background:url(img/a%20b.png)\"></div>\n"
@@ -5656,6 +5677,10 @@ static void sf_fixture(const char *root) {
"@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"
"div.s { background: url(../img/big-sprite.svg#icon-g); }\n"
/* A name whose escapes the rebase has to put back, unlike a fragment's;
the '#' has to come back encoded or it reads as one. */
"div.h { background: url(../img/b&amp;c%25d%23e.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";
@@ -5675,6 +5700,9 @@ static void sf_fixture(const char *root) {
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/sprite.svg", sf_svg, sizeof(sf_svg) - 1);
sf_put(root, "img/big-sprite.svg", big, sizeof(big));
sf_put(root, "img/b&c%d#e.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);
@@ -5811,6 +5839,29 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
NULL,
"quote inside a rewritten style attribute not escaped");
/* Fragments: kept on the replacement, escaped where they would close the
url() token, and never joined by the query the mirrored name dropped. */
sf_check(strstr(out, "img/sprite.svg") == NULL,
"a fragment-bearing reference was left a link");
sf_check(sf_count(out, "#icon-a\"") == 1, "img src fragment dropped");
sf_check(sf_count(out, "#icon-b 2x\"") == 1, "srcset fragment dropped");
sf_check(sf_count(out, "#icon-c\"") == 1, "xlink:href fragment dropped");
sf_check(sf_count(out, "#icon-d)") == 1, "style url() fragment dropped");
sf_check(sf_count(out, "#i%29e)") == 1,
"a fragment closing the url() token was not escaped");
sf_check(sf_count(out, "#icon-f\"") == 1, "fragment after a query dropped");
sf_check(strstr(out, "?v=1") == NULL, "query carried onto the data: URI");
sf_check(sf_count(out, "#g&amp;h%2Di\"") == 1,
"an escape the document already carried was encoded again");
sf_check(sf_count(out, "#q%22z\"") == 1,
"a quote in a fragment was not escaped");
sf_check(sf_count(out, "#w%20x)") == 1,
"whitespace in a fragment was not escaped");
sf_check(sf_count(out, "#lt%3Cs%3Egt)") == 1,
"'<'/'>' in a fragment were not escaped");
sf_check(sf_count(out, "#z%27%28%5C%C3%A9)") == 1,
"a quote, paren, backslash or high byte was left raw");
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") ==
@@ -5837,6 +5888,10 @@ static int st_singlefile(httrackp *opt, int argc, char **argv) {
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");
sf_check(strstr(css, "url(img/big-sprite.svg#icon-g)") != NULL,
"a rebased url() lost its fragment");
sf_check(strstr(css, "url(img/b%26c%25d%23e.png)") != NULL,
"a rebased name came back unescaped");
nested = sf_decode(css, "text/css", NULL);
sf_check(nested != NULL &&
strstr(nested, "url(data:image/png;base64,") != NULL,

View File

@@ -92,6 +92,30 @@ static int sf_is_space(int c) {
static int sf_is_sep(int c) { return c == '/' || c == '\\'; }
/* Drop the whitespace surrounding a reference. */
static void sf_trim(const char **ref, size_t *reflen) {
while (*reflen > 0 && sf_is_space((unsigned char) **ref)) {
(*ref)++;
(*reflen)--;
}
while (*reflen > 0 && sf_is_space((unsigned char) (*ref)[*reflen - 1]))
(*reflen)--;
}
/* Text after a reference's first '#', or NULL when it carries no fragment. */
static const char *sf_fragment(const char *ref, size_t reflen, size_t *len) {
size_t i;
sf_trim(&ref, &reflen);
for (i = 0; i < reflen; i++) {
if (ref[i] == '#') {
*len = reflen - i - 1;
return ref + i + 1;
}
}
return NULL;
}
/* 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) {
@@ -202,12 +226,7 @@ static hts_boolean sf_resolve(const sf_ctx *ctx, const char *base_dir,
size_t i, n;
int sp = 0, part;
while (reflen > 0 && sf_is_space((unsigned char) *ref)) {
ref++;
reflen--;
}
while (reflen > 0 && sf_is_space((unsigned char) ref[reflen - 1]))
reflen--;
sf_trim(&ref, &reflen);
/* A mirrored name carries no query or fragment. */
for (n = 0; n < reflen; n++) {
if (ref[n] == '#' || ref[n] == '?')
@@ -347,17 +366,20 @@ static hts_boolean sf_append_base64(String *out, char *data, size_t len) {
return HTS_TRUE;
}
/* Append a mirror-relative path, percent-escaping everything an unquoted CSS
url() or an HTML attribute could choke on. */
static void sf_append_escaped_path(String *out, const char *p) {
/* Append [p,p+len), escaping what an unquoted CSS url(), an HTML attribute or
an appended fragment could choke on. preencoded text already carries the
document's own '%' and '&' escapes, and encoding those again changes it. */
static void sf_append_escaped(String *out, const char *p, size_t len,
hts_boolean preencoded) {
static const char hex[] = "0123456789ABCDEF";
size_t i;
for (i = 0; p[i] != '\0'; i++) {
for (i = 0; i < len; i++) {
const unsigned char c = (unsigned char) p[i];
if (c <= 32 || c >= 127 || c == '"' || c == '\'' || c == '(' || c == ')' ||
c == '\\' || c == '<' || c == '>' || c == '&' || c == '%') {
c == '\\' || c == '<' || c == '>' || c == '#' ||
(!preencoded && (c == '%' || c == '&'))) {
StringAddchar(*out, '%');
StringAddchar(*out, hex[c >> 4]);
StringAddchar(*out, hex[c & 15]);
@@ -367,6 +389,13 @@ static void sf_append_escaped_path(String *out, const char *p) {
}
}
/* Re-attach a fragment: it selects inside the asset (an SVG sprite id) rather
than naming it, so the replacement needs it too. */
static void sf_append_fragment(String *out, const char *frag, size_t len) {
StringAddchar(*out, '#');
sf_append_escaped(out, frag, len, HTS_TRUE);
}
static void sf_warn_oversize(sf_ctx *ctx, const char *path, LLint size,
LLint cap) {
if (*ctx->warn_budget <= 0)
@@ -399,7 +428,8 @@ static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
String path = STRING_EMPTY;
char mime[HTS_MIMETYPE_SIZE];
char *body;
size_t body_len = 0;
size_t body_len = 0, frag_len = 0;
const char *const frag = sf_fragment(ref, reflen, &frag_len);
LLint size, cap;
int cls;
hts_boolean done = HTS_FALSE;
@@ -457,6 +487,8 @@ static hts_boolean sf_inline(sf_ctx *ctx, const char *base_dir, const char *ref,
StringCat(*out, mime);
StringCat(*out, ";base64,");
StringMemcat(*out, StringBuff(payload), StringLength(payload));
if (frag != NULL)
sf_append_fragment(out, frag, frag_len);
}
StringFree(payload);
}
@@ -475,7 +507,9 @@ fallback:
StringClear(rel);
sf_relative_from(rebase_dir, StringBuff(path), &rel);
if (StringLength(rel) > 0) {
sf_append_escaped_path(out, StringBuff(rel));
sf_append_escaped(out, StringBuff(rel), StringLength(rel), HTS_FALSE);
if (frag != NULL)
sf_append_fragment(out, frag, frag_len);
done = HTS_TRUE;
}
StringFree(rel);

View File

@@ -0,0 +1,108 @@
#!/bin/bash
# Issue #766: --single-file substituted the whole reference, dropping the '#'
# fragment an SVG sprite selector needs to pick one icon out of the sheet.
set -e
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_766.XXXXXX") || exit 1
serverpid=
cleanup() {
stop_server "$serverpid"
rm -rf "$tmpdir"
}
trap 'set +e; cleanup' EXIT
trap cleanup HUP INT QUIT PIPE TERM
fail() {
echo "FAIL: $1"
exit 1
}
# --- the site ---------------------------------------------------------------
doc="${tmpdir}/doc"
mkdir -p "$doc"
cat >"${doc}/index.html" <<'EOF'
<html><head><link rel="stylesheet" href="style.css"></head><body>
<img src="sprite.svg#icon-foo" alt="f">
<img srcset="sprite.svg#icon-set 2x" alt="s">
</body></html>
EOF
cat >"${doc}/style.css" <<'EOF'
body { background: url(sprite.svg#icon-css); }
div { background: url(big.svg#icon-big); }
EOF
printf '<svg xmlns="http://www.w3.org/2000/svg"><g id="icon-foo"/></svg>\n' \
>"${doc}/sprite.svg"
{
printf '<svg xmlns="http://www.w3.org/2000/svg"><!--'
head -c 20000 /dev/zero | tr '\0' 'x'
printf '%s\n' '--><g id="icon-big"/></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 || fail "server exited: $(cat "$serverlog")"
sleep 0.1
done
test -n "$port" || fail "could not discover server port"
base="http://127.0.0.1:${port}"
which httrack >/dev/null || fail "could not find httrack"
# --- crawl ------------------------------------------------------------------
# The cap sits between sprite.svg and big.svg, so one inlines and one is left
# for the rebase path.
out="${tmpdir}/crawl"
mkdir "$out"
httrack -O "$out" --quiet --disable-security-limits --robots=0 --timeout=30 \
--retries=0 --single-file --single-file-max-size 4096 \
"${base}/index.html" >"${tmpdir}/log" 2>&1
root="${out}/127.0.0.1_${port}"
page="${root}/index.html"
test -f "$page" || fail "no mirrored page at $page"
test -f "${root}/big.svg" || fail "the over-cap asset never reached the mirror"
b64='[A-Za-z0-9+/=]+'
printf '[fragment survives onto the data: URI] ..\t'
# Matching the data: prefix as well is what makes this more than "#icon-foo is
# somewhere in the page": an un-inlined reference would carry it too.
grep -Eq "src=\"data:image/svg\\+xml;base64,${b64}#icon-foo\"" "$page" ||
fail "img src lost its fragment: $(grep -o 'src="[^"]*"' "$page" | head -n2)"
grep -Eq "srcset=\"data:image/svg\\+xml;base64,${b64}#icon-set 2x\"" "$page" ||
fail "srcset lost its fragment or its descriptor"
echo OK
printf '[fragment survives inside an inlined stylesheet] ..\t'
"$python" - "$page" "${tmpdir}/got.css" <<'PY' || fail "no inlined stylesheet"
import base64, re, sys
html = open(sys.argv[1], "rb").read().decode("utf-8", "replace")
m = re.search(r'data:text/css;base64,([A-Za-z0-9+/=]*)', html)
if m is None:
sys.exit(1)
open(sys.argv[2], "wb").write(base64.b64decode(m.group(1)))
PY
grep -Eq "url\\(data:image/svg\\+xml;base64,${b64}#icon-css\\)" "${tmpdir}/got.css" ||
fail "url() lost its fragment: $(cat "${tmpdir}/got.css")"
# Over the cap, so it stays a link, rebased onto the page's directory: it still
# names the sprite sheet, so it still needs the selector.
grep -q 'url(big.svg#icon-big)' "${tmpdir}/got.css" ||
fail "the rebased over-cap url() lost its fragment"
echo OK

View File

@@ -230,6 +230,7 @@ TESTS = \
105_suite-timeout.test \
110_local-ftp-parallel.test \
111_local-ftp-update-rest.test \
112_local-single-file-fragment.test \
114_local-update-304-leak.test
CLEANFILES = check-network_sh.cache