From 94113a48c27fac524c743eb42072c39f6742e447 Mon Sep 17 00:00:00 2001 From: Varun Gokulnath Date: Tue, 11 Nov 2025 14:39:55 -0800 Subject: [PATCH 1/5] Add support for LX symlinks (WSL/MSYS2 native symlinks) - Add reparseTagLxSymlink constant (0xA000001D) - Implement decode logic for LX symlinks (UTF-8 format) - Add IsLxSymlink field to ReparsePoint struct to preserve symlink type - Implement encode logic to recreate LX symlinks on import - Add unit tests for LX symlink round-trip validation Fixes issue where Docker builds with MSYS2 failed with 'unsupported reparse point a000001d' error. Signed-off-by: Varun Gokulnath Signed-off-by: Varun Gokulnath --- reparse.go | 37 +++++++++++++++++++++++++--- reparse_lx_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 reparse_lx_test.go diff --git a/reparse.go b/reparse.go index 67d1a10..c949e3f 100644 --- a/reparse.go +++ b/reparse.go @@ -15,6 +15,7 @@ import ( const ( reparseTagMountPoint = 0xA0000003 reparseTagSymlink = 0xA000000C + reparseTagLxSymlink = 0xA000001D // WSL/MSYS2 native symlinks ) type reparseDataBuffer struct { @@ -31,6 +32,7 @@ type reparseDataBuffer struct { type ReparsePoint struct { Target string IsMountPoint bool + IsLxSymlink bool // True if this is an LX symlink (WSL/MSYS2 native) } // UnsupportedReparsePointError is returned when trying to decode a non-symlink or @@ -56,6 +58,20 @@ func DecodeReparsePointData(tag uint32, b []byte) (*ReparsePoint, error) { case reparseTagMountPoint: isMountPoint = true case reparseTagSymlink: + case reparseTagLxSymlink: + // LX symlinks store the target as UTF-8 after a 4-byte version field + if len(b) < 4 { + return nil, fmt.Errorf("LX symlink buffer too short") + } + targetBytes := b[4:] + for i, c := range targetBytes { + if c == 0 { + targetBytes = targetBytes[:i] + break + } + } + target := string(targetBytes) + return &ReparsePoint{Target: target, IsMountPoint: false, IsLxSymlink: true}, nil default: return nil, &UnsupportedReparsePointError{tag} } @@ -69,16 +85,31 @@ func DecodeReparsePointData(tag uint32, b []byte) (*ReparsePoint, error) { if err != nil { return nil, err } - return &ReparsePoint{string(utf16.Decode(name)), isMountPoint}, nil + return &ReparsePoint{string(utf16.Decode(name)), isMountPoint, false}, nil } func isDriveLetter(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } -// EncodeReparsePoint encodes a Win32 REPARSE_DATA_BUFFER structure describing a symlink or -// mount point. +// EncodeReparsePoint encodes a Win32 REPARSE_DATA_BUFFER structure describing a symlink, +// mount point, or LX symlink. func EncodeReparsePoint(rp *ReparsePoint) []byte { + if rp.IsLxSymlink { + // LX symlink: 4-byte version + UTF-8 target + version := uint32(2) + targetBytes := []byte(rp.Target) + dataLength := 4 + len(targetBytes) + + var b bytes.Buffer + _ = binary.Write(&b, binary.LittleEndian, uint32(reparseTagLxSymlink)) + _ = binary.Write(&b, binary.LittleEndian, uint16(dataLength)) + _ = binary.Write(&b, binary.LittleEndian, uint16(0)) + _ = binary.Write(&b, binary.LittleEndian, version) + _, _ = b.Write(targetBytes) + return b.Bytes() + } + // Generate an NT path and determine if this is a relative path. var ntTarget string relative := false diff --git a/reparse_lx_test.go b/reparse_lx_test.go new file mode 100644 index 0000000..135fcde --- /dev/null +++ b/reparse_lx_test.go @@ -0,0 +1,60 @@ +//go:build windows +// +build windows + +package winio + +import ( + "testing" +) + +func TestLxSymlinkRoundTrip(t *testing.T) { + // Test LX symlink encode/decode + original := &ReparsePoint{ + Target: "/usr/bin/bash", + IsMountPoint: false, + IsLxSymlink: true, + } + + // Encode + encoded := EncodeReparsePoint(original) + + // Decode + decoded, err := DecodeReparsePoint(encoded) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + // Verify + if decoded.Target != original.Target { + t.Errorf("Target mismatch: got %q, want %q", decoded.Target, original.Target) + } + if decoded.IsLxSymlink != original.IsLxSymlink { + t.Errorf("IsLxSymlink mismatch: got %v, want %v", decoded.IsLxSymlink, original.IsLxSymlink) + } + if decoded.IsMountPoint != original.IsMountPoint { + t.Errorf("IsMountPoint mismatch: got %v, want %v", decoded.IsMountPoint, original.IsMountPoint) + } +} + +func TestWindowsSymlinkNotLx(t *testing.T) { + // Test that regular Windows symlinks are not marked as LX + original := &ReparsePoint{ + Target: `C:\Windows\System32`, + IsMountPoint: false, + IsLxSymlink: false, + } + + // Encode + encoded := EncodeReparsePoint(original) + + // Decode + decoded, err := DecodeReparsePoint(encoded) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + // Verify it's NOT an LX symlink + if decoded.IsLxSymlink { + t.Errorf("Windows symlink incorrectly marked as LX symlink") + } +} From 3ffe5606922536778014a34dfc95b1e3123801de Mon Sep 17 00:00:00 2001 From: Varun Gokulnath Date: Tue, 11 Nov 2025 15:06:55 -0800 Subject: [PATCH 2/5] Fix linting issues: remove redundant build tags and use errors.New Signed-off-by: Varun Gokulnath Signed-off-by: Varun Gokulnath --- reparse.go | 4 ++-- reparse_lx_test.go | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/reparse.go b/reparse.go index c949e3f..da8f450 100644 --- a/reparse.go +++ b/reparse.go @@ -1,11 +1,11 @@ //go:build windows -// +build windows package winio import ( "bytes" "encoding/binary" + "errors" "fmt" "strings" "unicode/utf16" @@ -61,7 +61,7 @@ func DecodeReparsePointData(tag uint32, b []byte) (*ReparsePoint, error) { case reparseTagLxSymlink: // LX symlinks store the target as UTF-8 after a 4-byte version field if len(b) < 4 { - return nil, fmt.Errorf("LX symlink buffer too short") + return nil, errors.New("LX symlink buffer too short") } targetBytes := b[4:] for i, c := range targetBytes { diff --git a/reparse_lx_test.go b/reparse_lx_test.go index 135fcde..d4a5f14 100644 --- a/reparse_lx_test.go +++ b/reparse_lx_test.go @@ -1,5 +1,4 @@ //go:build windows -// +build windows package winio @@ -10,14 +9,14 @@ import ( func TestLxSymlinkRoundTrip(t *testing.T) { // Test LX symlink encode/decode original := &ReparsePoint{ - Target: "/usr/bin/bash", + Target: "/usr/bin/bash", IsMountPoint: false, IsLxSymlink: true, } // Encode encoded := EncodeReparsePoint(original) - + // Decode decoded, err := DecodeReparsePoint(encoded) if err != nil { @@ -39,14 +38,14 @@ func TestLxSymlinkRoundTrip(t *testing.T) { func TestWindowsSymlinkNotLx(t *testing.T) { // Test that regular Windows symlinks are not marked as LX original := &ReparsePoint{ - Target: `C:\Windows\System32`, + Target: `C:\Windows\System32`, IsMountPoint: false, IsLxSymlink: false, } // Encode encoded := EncodeReparsePoint(original) - + // Decode decoded, err := DecodeReparsePoint(encoded) if err != nil { From 4efa80daf1e4f3630d7ef392493c96a9263777db Mon Sep 17 00:00:00 2001 From: Varun Gokulnath Date: Wed, 12 Nov 2025 12:35:32 -0800 Subject: [PATCH 3/5] Refactor LX symlink logic into dedicated functions Extract LX symlink encode/decode into separate functions for better maintainability and cleaner separation of concerns. Signed-off-by: Varun Gokulnath Signed-off-by: Varun Gokulnath --- reparse.go | 71 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/reparse.go b/reparse.go index da8f450..849c543 100644 --- a/reparse.go +++ b/reparse.go @@ -53,28 +53,19 @@ func DecodeReparsePoint(b []byte) (*ReparsePoint, error) { } func DecodeReparsePointData(tag uint32, b []byte) (*ReparsePoint, error) { - isMountPoint := false switch tag { case reparseTagMountPoint: - isMountPoint = true + return decodeWindowsReparsePointData(b, true) case reparseTagSymlink: + return decodeWindowsReparsePointData(b, false) case reparseTagLxSymlink: - // LX symlinks store the target as UTF-8 after a 4-byte version field - if len(b) < 4 { - return nil, errors.New("LX symlink buffer too short") - } - targetBytes := b[4:] - for i, c := range targetBytes { - if c == 0 { - targetBytes = targetBytes[:i] - break - } - } - target := string(targetBytes) - return &ReparsePoint{Target: target, IsMountPoint: false, IsLxSymlink: true}, nil + return decodeLxReparsePointData(b) default: return nil, &UnsupportedReparsePointError{tag} } +} + +func decodeWindowsReparsePointData(b []byte, isMountPoint bool) (*ReparsePoint, error) { nameOffset := 8 + binary.LittleEndian.Uint16(b[4:6]) if !isMountPoint { nameOffset += 4 @@ -85,7 +76,23 @@ func DecodeReparsePointData(tag uint32, b []byte) (*ReparsePoint, error) { if err != nil { return nil, err } - return &ReparsePoint{string(utf16.Decode(name)), isMountPoint, false}, nil + return &ReparsePoint{Target: string(utf16.Decode(name)), IsMountPoint: isMountPoint, IsLxSymlink: false}, nil +} + +func decodeLxReparsePointData(b []byte) (*ReparsePoint, error) { + // LX symlinks store the target as UTF-8 after a 4-byte version field + if len(b) < 4 { + return nil, errors.New("LX symlink buffer too short") + } + targetBytes := b[4:] + for i, c := range targetBytes { + if c == 0 { + targetBytes = targetBytes[:i] + break + } + } + target := string(targetBytes) + return &ReparsePoint{Target: target, IsMountPoint: false, IsLxSymlink: true}, nil } func isDriveLetter(c byte) bool { @@ -96,19 +103,27 @@ func isDriveLetter(c byte) bool { // mount point, or LX symlink. func EncodeReparsePoint(rp *ReparsePoint) []byte { if rp.IsLxSymlink { - // LX symlink: 4-byte version + UTF-8 target - version := uint32(2) - targetBytes := []byte(rp.Target) - dataLength := 4 + len(targetBytes) - - var b bytes.Buffer - _ = binary.Write(&b, binary.LittleEndian, uint32(reparseTagLxSymlink)) - _ = binary.Write(&b, binary.LittleEndian, uint16(dataLength)) - _ = binary.Write(&b, binary.LittleEndian, uint16(0)) - _ = binary.Write(&b, binary.LittleEndian, version) - _, _ = b.Write(targetBytes) - return b.Bytes() + return encodeLxReparsePoint(rp) } + return encodeWindowsReparsePoint(rp) +} + +func encodeLxReparsePoint(rp *ReparsePoint) []byte { + // LX symlink: 4-byte version + UTF-8 target + version := uint32(2) + targetBytes := []byte(rp.Target) + dataLength := 4 + len(targetBytes) + + var b bytes.Buffer + _ = binary.Write(&b, binary.LittleEndian, uint32(reparseTagLxSymlink)) + _ = binary.Write(&b, binary.LittleEndian, uint16(dataLength)) + _ = binary.Write(&b, binary.LittleEndian, uint16(0)) + _ = binary.Write(&b, binary.LittleEndian, version) + _, _ = b.Write(targetBytes) + return b.Bytes() +} + +func encodeWindowsReparsePoint(rp *ReparsePoint) []byte { // Generate an NT path and determine if this is a relative path. var ntTarget string From a83edf96d5ad9e2ffd4609dd5d67fb72edee7f0e Mon Sep 17 00:00:00 2001 From: Varun Gokulnath Date: Wed, 12 Nov 2025 12:39:57 -0800 Subject: [PATCH 4/5] Fix linting: remove extra empty line Signed-off-by: Varun Gokulnath Signed-off-by: Varun Gokulnath --- reparse.go | 1 - 1 file changed, 1 deletion(-) diff --git a/reparse.go b/reparse.go index 849c543..013fe15 100644 --- a/reparse.go +++ b/reparse.go @@ -124,7 +124,6 @@ func encodeLxReparsePoint(rp *ReparsePoint) []byte { } func encodeWindowsReparsePoint(rp *ReparsePoint) []byte { - // Generate an NT path and determine if this is a relative path. var ntTarget string relative := false From 164281c34bc4d5bec5ffa2ae5c92297b592d6728 Mon Sep 17 00:00:00 2001 From: Varun Gokulnath Date: Thu, 11 Dec 2025 17:46:08 -0800 Subject: [PATCH 5/5] Addressed Feedback Signed-off-by: Varun Gokulnath --- reparse.go | 8 +++- reparse_lx_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/reparse.go b/reparse.go index 013fe15..9847fbd 100644 --- a/reparse.go +++ b/reparse.go @@ -16,6 +16,8 @@ const ( reparseTagMountPoint = 0xA0000003 reparseTagSymlink = 0xA000000C reparseTagLxSymlink = 0xA000001D // WSL/MSYS2 native symlinks + + lxSymlinkVersion = 2 // LX symlink format version ) type reparseDataBuffer struct { @@ -102,6 +104,9 @@ func isDriveLetter(c byte) bool { // EncodeReparsePoint encodes a Win32 REPARSE_DATA_BUFFER structure describing a symlink, // mount point, or LX symlink. func EncodeReparsePoint(rp *ReparsePoint) []byte { + if rp == nil { + return nil + } if rp.IsLxSymlink { return encodeLxReparsePoint(rp) } @@ -110,7 +115,6 @@ func EncodeReparsePoint(rp *ReparsePoint) []byte { func encodeLxReparsePoint(rp *ReparsePoint) []byte { // LX symlink: 4-byte version + UTF-8 target - version := uint32(2) targetBytes := []byte(rp.Target) dataLength := 4 + len(targetBytes) @@ -118,7 +122,7 @@ func encodeLxReparsePoint(rp *ReparsePoint) []byte { _ = binary.Write(&b, binary.LittleEndian, uint32(reparseTagLxSymlink)) _ = binary.Write(&b, binary.LittleEndian, uint16(dataLength)) _ = binary.Write(&b, binary.LittleEndian, uint16(0)) - _ = binary.Write(&b, binary.LittleEndian, version) + _ = binary.Write(&b, binary.LittleEndian, uint32(lxSymlinkVersion)) _, _ = b.Write(targetBytes) return b.Bytes() } diff --git a/reparse_lx_test.go b/reparse_lx_test.go index d4a5f14..f0c9c4c 100644 --- a/reparse_lx_test.go +++ b/reparse_lx_test.go @@ -6,10 +6,17 @@ import ( "testing" ) +const ( + testLxSymlinkAbsolutePath = "/usr/bin/bash" + testWindowsSymlinkPath = `C:\Windows\System32` + testLxSymlinkRelativePath = "../bin/sh" + testLxSymlinkSpecialCharsPath = "/path/with spaces/and-special!@#$%/файл.txt" +) + func TestLxSymlinkRoundTrip(t *testing.T) { // Test LX symlink encode/decode original := &ReparsePoint{ - Target: "/usr/bin/bash", + Target: testLxSymlinkAbsolutePath, IsMountPoint: false, IsLxSymlink: true, } @@ -38,7 +45,7 @@ func TestLxSymlinkRoundTrip(t *testing.T) { func TestWindowsSymlinkNotLx(t *testing.T) { // Test that regular Windows symlinks are not marked as LX original := &ReparsePoint{ - Target: `C:\Windows\System32`, + Target: testWindowsSymlinkPath, IsMountPoint: false, IsLxSymlink: false, } @@ -57,3 +64,89 @@ func TestWindowsSymlinkNotLx(t *testing.T) { t.Errorf("Windows symlink incorrectly marked as LX symlink") } } + +func TestLxSymlinkEmptyTarget(t *testing.T) { + // Test LX symlink with empty target + original := &ReparsePoint{ + Target: "", + IsMountPoint: false, + IsLxSymlink: true, + } + + // Encode + encoded := EncodeReparsePoint(original) + + // Decode + decoded, err := DecodeReparsePoint(encoded) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + // Verify + if decoded.Target != original.Target { + t.Errorf("Target mismatch: got %q, want %q", decoded.Target, original.Target) + } + if !decoded.IsLxSymlink { + t.Errorf("IsLxSymlink should be true") + } +} + +func TestLxSymlinkRelativePath(t *testing.T) { + // Test LX symlink with relative path + original := &ReparsePoint{ + Target: testLxSymlinkRelativePath, + IsMountPoint: false, + IsLxSymlink: true, + } + + // Encode + encoded := EncodeReparsePoint(original) + + // Decode + decoded, err := DecodeReparsePoint(encoded) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + // Verify + if decoded.Target != original.Target { + t.Errorf("Target mismatch: got %q, want %q", decoded.Target, original.Target) + } + if !decoded.IsLxSymlink { + t.Errorf("IsLxSymlink should be true") + } +} + +func TestLxSymlinkSpecialCharacters(t *testing.T) { + // Test LX symlink with special characters and Unicode + original := &ReparsePoint{ + Target: testLxSymlinkSpecialCharsPath, + IsMountPoint: false, + IsLxSymlink: true, + } + + // Encode + encoded := EncodeReparsePoint(original) + + // Decode + decoded, err := DecodeReparsePoint(encoded) + if err != nil { + t.Fatalf("Failed to decode: %v", err) + } + + // Verify + if decoded.Target != original.Target { + t.Errorf("Target mismatch: got %q, want %q", decoded.Target, original.Target) + } + if !decoded.IsLxSymlink { + t.Errorf("IsLxSymlink should be true") + } +} + +func TestEncodeReparsePointNil(t *testing.T) { + // Test encoding a nil ReparsePoint + encoded := EncodeReparsePoint(nil) + if encoded != nil { + t.Errorf("Expected nil result for nil input, got %v", encoded) + } +}