archive: refuse junctions and symlinks in the destination on Windows with OBJ_DONT_REPARSE

Draft
RO
1mo ago
+1225-137

Stacked on #41750 (its head branch is the base). Merge that one first.

Problem

  • On Windows, Bun.Archive#extract() and bun create write through a junction or a symlink that is already in the destination: with a junction out\shared, shared/f.txt lands in its target.
  • The Windows arms give the whole entry path to one open (src/libarchive/lib.rs). NtCreateFile resolves a link in a path. #41750 fixes POSIX only.

Fix

  • A Windows ContainedDir with the contract of #41750: a link in place of a parent rejects with ELOOP and the entry path. A file entry replaces a link with its name.
  • Local volume: one NtCreateFile relative to the destination handle with OBJ_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_REPARSE fails an open with STATUS_REPARSE_POINT_ENCOUNTERED when its path has a junction or a symlink. Go's os.Root uses 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 create lists 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).

buildcommitwhat it shows
12396370d2e26322, tests onlythe reproduction: 18 archive cases and 2 bun create cases fail on both lanes
125461f6220eb6cd, tests onlythe 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)
12549050c81f7cf8, with the fixpassed. 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:

rootnameflagsresult
localshared\f.txt (junction in the middle)FILE_OVERWRITE_IFcreated in the target of the junction
localshared\f.txtFILE_OVERWRITE_IF, OBJ_DONT_REPARSE0xC000050B, nothing created. RtlNtStatusToDosError gives 4395
localshared (junction under the last name)FILE_OVERWRITE_IF, OBJ_DONT_REPARSE0xC000050B. The same for a symlink to a file and a symlink to a directory
localsharedFILE_OPEN, FILE_OPEN_REPARSE_POINT, OBJ_DONT_REPARSEopens the junction itself: attributes 0x410, tag 0xA0000003
localdangling (junction to nowhere)FILE_CREATE, FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT, OBJ_DONT_REPARSE0xC0000035 (name taken), target not created
localfile.txt\x.txt (a file in place of a parent)FILE_OVERWRITE_IF, OBJ_DONT_REPARSE0xC000003A (path not found)
\\localhost\C$shared\f.txtFILE_OVERWRITE_IF, OBJ_DONT_REPARSEcreated in the target of the junction: the server resolves it
\\localhost\C$sharedFILE_OPEN_IF, FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINTopens the junction itself, tag 0xA0000003, Information says opened
\\localhost\C$the root handleFileFsDeviceInformationCharacteristics 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" and path = 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 of bun install already has this check.
  • bun create lists files that would be overwritten. Its conflict check split the / names of the tarball on \, so it found nothing on Windows.
  • bun create no longer writes .gitignore through a symlink. The symlink is replaced.
  • bun install and the --compile download 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:

caseNtCreateFilecloseGetFinalPathNameByHandle
default extractor, first extractionF+2D, F+2DF+D, F+DF+D, 0
default extractor, second extractionF, FF, FF, 0
glob extractor, first extraction2F, F+2DF+D, F+DF, 0
  • One NtQueryVolumeInformationFile per extract() 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_REPARSE fails 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 create still 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_INFORMATION binding. 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