diff --git a/internal/smartlink/smartlink.go b/internal/smartlink/smartlink.go index feb5134fa5..49fbe2d01d 100644 --- a/internal/smartlink/smartlink.go +++ b/internal/smartlink/smartlink.go @@ -10,7 +10,7 @@ import ( ) // LinkContents will link the contents of src to desc -func LinkContents(src, dest string) error { +func LinkContents(src, dest string, visited map[string]bool) error { if !fileutils.DirExists(src) { return errs.New("src dir does not exist: %s", src) } @@ -24,12 +24,23 @@ func LinkContents(src, dest string) error { return errs.Wrap(err, "Could not resolve src and dest paths") } + if visited == nil { + visited = make(map[string]bool) + } + if _, exists := visited[src]; exists { + // We've encountered a recursive link. This is most often the case when the resolved src has + // already been visited. In that case, just link the dest to the src (which may be a directory; + // this is fine). + return linkFile(src, dest) + } + visited[src] = true + entries, err := os.ReadDir(src) if err != nil { return errs.Wrap(err, "Reading dir %s failed", src) } for _, entry := range entries { - if err := Link(filepath.Join(src, entry.Name()), filepath.Join(dest, entry.Name())); err != nil { + if err := Link(filepath.Join(src, entry.Name()), filepath.Join(dest, entry.Name()), visited); err != nil { return errs.Wrap(err, "Link failed") } } @@ -39,13 +50,24 @@ func LinkContents(src, dest string) error { // Link creates a link from src to target. MS decided to support Symlinks but only if you opt into developer mode (go figure), // which we cannot reasonably force on our users. So on Windows we will instead create dirs and hardlinks. -func Link(src, dest string) error { +func Link(src, dest string, visited map[string]bool) error { var err error src, dest, err = resolvePaths(src, dest) if err != nil { return errs.Wrap(err, "Could not resolve src and dest paths") } + if visited == nil { + visited = make(map[string]bool) + } + if _, exists := visited[src]; exists { + // We've encountered a recursive link. This is most often the case when the resolved src has + // already been visited. In that case, just link the dest to the src (which may be a directory; + // this is fine). + return linkFile(src, dest) + } + visited[src] = true + if fileutils.IsDir(src) { if err := fileutils.Mkdir(dest); err != nil { return errs.Wrap(err, "could not create directory %s", dest) @@ -55,7 +77,7 @@ func Link(src, dest string) error { return errs.Wrap(err, "could not read directory %s", src) } for _, entry := range entries { - if err := Link(filepath.Join(src, entry.Name()), filepath.Join(dest, entry.Name())); err != nil { + if err := Link(filepath.Join(src, entry.Name()), filepath.Join(dest, entry.Name()), visited); err != nil { return errs.Wrap(err, "sub link failed") } } diff --git a/internal/smartlink/smartlink_lin_mac.go b/internal/smartlink/smartlink_lin_mac.go index b19f45d411..edc065f280 100644 --- a/internal/smartlink/smartlink_lin_mac.go +++ b/internal/smartlink/smartlink_lin_mac.go @@ -5,16 +5,10 @@ package smartlink import ( "os" - - "github.com/ActiveState/cli/internal/errs" - "github.com/ActiveState/cli/internal/fileutils" ) // file will create a symlink from src to dest, and falls back on a hardlink if no symlink is available. // This is a workaround for the fact that Windows does not support symlinks without admin privileges. func linkFile(src, dest string) error { - if fileutils.IsDir(src) { - return errs.New("src is a directory, not a file: %s", src) - } return os.Symlink(src, dest) } diff --git a/internal/smartlink/smartlink_test.go b/internal/smartlink/smartlink_test.go new file mode 100644 index 0000000000..5b52fbb0d3 --- /dev/null +++ b/internal/smartlink/smartlink_test.go @@ -0,0 +1,75 @@ +package smartlink + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/ActiveState/cli/internal/fileutils" + "github.com/stretchr/testify/require" +) + +func TestLinkContentsWithCircularLink(t *testing.T) { + srcDir, err := os.MkdirTemp("", "src") + require.NoError(t, err) + defer os.RemoveAll(srcDir) + + destDir, err := os.MkdirTemp("", "dest") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + // Create test file structure: + // src/ + // ├── regular.txt + // └── subdir/ + // ├── circle -> subdir (circular link) + // └── subfile.txt + + testFile := filepath.Join(srcDir, "regular.txt") + err = os.WriteFile(testFile, []byte("test content"), 0644) + require.NoError(t, err) + + subDir := filepath.Join(srcDir, "subdir") + err = os.Mkdir(subDir, 0755) + require.NoError(t, err) + + subFile := filepath.Join(subDir, "subfile.txt") + err = os.WriteFile(subFile, []byte("sub content"), 0644) + require.NoError(t, err) + + circularLink := filepath.Join(subDir, "circle") + err = os.Symlink(subDir, circularLink) + require.NoError(t, err) + + err = LinkContents(srcDir, destDir, nil) + if runtime.GOOS == "windows" { + require.Error(t, err) + return // hard links to directories are not allowed on Windows + } + require.NoError(t, err) + + // Verify file structure. + destFile := filepath.Join(destDir, "regular.txt") + require.FileExists(t, destFile) + content, err := os.ReadFile(destFile) + require.NoError(t, err) + require.Equal(t, "test content", string(content)) + + destSubFile := filepath.Join(destDir, "subdir", "subfile.txt") + require.FileExists(t, destSubFile) + subContent, err := os.ReadFile(destSubFile) + require.NoError(t, err) + require.Equal(t, "sub content", string(subContent)) + + destCircular := filepath.Join(destDir, "subdir", "circle") + require.FileExists(t, destCircular) + target, err := fileutils.ResolveUniquePath(destCircular) + require.NoError(t, err) + srcCircular := filepath.Join(srcDir, "subdir") + if runtime.GOOS == "darwin" { + srcCircular, err = fileutils.ResolveUniquePath(srcCircular) // needed for full $TMPDIR resolution + require.NoError(t, err) + } + require.Equal(t, target, srcCircular) +} diff --git a/pkg/runtime/depot.go b/pkg/runtime/depot.go index 610418e952..c53a1ac1e6 100644 --- a/pkg/runtime/depot.go +++ b/pkg/runtime/depot.go @@ -177,7 +177,7 @@ func (d *depot) DeployViaLink(id strfmt.UUID, relativeSrc, absoluteDest string) } // Copy or link the artifact files, depending on whether the artifact in question relies on file transformations - if err := smartlink.LinkContents(absoluteSrc, absoluteDest); err != nil { + if err := smartlink.LinkContents(absoluteSrc, absoluteDest, nil); err != nil { return errs.Wrap(err, "failed to link artifact") } @@ -295,7 +295,7 @@ func (d *depot) Undeploy(id strfmt.UUID, relativeSrc, path string) error { for sharedFile, relinkSrc := range redeploys { switch deploy.Type { case deploymentTypeLink: - if err := smartlink.Link(relinkSrc, sharedFile); err != nil { + if err := smartlink.Link(relinkSrc, sharedFile, nil); err != nil { return errs.Wrap(err, "failed to relink file") } case deploymentTypeCopy: diff --git a/pkg/runtime/links_windows.go b/pkg/runtime/links_windows.go index 0bdbbe79e8..907882a66f 100644 --- a/pkg/runtime/links_windows.go +++ b/pkg/runtime/links_windows.go @@ -43,7 +43,7 @@ func supportsHardLinks(path string) (supported bool) { } logging.Debug("Attempting to link '%s' to '%s'", lnk, target) - err = smartlink.Link(target, lnk) + err = smartlink.Link(target, lnk, nil) if err != nil { logging.Debug("Test link creation failed: %v", err) return false