diff --git a/core/runtime/v2/bundle.go b/core/runtime/v2/bundle.go index 5f50325957..81ae69b6bd 100644 --- a/core/runtime/v2/bundle.go +++ b/core/runtime/v2/bundle.go @@ -102,8 +102,8 @@ func NewBundle(ctx context.Context, root, state, id string, spec typeurl.Any) (b } } paths = append(paths, work) - // symlink workdir - if err := os.Symlink(work, filepath.Join(b.Path, "work")); err != nil { + // create work link (symlink on unix, junction on windows) + if err := createWorkLink(work, b.Path); err != nil { return nil, err } // Spec may be nil for some sandboxers that do not initialize the spec, for @@ -144,7 +144,7 @@ type Bundle struct { // Delete a bundle atomically func (b *Bundle) Delete() error { - work, werr := os.Readlink(filepath.Join(b.Path, "work")) + work, werr := resolveWorkLink(b.Path) rootfs := filepath.Join(b.Path, "rootfs") if err := mount.UnmountRecursive(rootfs, 0); err != nil { return fmt.Errorf("unmount rootfs %s: %w", rootfs, err) diff --git a/core/runtime/v2/bundle_link_unix.go b/core/runtime/v2/bundle_link_unix.go new file mode 100644 index 0000000000..b67bf144f3 --- /dev/null +++ b/core/runtime/v2/bundle_link_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package v2 + +import ( + "os" + "path/filepath" +) + +// createWorkLink creates a symlink from bundlePath/work -> work. +func createWorkLink(work, bundlePath string) error { + return os.Symlink(work, filepath.Join(bundlePath, "work")) +} + +// resolveWorkLink reads the symlink at bundlePath/work to recover the +// working directory path. +func resolveWorkLink(bundlePath string) (string, error) { + return os.Readlink(filepath.Join(bundlePath, "work")) +} diff --git a/core/runtime/v2/bundle_link_windows.go b/core/runtime/v2/bundle_link_windows.go new file mode 100644 index 0000000000..03085ae5e3 --- /dev/null +++ b/core/runtime/v2/bundle_link_windows.go @@ -0,0 +1,94 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package v2 + +import ( + "os" + "path/filepath" + + winio "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +// createWorkLink creates a directory junction from bundlePath/work -> work. +// Junctions do not require administrator privileges or Developer Mode, +// unlike symlinks on Windows. +func createWorkLink(work, bundlePath string) error { + target, err := filepath.Abs(work) + if err != nil { + return err + } + + link := filepath.Join(bundlePath, "work") + if err := os.Mkdir(link, 0711); err != nil { + return err + } + + linkPtr, err := windows.UTF16PtrFromString(link) + if err != nil { + os.Remove(link) + return err + } + + handle, err := windows.CreateFile( + linkPtr, + windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + os.Remove(link) + return err + } + + rp := winio.ReparsePoint{ + Target: target, + IsMountPoint: true, + } + data := winio.EncodeReparsePoint(&rp) + + var bytesReturned uint32 + err = windows.DeviceIoControl( + handle, + windows.FSCTL_SET_REPARSE_POINT, + &data[0], + uint32(len(data)), + nil, + 0, + &bytesReturned, + nil, + ) + // The handle is opened without sharing, so it must be closed before the + // directory can be removed on failure. + windows.CloseHandle(handle) + if err != nil { + os.Remove(link) + return err + } + + return nil +} + +// resolveWorkLink reads the junction/symlink at bundlePath/work to recover +// the working directory path. os.Readlink works for both symlinks and +// junctions on Windows. +func resolveWorkLink(bundlePath string) (string, error) { + return os.Readlink(filepath.Join(bundlePath, "work")) +} diff --git a/core/runtime/v2/bundle_link_windows_test.go b/core/runtime/v2/bundle_link_windows_test.go new file mode 100644 index 0000000000..3398989b4a --- /dev/null +++ b/core/runtime/v2/bundle_link_windows_test.go @@ -0,0 +1,55 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package v2 + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateWorkLinkJunction(t *testing.T) { + dir := t.TempDir() + + work := filepath.Join(dir, "root", "default", "test-id") + bundlePath := filepath.Join(dir, "state", "default", "test-id") + + require.NoError(t, os.MkdirAll(work, 0711), "failed to create work dir") + require.NoError(t, os.MkdirAll(bundlePath, 0700), "failed to create bundle dir") + + // Create the junction + require.NoError(t, createWorkLink(work, bundlePath), "failed to create work link") + + // Verify the junction target resolves back to work + resolved, err := resolveWorkLink(bundlePath) + require.NoError(t, err, "failed to resolve work link") + + absWork, err := filepath.Abs(work) + require.NoError(t, err, "failed to get absolute path") + assert.Equal(t, absWork, resolved, "resolved work link should match absolute work path") + + // Write a file through the junction and read from the target + testContent := []byte("hello") + require.NoError(t, os.WriteFile(filepath.Join(bundlePath, "work", "testfile"), testContent, 0644), "failed to write through junction") + + got, err := os.ReadFile(filepath.Join(work, "testfile")) + require.NoError(t, err, "failed to read from work dir") + assert.Equal(t, testContent, got, "content written through junction should be readable from target") +}