From 0f955e7c4724a32818dc0214a1289cf157442596 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 17 Feb 2026 11:55:09 -0600 Subject: [PATCH 1/3] Populate `PrintName` in junction reparse point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When creating a junction, the `PrintName` field in the [reparse data buffer](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/ca069dad-ed16-42aa-b057-b6b207f447cc) was left empty, which diverges from `mklink /j`. The empty `PrintName` causes junctions to break in Windows Containers (WCoW) during layer serialization, which can be encountered, e.g., by creating and consuming a junction across container build steps: ```dockerfile FROM mcr.microsoft.com/windows/servercore:ltsc2022 RUN ... RUN ... ``` WCoW uses [`microsoft/hcsshim`](https://github.com/microsoft/hcsshim) for layer serialization which in turn uses [`microsoft/go-winio`](https://github.com/microsoft/go-winio) for encoding and decoding reparse points. They [only read `PrintName`](https://github.com/microsoft/go-winio/blob/75610162e7da1816cf16fe88f0da73dea994ea50/reparse.go#L70-L82), and discard `SubstituteName`. When the junction is re-encoded, the empty `PrintName` results in a broken junction. You could argue this is a bug there, I think, but it seems reasonable to write the `PrintName` here too. This was reported downstream in https://github.com/astral-sh/uv/issues/17966#issuecomment-3912222506. I wrote a janky test binary and WCoW build in GitHub Actions to reproduce the reported failure and validated that this change resolves it. Disclosure — I'm far from an expert on junctions and relied on Claude Opus to diagnose and fix this issue. --- src/internals.rs | 112 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/src/internals.rs b/src/internals.rs index 0ba5c2fc..89b37291 100644 --- a/src/internals.rs +++ b/src/internals.rs @@ -24,10 +24,9 @@ const WCHAR_SIZE: u16 = size_of::() as _; pub fn create(target: &Path, junction: &Path) -> io::Result<()> { const UNICODE_NULL_SIZE: u16 = WCHAR_SIZE; - const MAX_AVAILABLE_PATH_BUFFER: u16 = c::MAXIMUM_REPARSE_DATA_BUFFER_SIZE as u16 + const MAX_PATH_BUFFER: u16 = c::MAXIMUM_REPARSE_DATA_BUFFER_SIZE as u16 - c::REPARSE_DATA_BUFFER_HEADER_SIZE - - c::MOUNT_POINT_REPARSE_BUFFER_HEADER_SIZE - - 2 * UNICODE_NULL_SIZE; + - c::MOUNT_POINT_REPARSE_BUFFER_HEADER_SIZE; // We're using low-level APIs to create the junction, and these are more picky about paths. // For example, forward slashes cannot be used as a path separator, so we should try to @@ -37,19 +36,29 @@ pub fn create(target: &Path, junction: &Path) -> io::Result<()> { let target = target.strip_prefix(VERBATIM_PREFIX.as_slice()).unwrap_or(&target); fs::create_dir(junction)?; let file = helpers::open_reparse_point(junction, true)?; - let target_len_in_bytes = { - // "\??\" + target + + // SubstituteName = "\??\" + target (NT path) + let substitute_len_in_bytes = { let len = NT_PREFIX.len().saturating_add(target.len()); let min_len = cmp::min(len, u16::MAX as usize) as u16; - // Len without `UNICODE_NULL` at the end - let target_len_in_bytes = min_len.saturating_mul(WCHAR_SIZE); - // Check for buffer overflow. - if target_len_in_bytes > MAX_AVAILABLE_PATH_BUFFER { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "`target` is too long")); - } - target_len_in_bytes + min_len.saturating_mul(WCHAR_SIZE) + }; + + // PrintName = target (Win32 path, without the \??\ prefix) + let print_name_len_in_bytes = { + let min_len = cmp::min(target.len(), u16::MAX as usize) as u16; + min_len.saturating_mul(WCHAR_SIZE) }; + // Check for buffer overflow: both names + their null terminators must fit + let total_path_buffer = substitute_len_in_bytes + .saturating_add(UNICODE_NULL_SIZE) + .saturating_add(print_name_len_in_bytes) + .saturating_add(UNICODE_NULL_SIZE); + if total_path_buffer > MAX_PATH_BUFFER { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "`target` is too long")); + } + // Redefine the above char array into a ReparseDataBuffer we can work with let mut data = BytesAsReparseDataBuffer::new(); let rdb = data.as_mut_ptr(); @@ -58,24 +67,39 @@ pub fn create(target: &Path, junction: &Path) -> io::Result<()> { addr_of_mut!((*rdb).ReparseTag).write(c::IO_REPARSE_TAG_MOUNT_POINT); addr_of_mut!((*rdb).Reserved).write(0); - // We write target at offset 0 of PathBuffer + // SubstituteName starts at offset 0 in PathBuffer addr_of_mut!((*rdb).ReparseBuffer.SubstituteNameOffset).write(0); - addr_of_mut!((*rdb).ReparseBuffer.SubstituteNameLength).write(target_len_in_bytes); + addr_of_mut!((*rdb).ReparseBuffer.SubstituteNameLength).write(substitute_len_in_bytes); - // We do not use PrintName. However let's set its offset correctly right after SubstituteName - addr_of_mut!((*rdb).ReparseBuffer.PrintNameOffset).write(target_len_in_bytes + UNICODE_NULL_SIZE); - addr_of_mut!((*rdb).ReparseBuffer.PrintNameLength).write(0); + // PrintName starts right after SubstituteName + its null terminator + addr_of_mut!((*rdb).ReparseBuffer.PrintNameOffset).write(substitute_len_in_bytes + UNICODE_NULL_SIZE); + addr_of_mut!((*rdb).ReparseBuffer.PrintNameLength).write(print_name_len_in_bytes); let mut path_buffer_ptr: *mut u16 = addr_of_mut!((*rdb).ReparseBuffer.PathBuffer).cast(); - // Safe because we checked `MAX_AVAILABLE_PATH_BUFFER` + + // Write SubstituteName: "\??\" + target copy_nonoverlapping(NT_PREFIX.as_ptr(), path_buffer_ptr, NT_PREFIX.len()); - // TODO: Do we need to write the NULL-terminator byte? - // It looks like libuv does that. path_buffer_ptr = path_buffer_ptr.add(NT_PREFIX.len()); copy_nonoverlapping(target.as_ptr(), path_buffer_ptr, target.len()); + path_buffer_ptr = path_buffer_ptr.add(target.len()); + + // Null terminator after SubstituteName + path_buffer_ptr.write(0); + path_buffer_ptr = path_buffer_ptr.add(1); + + // Write PrintName: target (Win32 path without \??\ prefix) + copy_nonoverlapping(target.as_ptr(), path_buffer_ptr, target.len()); + path_buffer_ptr = path_buffer_ptr.add(target.len()); + + // Null terminator after PrintName + path_buffer_ptr.write(0); // Set the total size of the data buffer - let size = target_len_in_bytes.wrapping_add(c::MOUNT_POINT_REPARSE_BUFFER_HEADER_SIZE + 2 * UNICODE_NULL_SIZE); + let size = c::MOUNT_POINT_REPARSE_BUFFER_HEADER_SIZE + + substitute_len_in_bytes + + UNICODE_NULL_SIZE + + print_name_len_in_bytes + + UNICODE_NULL_SIZE; addr_of_mut!((*rdb).ReparseDataLength).write(size); size.wrapping_add(c::REPARSE_DATA_BUFFER_HEADER_SIZE) }; @@ -128,3 +152,49 @@ pub fn get_target(junction: &Path) -> io::Result { Err(io::Error::new(io::ErrorKind::Other, "not a reparse tag mount point")) } } + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::os::windows::io::AsRawHandle; + use std::{fs, slice}; + + use super::*; + + #[test] + fn create_populates_print_name() { + // Regression test: the junction reparse point must have a non-empty PrintName + // so that Windows Container layer snapshots correctly preserve the junction target. + let tmpdir = tempfile::tempdir().unwrap(); + let target = tmpdir.path().join("target"); + let junction = tmpdir.path().join("junction"); + fs::create_dir_all(&target).unwrap(); + + create(&target, &junction).unwrap(); + + // Read back the raw reparse data + let file = helpers::open_reparse_point(&junction, false).unwrap(); + let mut data = cast::BytesAsReparseDataBuffer::new(); + helpers::get_reparse_data_point(file.as_raw_handle(), data.as_mut_ptr()).unwrap(); + let rdb = unsafe { data.assume_init() }; + + assert_eq!(rdb.ReparseTag, c::IO_REPARSE_TAG_MOUNT_POINT); + + // Read PrintName + let print_offset = (rdb.ReparseBuffer.PrintNameOffset / WCHAR_SIZE) as usize; + let print_len = (rdb.ReparseBuffer.PrintNameLength / WCHAR_SIZE) as usize; + let print_name = unsafe { + let buf = rdb.ReparseBuffer.PathBuffer.as_ptr().add(print_offset); + slice::from_raw_parts(buf, print_len) + }; + + // PrintName must not be empty + assert!(print_len > 0, "PrintName must not be empty"); + + // PrintName should match what get_target returns (the Win32 path without \??\ prefix) + let print_path = PathBuf::from(OsString::from_wide(print_name)); + let target_path = get_target(&junction).unwrap(); + assert_eq!(print_path, target_path, "PrintName should match the target path"); + } +} From 509e2e16959b895757c24ce56194f3f4ac1cfc05 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sun, 22 Feb 2026 09:00:59 -0600 Subject: [PATCH 2/3] Fix test sharing violation by dropping file handle before get_target The test opened an exclusive handle (share_mode(0)) to the junction and held it while calling get_target(), which tries to open its own handle. Scope the file handle into a block so it is dropped before the second open. --- src/internals.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/internals.rs b/src/internals.rs index 89b37291..1be89867 100644 --- a/src/internals.rs +++ b/src/internals.rs @@ -174,9 +174,11 @@ mod tests { create(&target, &junction).unwrap(); // Read back the raw reparse data - let file = helpers::open_reparse_point(&junction, false).unwrap(); let mut data = cast::BytesAsReparseDataBuffer::new(); - helpers::get_reparse_data_point(file.as_raw_handle(), data.as_mut_ptr()).unwrap(); + { + let file = helpers::open_reparse_point(&junction, false).unwrap(); + helpers::get_reparse_data_point(file.as_raw_handle(), data.as_mut_ptr()).unwrap(); + } let rdb = unsafe { data.assume_init() }; assert_eq!(rdb.ReparseTag, c::IO_REPARSE_TAG_MOUNT_POINT); From 951392ff3516bc7f08782efa6798a3549478d95e Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 23 Feb 2026 18:29:02 -0600 Subject: [PATCH 3/3] Move the test case --- src/internals.rs | 56 ++++-------------------------------------------- src/tests.rs | 47 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/src/internals.rs b/src/internals.rs index 1be89867..188574f1 100644 --- a/src/internals.rs +++ b/src/internals.rs @@ -1,6 +1,6 @@ -mod c; -mod cast; -mod helpers; +pub(crate) mod c; +pub(crate) mod cast; +pub(crate) mod helpers; use std::ffi::OsString; use std::mem::size_of; @@ -20,7 +20,7 @@ const NT_PREFIX: [u16; 4] = helpers::utf16s(br"\??\"); /// Ref: const VERBATIM_PREFIX: [u16; 4] = helpers::utf16s(br"\\?\"); -const WCHAR_SIZE: u16 = size_of::() as _; +pub(crate) const WCHAR_SIZE: u16 = size_of::() as _; pub fn create(target: &Path, junction: &Path) -> io::Result<()> { const UNICODE_NULL_SIZE: u16 = WCHAR_SIZE; @@ -152,51 +152,3 @@ pub fn get_target(junction: &Path) -> io::Result { Err(io::Error::new(io::ErrorKind::Other, "not a reparse tag mount point")) } } - -#[cfg(test)] -mod tests { - use std::ffi::OsString; - use std::os::windows::ffi::OsStringExt; - use std::os::windows::io::AsRawHandle; - use std::{fs, slice}; - - use super::*; - - #[test] - fn create_populates_print_name() { - // Regression test: the junction reparse point must have a non-empty PrintName - // so that Windows Container layer snapshots correctly preserve the junction target. - let tmpdir = tempfile::tempdir().unwrap(); - let target = tmpdir.path().join("target"); - let junction = tmpdir.path().join("junction"); - fs::create_dir_all(&target).unwrap(); - - create(&target, &junction).unwrap(); - - // Read back the raw reparse data - let mut data = cast::BytesAsReparseDataBuffer::new(); - { - let file = helpers::open_reparse_point(&junction, false).unwrap(); - helpers::get_reparse_data_point(file.as_raw_handle(), data.as_mut_ptr()).unwrap(); - } - let rdb = unsafe { data.assume_init() }; - - assert_eq!(rdb.ReparseTag, c::IO_REPARSE_TAG_MOUNT_POINT); - - // Read PrintName - let print_offset = (rdb.ReparseBuffer.PrintNameOffset / WCHAR_SIZE) as usize; - let print_len = (rdb.ReparseBuffer.PrintNameLength / WCHAR_SIZE) as usize; - let print_name = unsafe { - let buf = rdb.ReparseBuffer.PathBuffer.as_ptr().add(print_offset); - slice::from_raw_parts(buf, print_len) - }; - - // PrintName must not be empty - assert!(print_len > 0, "PrintName must not be empty"); - - // PrintName should match what get_target returns (the Win32 path without \??\ prefix) - let print_path = PathBuf::from(OsString::from_wide(print_name)); - let target_path = get_target(&junction).unwrap(); - assert_eq!(print_path, target_path, "PrintName should match the target path"); - } -} diff --git a/src/tests.rs b/src/tests.rs index 2cb3792c..d03ff643 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,8 +1,13 @@ +use std::ffi::OsString; use std::fs::{self, File}; use std::io::{self, Write}; +use std::os::windows::ffi::OsStringExt; use std::os::windows::fs::symlink_file; +use std::os::windows::io::AsRawHandle; #[cfg(miri)] -use std::path::{Path, PathBuf}; +use std::path::Path; +use std::path::PathBuf; +use std::slice; #[cfg(not(miri))] use tempfile::TempDir; @@ -265,3 +270,43 @@ fn create_with_verbatim_prefix_paths() { // get_target returns path without verbatim prefix assert_eq!(super::get_target(&junction).unwrap(), target); } + +#[test] +fn create_populates_print_name() { + // Regression test: the junction reparse point must have a non-empty PrintName + // so that Windows Container layer snapshots correctly preserve the junction target. + use super::internals::{c, cast, helpers, WCHAR_SIZE}; + + let tmpdir = tempfile::tempdir().unwrap(); + let target = tmpdir.path().join("target"); + let junction = tmpdir.path().join("junction"); + fs::create_dir_all(&target).unwrap(); + + super::create(&target, &junction).unwrap(); + + // Read back the raw reparse data + let mut data = cast::BytesAsReparseDataBuffer::new(); + { + let file = helpers::open_reparse_point(&junction, false).unwrap(); + helpers::get_reparse_data_point(file.as_raw_handle(), data.as_mut_ptr()).unwrap(); + } + let rdb = unsafe { data.assume_init() }; + + assert_eq!(rdb.ReparseTag, c::IO_REPARSE_TAG_MOUNT_POINT); + + // Read PrintName + let print_offset = (rdb.ReparseBuffer.PrintNameOffset / WCHAR_SIZE) as usize; + let print_len = (rdb.ReparseBuffer.PrintNameLength / WCHAR_SIZE) as usize; + let print_name = unsafe { + let buf = rdb.ReparseBuffer.PathBuffer.as_ptr().add(print_offset); + slice::from_raw_parts(buf, print_len) + }; + + // PrintName must not be empty + assert!(print_len > 0, "PrintName must not be empty"); + + // PrintName should match what get_target returns (the Win32 path without \??\ prefix) + let print_path = PathBuf::from(OsString::from_wide(print_name)); + let target_path = super::get_target(&junction).unwrap(); + assert_eq!(print_path, target_path, "PrintName should match the target path"); +}