archive: refuse junctions and symlinks in the destination on Windows with OBJ_DONT_REPARSE
Stacked on #41750 (its head branch is the base). Merge that one first.
Problem
- On Windows,
Bun.Archive#extract()andbun createwrite through a junction or a symlink that is already in the destination: with a junctionout\shared,shared/f.txtlands in its target. - The Windows arms give the whole entry path to one open (
src/libarchive/lib.rs).NtCreateFileresolves a link in a path. #41750 fixes POSIX only.
Fix
- A Windows
ContainedDirwith the contract of #41750: a link in place of a parent rejects withELOOPand the entry path. A file entry replaces a link with its name. - Local volume: one
NtCreateFilerelative to the destination handle withOBJ_DONT_REPARSE. The kernel refuses the link. - Network share: the server resolves the path, so each parent opens as itself and its reparse tag decides.
- Verified on the Windows CI lanes only:
test/js/bun/archive.test.ts,test/cli/install/bun-create.test.ts.
Background
- A junction is a directory link that any user can create.
OBJ_DONT_REPARSEfails an open withSTATUS_REPARSE_POINT_ENCOUNTEREDwhen its path has a junction or a symlink. Go'sos.Rootuses it.- Considered the walk for every destination. #31585 was refused for that cost: 3 calls per directory.
Downsides
- Breaking on Windows: an entry below a junction rejects, and
bun createlists conflicts (its check found none before). - Cost from the code, not measured (no Windows machine): local volume, one name query less per entry. Network share, 3 more calls per directory.
- A folder with a mounted volume is refused like a junction.
Notes
State of the verification
No Windows machine was available for this work. The code is type-checked for all 12 targets (bun run rust:check-all). It runs only on the two Windows lanes of CI (Windows Server 2019 x64, Windows 11 aarch64).
| build | commit | what it shows |
|---|---|---|
| 123963 | 70d2e26322, tests only | the reproduction: 18 archive cases and 2 bun create cases fail on both lanes |
| 125461 | f6220eb6cd, tests only | the wider test set on unfixed code: 38 archive cases and 4 bun create cases fail on both lanes. A temporary probe records what NtCreateFile does (below) |
| 125490 | 50c81f7cf8, with the fix | passed. On both lanes test/js/bun/archive.test.ts has 158 pass and 0 fail, and test/cli/install/bun-create.test.ts has 25 pass and 0 fail |
After this run, #44968 landed on main. It fixes the bun create part in another way, so that part of this branch is not necessary now. The Bun.Archive#extract() part is still open on main.
What NtCreateFile does, observed on both lanes (build 125461)
A temporary test called NtCreateFile through bun:ffi with a directory handle as RootDirectory. 86 observations, the same on x64 and arm64. The ones this change depends on:
| root | name | flags | result |
|---|---|---|---|
| local | shared\f.txt (junction in the middle) | FILE_OVERWRITE_IF | created in the target of the junction |
| local | shared\f.txt | FILE_OVERWRITE_IF, OBJ_DONT_REPARSE | 0xC000050B, nothing created. RtlNtStatusToDosError gives 4395 |
| local | shared (junction under the last name) | FILE_OVERWRITE_IF, OBJ_DONT_REPARSE | 0xC000050B. The same for a symlink to a file and a symlink to a directory |
| local | shared | FILE_OPEN, FILE_OPEN_REPARSE_POINT, OBJ_DONT_REPARSE | opens the junction itself: attributes 0x410, tag 0xA0000003 |
| local | dangling (junction to nowhere) | FILE_CREATE, FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT, OBJ_DONT_REPARSE | 0xC0000035 (name taken), target not created |
| local | file.txt\x.txt (a file in place of a parent) | FILE_OVERWRITE_IF, OBJ_DONT_REPARSE | 0xC000003A (path not found) |
\\localhost\C$ | shared\f.txt | FILE_OVERWRITE_IF, OBJ_DONT_REPARSE | created in the target of the junction: the server resolves it |
\\localhost\C$ | shared | FILE_OPEN_IF, FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT | opens the junction itself, tag 0xA0000003, Information says opened |
\\localhost\C$ | the root handle | FileFsDeviceInformation | Characteristics has FILE_REMOTE_DEVICE (0x10). The local root does not |
Behavior changes on Windows
- An entry below a junction or a symlink that is already in the destination rejects with
ELOOP,syscall: "open"andpath= the entry, with/separators. Entries before it stay extracted. - A file entry replaces a junction or a symlink under its own name. Before, a symlink to a file got its target overwritten, and a junction made the default extractor reject.
- A directory entry whose name is taken by a file or a link counts and creates nothing, as on POSIX. Before, a file under the name made the default extractor reject, and a junction to nowhere got its target created.
- The default extractor skips an entry whose name starts with
..after normalization (1:/../../x.txt). Before, it created the file above the destination. The streaming extractor ofbun installalready has this check. bun createlists files that would be overwritten. Its conflict check split the/names of the tarball on\, so it found nothing on Windows.bun createno longer writes.gitignorethrough a symlink. The symlink is replaced.bun installand the--compiledownload keep their calls (DestinationKind::PrivateFresh), except for the..check above.
Cost, counted from the code
For F files in D new one-level directories, calls today and with this change on a local volume:
| case | NtCreateFile | close | GetFinalPathNameByHandle |
|---|---|---|---|
| default extractor, first extraction | F+2D, F+2D | F+D, F+D | F+D, 0 |
| default extractor, second extraction | F, F | F, F | F, 0 |
glob extractor, first extraction | 2F, F+2D | F+D, F+D | F, 0 |
- One
NtQueryVolumeInformationFileperextract()decides between the two resolvers. - A link in the way costs one refused open and one refused probe. A replaced link costs a refused open, a probe, a delete and a create.
- Network share: each directory that is not already open costs an open, a query and a close. A file that exists costs a query and a truncate. Up to 128 directory handles stay open during one
extract(). - Not measured: call counts under Process Monitor, wall clock with antivirus on, allocations. They need a Windows machine.
Not covered
- A destination in a folder that a cloud provider syncs (OneDrive) was not run. Its placeholders are reparse points that are not links. By the documentation,
OBJ_DONT_REPARSEfails only for a reparse that the I/O manager follows. - A folder with a volume mounted on it has the same reparse tag as a junction. It is refused as a parent, and removed like a junction when a file entry has its name.
- The streaming extractor of
bun install(src/install/TarballStream.rs) and the other code that opens a whole path below a directory (bun patch,bun pm pack) are unchanged. - The conflict check of
bun createstill looks only at entries with content. - The choice between reject, skip and follow is open on #41750. This change builds the same answer: reject.
Relation to other pull requests
- #41750: the contract and the POSIX
ContainedDir. This change adds the Windows one beside it. - #31585: walks every parent of every entry. That walk is the second resolver here, with cached handles, for network shares only.
- #41925 adds the same
FILE_ATTRIBUTE_TAG_INFORMATIONbinding. The one that lands second drops its copy.
no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/archive.test.ts