Skip to content

A name ending in "url" had its assignment taken as a link - #1743

Merged
xroche merged 12 commits into
masterfrom
js-url-guard
Sep 23, 2026
Merged

xroche merged 12 commits into
masterfrom
js-url-guard

Conversation

@xroche

@xroche xroche commented Sep 22, 2026

Copy link
Copy Markdown
Owner

hts_js_scan_link matched the url keyword before running the guard that decides whether the keyword stands alone. A rejected guard left the match in place, so control fell through to the plain assignment rule and the string became a link. On 3.50.3, var myurl = "hello world"; fetches /hello%20world and rewrites the saved script to "hello%20world.html".

The guard now gates the match, and it asks whether the previous character can continue a JavaScript name. A quote cannot: it ends a string, and both CSS and a template literal put a url() straight after one. Above 127 a name may be Unicode, so only a space ends one. That is every WhiteSpace and LineTerminator the language defines, nine code points rather than the three once spelled out.

Whether the operand interpolates is a question about the operand, so a ${ in it refuses the link. That is what keeps `url('${t}')` out while `url('/r/r1')` stays in. A quote before from is refused at that row instead, because the rows differ: url() legitimately follows a quote and an import clause never does.

obj.url("/x") keeps working through a .url row of its own, beside .href and .open, rather than by weakening the shared predicate. A dot has to keep gluing, or Array.from("/x") and foo.import("x") become links.

That rule reaches only an operand without a file extension. var myurl = "/a/b.png" is still fetched, here and on master, because the dirty-string rule takes it first and this guard never runs. A JavaScript name holding an image path is a plausible asset, so that is worth keeping. It does make the fix narrower than the title reads.

Measured over a corpus giving every URL an extensionless twin, plus a style= attribute, since that mask hid two defects during review. Master mirrors 49 files, this branch's first head 34, this one 44. The five losses against master are all meant: the three #1739 names, an accented letter before url(, and the spurious file an interpolation invents. Nothing is added. A control that also refuses url( after a colon loses eight assets, so the clean result is not a blind differential.

The selftest model now decodes the code point where the engine matches UTF-8 byte triples. The two therefore agree on which characters glue only by accident. Membership is pinned by the hardcoded cases rather than by the sweep, which cannot reach a byte its prefixes do not carry. The model used to refuse every byte above 127 while the engine exempted three. The corpus could not see that disagreement, so a non-ASCII prefix reds the old tree and passes this one.

Closes #1739

xroche and others added 5 commits September 21, 2026 22:23
…" is

The script scanner's import rule wants the keyword, a space, then a
quote. The form almost all module code is written in never reaches the
mirror.

Follow the specifier after "from" when it is a path, and refuse a bare
one. "jquery" names a module the loader resolves through its own
configuration, so following it saves an error page under a real name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
The guard ran after nc was assigned, so a rejected guard left the
keyword matched and the default rule took over. That rule asks nothing
of the operand, so var copyFrom = "hello world" became a URL.

Gate the match on the guard. Refuse every byte JavaScript allows inside
a name, so $from reads as one token rather than as the keyword. Follow
an absolute specifier too, the way a bare import already does, and pin
the sweep at the count its corpus really has.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Treating every byte above 127 as part of a name took in the UTF-8
whitespace U+00A0, U+2028 and U+2029. JavaScript reads those as
boundaries. A dynamic import written after one lost the module base
#1730 gives it, so its specifier resolved against the page.

Take those three sequences back by looking behind the high byte. A
Unicode name still glues, because it really is one name.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
hts_js_scan_link matched "url" before running the guard that decides
whether the keyword stands alone. A rejected guard left the match in
place, so control fell through to the plain assignment rule and the
string became a link: var myurl = "hello world" fetched /hello%20world.

The guard now gates the match, reusing js_glues_keyword. The url row
also serves CSS, so the crawl test mirrors an asset behind every
spelling a stylesheet writes url() in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
xroche and others added 2 commits September 22, 2026 18:29
#1737 squash-merged, so the three commits this branch carried from it now
conflict with the single commit that landed.

Two conflicts, both resolved to this branch: 7ad8005 retires
JSGUARD_NO_NAME_BYTE in favour of JSGUARD_NO_IDENT_BYTE, and the sweep
floor in 01_engine-jsscan rises with the cases this branch adds. Measured
on the merge: 1270122 cases swept, so master's 705631 would have been a
floor the sweep clears without trying.

The merge also produced a duplicated case JSGUARD_NO_IDENT_BYTE with no
conflict marker, because this branch rewrote the NO_NAME_BYTE arm while
master added an arm of its own under the new name. The two bodies were
identical; master's comment said "Unlike the url guard", which this branch
retires, so that copy went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Master gained 489_local-warc-interim-headers.test while this branch was
open, so the merge left two tests claiming 489. Nothing in the diff says
so, and 231_test-names is what reports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
xroche added a commit that referenced this pull request Sep 22, 2026
…first (#1750)

On WSL2's drvfs a write can be refused and land all the same. For that
case #1694 made `write_lock_request` ask, after a refused write, whether
the request is there and newer than `hts-in_progress.lock`. That
question loses a race. The engine polls for the request and deletes it,
so the check sees nothing and writes a second copy. Test 459 then finds
`hts-stop.lock` still in place after the engine paused, which is what
reds the Win32 leg of #1743.

The request is now written as `hts-stop.lock.staged` and moved onto its
real name. The staging file going away is what says the move happened,
and the engine never reads that name, so nothing can take it first.

Test 258 loses the case built on the old check and gains two. A staged
file a refused write left behind is moved rather than written again, and
a request an earlier run left behind is replaced.

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
xroche added a commit that referenced this pull request Sep 22, 2026
Review findings on this branch. Test 490 collided with the number PR
#1743 claims, so the file moves to 491. Its scan reported a clean tree
whatever the file list held, so it now floors the count. It also missed
the alias handed to the compiler rather than written in a header, which
is how a Visual Studio build would carry one, so it reads the project
files too.

The no-argument message in htsback.c goes back to snprintf: its text is
65 bytes, so it cannot fill the field and the conversion bought nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
xroche and others added 5 commits September 22, 2026 21:44
The guard refused four bytes that cannot be part of a JavaScript name, and
every byte above 127 bar three. Measured against master, that cost valid
forms: content:"x"url(a.png), which is one declaration a browser applies,
obj.url("/x") where the operand has no file extension, and url( after any
Unicode space but U+00A0, U+2028 and U+2029.

Ask the question the guard was for. A quote continues a name in code, where
the keyword is inside a string or a template literal, and not in CSS, which
has neither and juxtaposes its tokens. Above 127 only a space ends a name,
so spell every WhiteSpace and LineTerminator the language defines.

A dot keeps gluing, or Array.from("/x") and foo.import("x") become links.
obj.url("/x") gets a .url row of its own instead, beside .href and .open.

Against master over a fixture of every spelling, the result is a strict
subset: every link master finds is still found, less exactly the three
this series fixes.

The model now decodes the code point where the engine matches UTF-8 byte
triples, so the two agree on membership only by accident, and the corpus
carries non-ASCII at all. Dropping U+3000, or the U+00A0 exemption, passed
the old sweep and reds this one. So does dropping the single quote, which
nothing covered.

Test 490 loses three negative controls that no version of this guard
discriminates, and its .url arm takes an operand with no file extension,
because the dirty-string rule rescues one that has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
A backtick glued the keyword whenever the parse was not CSS, and a bundle
never is, so every template literal holding a path lost its link:
`url('/r/r1')` is fetched by master and was not by this branch. An operand
with a file extension hid it, the way one hid obj.url() last round.

The byte before the keyword cannot say whether the operand interpolates.
Test the operand: a "${" in it means code rather than a path. So the
quotes leave the predicate, which no longer needs to know it is in CSS,
and js_glues_keyword goes back to asking one context-free question.

Only the backtick ever reached the scanner. Master follows neither
"url('/x')" nor 'url("/x")' in JavaScript, so dropping all three costs
nothing there and recovers content:"x"url(a.png) in CSS.

A quote before "from" is refused at that row instead. The rows differ:
url() legitimately follows a quote and an import clause never does.
Without it style="font-family:'x'from'/img/i.png'" fetched a file no page
asked for, which master does not.

Measured over a corpus giving every URL an extensionless twin, plus a
style= attribute, since that mask hid both defects: 49 files for master,
34 for the branch point, 44 here. Five losses against master, all of them
meant, being the three #1739 names, an accented letter before url(, and
the spurious file an interpolation invents. No additions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Three from a second review, and the first one corrects a claim of mine.

The widened corpus is not what catches a wrong byte in the space set.
Dropping U+3000, or the non-breaking space exemption, reds the selftest
with ASCII-only prefixes too, because cases[] holds both spellings. What
the corpus bought is narrower: the model used to refuse every byte above
127 while the engine exempted three, and no prefix could reach that
disagreement. The body said the wrong thing and now says this.

U+2028 had no jsscan case. It sat only in the jsimport table, so the
comment claiming cases[] pins membership was false for it, and dropping
it from the space set was caught by the other selftest alone.

Test 490 still wrote four fixture names nothing reads, left behind when
the assertions on them went. Delete the lines rather than re-add a
vacuous assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Measured which mutant they catch, having earlier credited them with work
the case table does. They kill none of the membership mutants: dropping
U+3000 or the non-breaking space exemption reds with ASCII-only prefixes
too. They kill a decode one. "\342\202\240" is U+20A0, which ends in the
byte 0xA0, so an engine reading that byte as a space without looking at
what precedes it disagrees with a model that decodes the whole code point,
and no case has that shape. The comment says so, and names the mutant.

Asking the question that way found a gap. Dropping the lead-byte test on
U+FEFF let BB BF read as a space behind any lead, and nothing caught it.
A U+1EFF case, which must glue, now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
A reviewer asked whether the "${" test being near-verbatim in both is a
coverage hole, since the sweep compares the two and an edit to both would
agree. Measured by removing it from both: the selftest still reds through
cases[], and test 490 still reds on the 404.

So the duplication is safe, and it is safe for a reason the next reader
cannot see from the code. Write it down, or the note that looks like a
smell gets "fixed" by sharing the loop, which is what would make it real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit 6758bc3 into master Sep 23, 2026
39 checks passed
@xroche
xroche deleted the js-url-guard branch September 23, 2026 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A variable named baseurl has its string value fetched as a URL and rewritten

1 participant