Repository: leanprover/verso
Version tested: tag v4.33.0 = 3bdedf29bada13d8103e6c979001c51dcee210c8
Still present on main (ac797d6adcb6ec06c0da7808abcfec90073d9fc5): binFiles and both call
sites are byte-identical to the tag. Only emitSearchBox has moved (681 → 710). Line numbers below
refer to main.
Toolchain: leanprover/lean4:v4.33.0
Platform: Windows 11, x86_64
Impact: Any Verso manual fails to render on Windows. Linux and macOS are unaffected.
Symptom
uncaught exception: no such file or directory (error code: 2)
file: _out/blueprint\html-single\-verso-search\../../../static-web/search\domain-mappers.d.ts
The output directory is created and partially populated, then the run dies on the search assets.
The crux
include_bin_dir walks the directory as a System.FilePath, but keys each entry by that path's
toString — the path relative to the importing source file, complete with the build host's
separator. From that point on the value is a bare String, and every caller has to recover the
filename with dropPrefix against a hand-written literal duplicating the one in the call:
-- src/verso-search/VersoSearch/DomainSearch.lean:269-271
(include_bin_dir "../../../static-web/search").filterMap fun (name, contents) =>
if name.endsWith "domain-mappers.js" then none
else some (name.dropPrefix "../../../static-web/search/" |>.copy, contents)
Those keys are built in src/verso-util/VersoUtil/BinFiles.lean:41-49:
go (base path : System.FilePath) : StateT (Array _) IO Unit := do
let here := base / path -- host-side read path: correct
match (← here.metadata).type with
| .dir =>
for entry in (← here.readDir) do
go base (path / entry.fileName) -- this `/` ends up in the key
| .file =>
...
modify (·.push (path, e)) -- `path` IS the key
path : FilePath and entry.fileName : String (a bare name — IO.FS.DirEntry keeps the directory
separately in root), so that / resolves to instance : HDiv FilePath String FilePath, i.e.
FilePath.join p ⟨sub⟩, which concatenates unconditionally:
def join (p sub : FilePath) : FilePath :=
if sub.isAbsolute then sub
else ⟨p.toString ++ pathSeparator.toString ++ sub.toString⟩
pathSeparator is '\\' on Windows. path is seeded from the call site's string literal — forward
slashes, as written in source — and each recursion appends one host separator; the elaborator
finally emits mkStrLit path.toString.
The observed key corroborates this: ../../../static-web/search\domain-mappers.d.ts is
forward-slashed throughout the portion contributed by the literal, with exactly one backslash,
immediately before the basename. static-web/search/ is flat, so the walk recurses once — one
join, one separator.
Two things then go wrong, and both follow from that choice of key:
1. The prefix comparison is separator-sensitive. The dropped literal ends in /; the key has
\ there. dropPrefix finds no match and returns the string unchanged, so the "filename" is the
entire source-relative path, and emitSearchBox throws writing
<out>/-verso-search/../../../static-web/search\domain-mappers.d.ts.
2. The key points outside the output directory. This is the more serious half: being
source-relative, it begins with ../../../. emitSearchBox
(src/verso-manual/VersoManual.lean:710) writes dir / file after only ensureDir dir, so the
failure is loud. Had it created parent directories first — as the KaTeX loop at
src/verso-manual/VersoManual/Html/Features.lean:98 does — Verso would have silently written its
search assets three levels above the requested output directory. The crash is the lucky outcome.
src/verso/Verso/Output/Html/KaTeX.lean:31-33 has the same shape and is latently affected: its
dropped prefix lies entirely within the literal portion, so it matches, and the key merely comes
out as katex/fonts\KaTeX_AMS-Regular.woff2 — not the katex/fonts/... the docstring promises,
but still writable, so the fonts land correctly today.
The docstring's contract does not hold on Windows either:
the strings are the filenames; the provided path is a prefix of all of them
Suggested fix: stay in System.FilePath
FilePath is already the right tool and is already in use for the traversal — the defect is
leaving it. Lean already models a path as a structured value, and gives both directions of the
conversion:
mkFilePath : List String → FilePath builds a host path from a list of names;
FilePath.components : FilePath → List String recovers the names, normalizing first
(pathSeparators is ['\\', '/'] on Windows, so it folds mixed input).
The names are the platform-neutral datum. So key each entry by its names relative to the
included directory, and convert to a FilePath once, at the point where something actually
touches the disk:
private meta partial def binFiles (base root : System.FilePath) : IO (Array (List String × Expr)) :=
(·.snd) <$> StateT.run (go []) #[]
where
go (rel : List String) : StateT (Array _) IO Unit := do
let here := rel.foldl (· / ·) (base / root)
match (← here.metadata).type with
| .dir =>
for entry in (← here.readDir) do
go (rel ++ [entry.fileName])
| .file =>
let contents ← IO.FS.readBinFile here
let e : Expr := mkApp2 (.const ``Z85.decode []) (mkStrLit (Z85.encode contents)) (toExpr contents.size)
modify (·.push (rel, e))
| .symlink | .other => return ()
FilePath stays inside the elaborator, where it is joined against the real filesystem; the
components are what crosses into the artifact. (Note FilePath.join has no empty-path guard —
("" : FilePath) / "x" yields "\x" on Windows — which is why the accumulator is a component
list seeded with [] rather than an empty FilePath.)
Both call sites then lose their string surgery entirely — the key never becomes a string to begin
with, so there is nothing to concatenate or strip:
-- DomainSearch.lean
(include_bin_dir "../../../static-web/search").filter fun (name, _) =>
name.getLast? != some "domain-mappers.js"
-- KaTeX.lean
(include_bin_dir "../../../../../vendored-js/katex/fonts").map fun (name, contents) =>
("katex" :: "fonts" :: name, contents)
and the consumers build a FilePath only where they write:
-- VersoManual.lean:710
for (name, contents) in searchBoxCode do
IO.FS.writeBinFile (name.foldl (· / ·) dir) contents
Three things fall out of this:
- A key relative to the include root can never contain
.., so the escape hazard above is
impossible by construction rather than something emitSearchBox must guard against.
- No separator is ever baked into the compiled artifact. That matters beyond tidiness:
.olean
files are shared across platforms, so a key joined on the build host would carry \ into a
Linux consumer.
- The
endsWith filter becomes an exact match on the final name, where before a file called
xdomain-mappers.js would also have been dropped.
The docstring should then read that keys are the entry's path components relative to the included
directory. This changes include_bin_dir's key type, but it is an internal utility and both
in-tree call sites are updated in the same change.
Repository:
leanprover/versoVersion tested: tag
v4.33.0=3bdedf29bada13d8103e6c979001c51dcee210c8Still present on
main(ac797d6adcb6ec06c0da7808abcfec90073d9fc5):binFilesand both callsites are byte-identical to the tag. Only
emitSearchBoxhas moved (681 → 710). Line numbers belowrefer to
main.Toolchain:
leanprover/lean4:v4.33.0Platform: Windows 11, x86_64
Impact: Any Verso manual fails to render on Windows. Linux and macOS are unaffected.
Symptom
The output directory is created and partially populated, then the run dies on the search assets.
The crux
include_bin_dirwalks the directory as aSystem.FilePath, but keys each entry by that path'stoString— the path relative to the importing source file, complete with the build host'sseparator. From that point on the value is a bare
String, and every caller has to recover thefilename with
dropPrefixagainst a hand-written literal duplicating the one in the call:Those keys are built in
src/verso-util/VersoUtil/BinFiles.lean:41-49:path : FilePathandentry.fileName : String(a bare name —IO.FS.DirEntrykeeps the directoryseparately in
root), so that/resolves toinstance : HDiv FilePath String FilePath, i.e.FilePath.join p ⟨sub⟩, which concatenates unconditionally:pathSeparatoris'\\'on Windows.pathis seeded from the call site's string literal — forwardslashes, as written in source — and each recursion appends one host separator; the elaborator
finally emits
mkStrLit path.toString.The observed key corroborates this:
../../../static-web/search\domain-mappers.d.tsisforward-slashed throughout the portion contributed by the literal, with exactly one backslash,
immediately before the basename.
static-web/search/is flat, so the walk recurses once — onejoin, one separator.Two things then go wrong, and both follow from that choice of key:
1. The prefix comparison is separator-sensitive. The dropped literal ends in
/; the key has\there.dropPrefixfinds no match and returns the string unchanged, so the "filename" is theentire source-relative path, and
emitSearchBoxthrows writing<out>/-verso-search/../../../static-web/search\domain-mappers.d.ts.2. The key points outside the output directory. This is the more serious half: being
source-relative, it begins with
../../../.emitSearchBox(
src/verso-manual/VersoManual.lean:710) writesdir / fileafter onlyensureDir dir, so thefailure is loud. Had it created parent directories first — as the KaTeX loop at
src/verso-manual/VersoManual/Html/Features.lean:98does — Verso would have silently written itssearch assets three levels above the requested output directory. The crash is the lucky outcome.
src/verso/Verso/Output/Html/KaTeX.lean:31-33has the same shape and is latently affected: itsdropped prefix lies entirely within the literal portion, so it matches, and the key merely comes
out as
katex/fonts\KaTeX_AMS-Regular.woff2— not thekatex/fonts/...the docstring promises,but still writable, so the fonts land correctly today.
The docstring's contract does not hold on Windows either:
Suggested fix: stay in
System.FilePathFilePathis already the right tool and is already in use for the traversal — the defect isleaving it. Lean already models a path as a structured value, and gives both directions of the
conversion:
mkFilePath : List String → FilePathbuilds a host path from a list of names;FilePath.components : FilePath → List Stringrecovers the names, normalizing first(
pathSeparatorsis['\\', '/']on Windows, so it folds mixed input).The names are the platform-neutral datum. So key each entry by its names relative to the
included directory, and convert to a
FilePathonce, at the point where something actuallytouches the disk:
FilePathstays inside the elaborator, where it is joined against the real filesystem; thecomponents are what crosses into the artifact. (Note
FilePath.joinhas no empty-path guard —("" : FilePath) / "x"yields"\x"on Windows — which is why the accumulator is a componentlist seeded with
[]rather than an emptyFilePath.)Both call sites then lose their string surgery entirely — the key never becomes a string to begin
with, so there is nothing to concatenate or strip:
and the consumers build a
FilePathonly where they write:Three things fall out of this:
.., so the escape hazard above isimpossible by construction rather than something
emitSearchBoxmust guard against..oleanfiles are shared across platforms, so a key joined on the build host would carry
\into aLinux consumer.
endsWithfilter becomes an exact match on the final name, where before a file calledxdomain-mappers.jswould also have been dropped.The docstring should then read that keys are the entry's path components relative to the included
directory. This changes
include_bin_dir's key type, but it is an internal utility and bothin-tree call sites are updated in the same change.