Skip to content

Commit e67bcb4

Browse files
committed
Normalize REv2 symlink targets to native format
REv2 SymlinkNode targets use UNIX path separators `/`, and although Windows file paths should handle `/` or `\` equivalently, this is not the case when resolving symlink targets. In particular, symlink reparse buffers are documented as being pathnames, which means they must: 1) use the `\` separator; 2) if targetting a directory, it must not have a trailing `\` separator. We now normalise symlink targets on Windows when building the VFS entries to ensure they obey these rules.
1 parent 9b1e363 commit e67bcb4

File tree

9 files changed

+224
-36
lines changed

9 files changed

+224
-36
lines changed

MODULE.bazel

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ git_override(
2323

2424
git_override(
2525
module_name = "com_github_buildbarn_bb_storage",
26-
commit = "6002cad335378bbdcdda4225d47c7dc2c08a6d94",
27-
remote = "https://github.com/buildbarn/bb-storage.git",
26+
commit = "5d535411b13e77d910bba6c7aab95de88252cd83",
27+
remote = "https://github.com/tomgr/bb-storage.git",
2828
)
2929

3030
git_override(

pkg/filesystem/virtual/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ go_library(
2626
"leaf.go",
2727
"linkable_leaf.go",
2828
"named_attributes_factory.go",
29+
"native_symlink_target_unix.go",
30+
"native_symlink_target_windows.go",
2931
"nfs_handle_allocator.go",
3032
"node.go",
3133
"permissions.go",

pkg/filesystem/virtual/base_symlink_factory.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func (f symlink) readlinkParser() (path.Parser, error) {
3232
if !utf8.Valid(f.target) {
3333
return nil, status.Error(codes.InvalidArgument, "Symbolic link contents are not valid UTF-8")
3434
}
35-
return path.UNIXFormat.NewParser(string(f.target)), nil
35+
return path.LocalFormat.NewParser(string(f.target)), nil
3636
}
3737

3838
func (f symlink) readlinkString() (string, error) {

pkg/filesystem/virtual/cas_initial_contents_fetcher.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,14 @@ func (icf *casInitialContentsFetcher) fetchContentsUnwrapped(fileReadMonitorFact
110110
return nil, status.Errorf(codes.InvalidArgument, "Directory contains multiple children named %#v", entry.Name)
111111
}
112112

113-
leaf := icf.options.symlinkFactory.LookupSymlink([]byte(entry.Target))
113+
// REv2 symlink targets use UNIX path separators. Convert
114+
// to the native platform format so that the stored bytes
115+
// can be returned verbatim by VirtualReadlink.
116+
target, err := nativeSymlinkTarget(entry.Target)
117+
if err != nil {
118+
return nil, util.StatusWrapf(err, "Failed to normalize target of symlink %#v", entry.Name)
119+
}
120+
leaf := icf.options.symlinkFactory.LookupSymlink(target)
114121
children[component] = InitialChild{}.FromLeaf(leaf)
115122
leavesToUnlink = append(leavesToUnlink, leaf)
116123
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !windows
2+
3+
package virtual
4+
5+
// nativeSymlinkTarget converts a REv2 symlink target (UNIX format)
6+
// to the native platform format. On UNIX this is a no-op, as REv2
7+
// already uses UNIX path separators.
8+
func nativeSymlinkTarget(target string) ([]byte, error) {
9+
return []byte(target), nil
10+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//go:build windows
2+
3+
package virtual
4+
5+
import "github.com/buildbarn/bb-storage/pkg/filesystem/path"
6+
7+
// nativeSymlinkTarget converts a REv2 symlink target (UNIX format)
8+
// to the native platform format. On Windows this converts forward
9+
// slashes to backslashes and strips trailing directory separators
10+
// that would cause issues in reparse point substitute names.
11+
func nativeSymlinkTarget(target string) ([]byte, error) {
12+
targetParser := path.UNIXFormat.NewParser(target)
13+
targetPath, scopeWalker := path.EmptyBuilder.Join(path.VoidScopeWalker)
14+
if err := path.Resolve(targetParser, scopeWalker); err != nil {
15+
return nil, err
16+
}
17+
s, err := targetPath.GetWindowsString(path.WindowsPathFormatStandard)
18+
if err != nil {
19+
return nil, err
20+
}
21+
return []byte(s), nil
22+
}

pkg/filesystem/virtual/winfsp/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@ go_test(
6868
tags = ["manual"],
6969
deps = select({
7070
"@rules_go//go/platform:windows": [
71+
"//pkg/cas",
7172
"//pkg/filesystem/pool",
7273
"//pkg/filesystem/virtual",
7374
"//pkg/filesystem/virtual/configuration",
7475
"//pkg/proto/configuration/filesystem/virtual",
76+
"@bazel_remote_apis//build/bazel/remote/execution/v2:remote_execution_go_proto",
7577
"@com_github_buildbarn_bb_storage//pkg/blockdevice",
7678
"@com_github_buildbarn_bb_storage//pkg/clock",
79+
"@com_github_buildbarn_bb_storage//pkg/digest",
80+
"@com_github_buildbarn_bb_storage//pkg/filesystem",
81+
"@com_github_buildbarn_bb_storage//pkg/filesystem/path",
7782
"@com_github_buildbarn_bb_storage//pkg/program",
7883
"@com_github_buildbarn_bb_storage//pkg/util",
7984
"@com_github_stretchr_testify//require",

pkg/filesystem/virtual/winfsp/file_system.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,17 +1352,17 @@ func getReparsePointForLeaf(ctx context.Context, leaf virtual.Leaf, buffer []byt
13521352
return 0, nil
13531353
}
13541354

1355-
// Parse the path to determine if it's absolute
1355+
// Symlink targets are stored in native (Windows) format, so
1356+
// we can pass them directly to FillSymlinkReparseBuffer.
13561357
w := relativePathChecker{}
13571358
if err := path.Resolve(path.LocalFormat.NewParser(string(target)), &w); err != nil {
13581359
return 0, err
13591360
}
1360-
var flags int
1361+
var flags uint32
13611362
if w.isRelative {
1362-
flags = windowsext.SYMLINK_FLAG_RELATIVE
1363+
flags = uint32(windowsext.SYMLINK_FLAG_RELATIVE)
13631364
}
1364-
1365-
return FillSymlinkReparseBuffer(string(target), uint32(flags), buffer)
1365+
return FillSymlinkReparseBuffer(string(target), flags, buffer)
13661366
}
13671367

13681368
func (fs *FileSystem) GetReparsePoint(ref *ffi.FileSystemRef, file uintptr, name string, buffer []byte) (int, error) {

pkg/filesystem/virtual/winfsp/file_system_integration_test.go

Lines changed: 169 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import (
1414
"strings"
1515
"testing"
1616

17+
remoteexecution "github.com/bazelbuild/remote-apis/build/bazel/remote/execution/v2"
1718
"github.com/bazelbuild/rules_go/go/runfiles"
19+
"github.com/buildbarn/bb-remote-execution/pkg/cas"
1820
"github.com/buildbarn/bb-remote-execution/pkg/filesystem/pool"
1921
"github.com/buildbarn/bb-remote-execution/pkg/filesystem/virtual"
2022
virtual_configuration "github.com/buildbarn/bb-remote-execution/pkg/filesystem/virtual/configuration"
2123
virtual_pb "github.com/buildbarn/bb-remote-execution/pkg/proto/configuration/filesystem/virtual"
2224
"github.com/buildbarn/bb-storage/pkg/blockdevice"
2325
"github.com/buildbarn/bb-storage/pkg/clock"
26+
"github.com/buildbarn/bb-storage/pkg/digest"
27+
bb_path "github.com/buildbarn/bb-storage/pkg/filesystem/path"
2428
"github.com/buildbarn/bb-storage/pkg/program"
2529
"github.com/buildbarn/bb-storage/pkg/util"
2630
"github.com/stretchr/testify/require"
@@ -39,7 +43,7 @@ func findFreeDriveLetter() (string, error) {
3943
return "", fmt.Errorf("no free drive letters available")
4044
}
4145

42-
func createWinFSPMountForTest(t *testing.T, terminationGroup program.Group, caseSensitive bool) (string, blockdevice.BlockDevice) {
46+
func createWinFSPForTest(t *testing.T, terminationGroup program.Group, caseSensitive bool) (string, blockdevice.BlockDevice, virtual_configuration.Mount, virtual.PrepopulatedDirectory, virtual.SymlinkFactory) {
4347
// We can't run winfsp-tests at a directory path due to
4448
// https://github.com/winfsp/winfsp/issues/279. Instead find a free drive
4549
// letter and run it there instead.
@@ -75,38 +79,42 @@ func createWinFSPMountForTest(t *testing.T, terminationGroup program.Group, case
7579

7680
// Create a virtual directory to hold new files.
7781
defaultAttributesSetter := func(requested virtual.AttributesMask, attributes *virtual.Attributes) {}
78-
err = mount.Expose(
79-
terminationGroup,
80-
virtual.NewInMemoryPrepopulatedDirectory(
81-
virtual.NewHandleAllocatingFileAllocator(
82-
virtual.NewPoolBackedFileAllocator(
83-
pool.NewBlockDeviceBackedFilePool(
84-
bd,
85-
pool.NewBitmapSectorAllocator(uint32(sectorCount)),
86-
sectorSizeBytes,
87-
),
88-
util.DefaultErrorLogger,
89-
defaultAttributesSetter,
90-
virtual.NoNamedAttributesFactory,
82+
symlinkFactory := virtual.NewHandleAllocatingSymlinkFactory(
83+
virtual.BaseSymlinkFactory,
84+
handleAllocator.New(),
85+
)
86+
rootDir := virtual.NewInMemoryPrepopulatedDirectory(
87+
virtual.NewHandleAllocatingFileAllocator(
88+
virtual.NewPoolBackedFileAllocator(
89+
pool.NewBlockDeviceBackedFilePool(
90+
bd,
91+
pool.NewBitmapSectorAllocator(uint32(sectorCount)),
92+
sectorSizeBytes,
9193
),
92-
handleAllocator,
94+
util.DefaultErrorLogger,
95+
defaultAttributesSetter,
96+
virtual.NoNamedAttributesFactory,
9397
),
94-
virtual.NewHandleAllocatingSymlinkFactory(
95-
virtual.BaseSymlinkFactory,
96-
handleAllocator.New(),
97-
),
98-
util.DefaultErrorLogger,
9998
handleAllocator,
100-
sort.Sort,
101-
func(s string) bool { return false },
102-
clock.SystemClock,
103-
normalizer,
104-
defaultAttributesSetter,
105-
virtual.NoNamedAttributesFactory,
10699
),
100+
symlinkFactory,
101+
util.DefaultErrorLogger,
102+
handleAllocator,
103+
sort.Sort,
104+
func(s string) bool { return false },
105+
clock.SystemClock,
106+
normalizer,
107+
defaultAttributesSetter,
108+
virtual.NoNamedAttributesFactory,
107109
)
108-
require.NoError(t, err, "Failed to expose mount point")
109110

111+
return vfsPath, bd, mount, rootDir, symlinkFactory
112+
}
113+
114+
func createWinFSPMountForTest(t *testing.T, terminationGroup program.Group, caseSensitive bool) (string, blockdevice.BlockDevice) {
115+
vfsPath, bd, mount, rootDir, _ := createWinFSPForTest(t, terminationGroup, caseSensitive)
116+
err := mount.Expose(terminationGroup, rootDir)
117+
require.NoError(t, err, "Failed to expose mount point")
110118
return vfsPath, bd
111119
}
112120

@@ -417,6 +425,140 @@ func TestWinFSPFileSystemGetSecurityByNameIntegration(t *testing.T) {
417425
})
418426
}
419427

428+
// staticDirectoryWalker is a simple DirectoryWalker that returns a
429+
// fixed Directory proto. Used to test the CAS ingestion path with
430+
// symlinks whose targets use UNIX path separators (per the REv2 spec).
431+
type staticDirectoryWalker struct {
432+
directory *remoteexecution.Directory
433+
}
434+
435+
func (w *staticDirectoryWalker) GetDirectory(ctx context.Context) (*remoteexecution.Directory, error) {
436+
return w.directory, nil
437+
}
438+
439+
func (w *staticDirectoryWalker) GetChild(d digest.Digest) cas.DirectoryWalker {
440+
return &staticDirectoryWalker{}
441+
}
442+
443+
func (w *staticDirectoryWalker) GetDescription() string {
444+
return "static test directory"
445+
}
446+
447+
func (w *staticDirectoryWalker) GetContainingDigest() digest.Digest {
448+
return digest.MustNewDigest("test", remoteexecution.DigestFunction_SHA256, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0)
449+
}
450+
451+
func TestWinFSPFileSystemStatFollowsSymlink(t *testing.T) {
452+
program.RunLocal(context.Background(), func(ctx context.Context, siblingsGroup, dependenciesGroup program.Group) error {
453+
vfsPath, bd, mount, rootDir, symlinkFactory := createWinFSPForTest(t, dependenciesGroup, false)
454+
defer bd.Close()
455+
456+
// Build a pnpm-style node_modules layout with chained
457+
// symlinks. Symlinks are created through the CAS
458+
// ingestion path (NewCASInitialContentsFetcher) so that
459+
// their UNIX-formatted targets from the REv2 proto are
460+
// normalized to native Windows format, exercising the
461+
// real production code path.
462+
digestFunction := digest.MustNewFunction("test", remoteexecution.DigestFunction_SHA256)
463+
noopFileReadMonitorFactory := virtual.FileReadMonitorFactory(
464+
func(name bb_path.Component) virtual.FileReadMonitor {
465+
return func() {}
466+
})
467+
468+
// Helper to create a chain of nested directories.
469+
mkdirs := func(parent virtual.PrepopulatedDirectory, names ...string) virtual.PrepopulatedDirectory {
470+
for _, name := range names {
471+
child, err := parent.CreateAndEnterPrepopulatedDirectory(bb_path.MustNewComponent(name))
472+
require.NoError(t, err)
473+
parent = child
474+
}
475+
return parent
476+
}
477+
478+
// Helper to create symlink children via the CAS
479+
// ingestion path, so that UNIX-formatted targets from
480+
// the REv2 proto are normalized to native Windows format.
481+
addCASSymlinks := func(parent virtual.PrepopulatedDirectory, symlinks []*remoteexecution.SymlinkNode) {
482+
children, err := virtual.NewCASInitialContentsFetcher(
483+
ctx,
484+
&staticDirectoryWalker{directory: &remoteexecution.Directory{
485+
Symlinks: symlinks,
486+
}},
487+
nil, symlinkFactory, digestFunction,
488+
).FetchContents(noopFileReadMonitorFactory)
489+
require.NoError(t, err)
490+
require.NoError(t, parent.CreateChildren(children, false))
491+
}
492+
493+
// store/pkg — the real directory.
494+
mkdirs(rootDir, "store", "pkg")
495+
496+
// store-link -> store/pkg (single symlink).
497+
addCASSymlinks(rootDir, []*remoteexecution.SymlinkNode{
498+
{Name: "store-link", Target: "store/pkg"},
499+
})
500+
501+
// node_modules/.pnpm/pkg@1.0.0/node_modules/pkg -> ../../../../store/pkg
502+
nmDir := mkdirs(rootDir, "node_modules")
503+
innerNmDir := mkdirs(nmDir, ".pnpm", "pkg@1.0.0", "node_modules")
504+
addCASSymlinks(innerNmDir, []*remoteexecution.SymlinkNode{
505+
{Name: "pkg", Target: "../../../../store/pkg"},
506+
})
507+
508+
// node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg (chained symlink).
509+
addCASSymlinks(nmDir, []*remoteexecution.SymlinkNode{
510+
{Name: "pkg", Target: ".pnpm/pkg@1.0.0/node_modules/pkg"},
511+
})
512+
513+
require.NoError(t, mount.Expose(dependenciesGroup, rootDir))
514+
515+
// Write a file into the real directory after mounting.
516+
testContent := []byte(`{"name":"pkg"}`)
517+
require.NoError(t, os.WriteFile(
518+
filepath.Join(vfsPath, "store", "pkg", "package.json"),
519+
testContent, 0o644,
520+
))
521+
522+
t.Run("SingleSymlink", func(t *testing.T) {
523+
singleSymlinkPath := filepath.Join(vfsPath, "store-link")
524+
info, err := os.Stat(singleSymlinkPath)
525+
require.NoError(t, err)
526+
require.True(t, info.IsDir())
527+
528+
content, err := os.ReadFile(filepath.Join(singleSymlinkPath, "package.json"))
529+
require.NoError(t, err)
530+
require.Equal(t, testContent, content)
531+
})
532+
533+
t.Run("ChainedSymlinks", func(t *testing.T) {
534+
symlinkPath := filepath.Join(vfsPath, "node_modules", "pkg")
535+
536+
info, err := os.Lstat(symlinkPath)
537+
require.NoError(t, err)
538+
require.NotZero(t, info.Mode()&os.ModeSymlink)
539+
540+
target, err := os.Readlink(symlinkPath)
541+
require.NoError(t, err)
542+
require.Equal(t, `.pnpm\pkg@1.0.0\node_modules\pkg`, target)
543+
544+
info, err = os.Stat(symlinkPath)
545+
require.NoError(t, err)
546+
require.True(t, info.IsDir())
547+
548+
content, err := os.ReadFile(filepath.Join(symlinkPath, "package.json"))
549+
require.NoError(t, err)
550+
require.Equal(t, testContent, content)
551+
552+
entries, err := os.ReadDir(symlinkPath)
553+
require.NoError(t, err)
554+
require.Len(t, entries, 1)
555+
require.Equal(t, "package.json", entries[0].Name())
556+
})
557+
558+
return nil
559+
})
560+
}
561+
420562
func TestWinFSPFileSystemCasePreserving(t *testing.T) {
421563
program.RunLocal(context.Background(), func(ctx context.Context, siblingsGroup, dependenciesGroup program.Group) error {
422564
vfsPath, bd := createWinFSPMountForTest(t, dependenciesGroup, false)

0 commit comments

Comments
 (0)