From 22780f9cf2fedf7339e24dd468968eb1ddd55b7c Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Mon, 17 Nov 2025 11:53:23 +0000 Subject: [PATCH 1/2] :sparkles: `[filesystem]` Added a way to follow symlink `EvalSymlinks` --- changes/20251117115305.feature | 1 + utils/filesystem/filepath.go | 18 ++++ utils/filesystem/files_test.go | 69 ------------- utils/filesystem/filesystem.go | 5 +- utils/filesystem/links_test.go | 179 +++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 70 deletions(-) create mode 100644 changes/20251117115305.feature create mode 100644 utils/filesystem/links_test.go diff --git a/changes/20251117115305.feature b/changes/20251117115305.feature new file mode 100644 index 0000000000..2b574f9950 --- /dev/null +++ b/changes/20251117115305.feature @@ -0,0 +1 @@ +:sparkles: `[filesystem]` Added a way to follow symlink `EvalSymlinks` diff --git a/utils/filesystem/filepath.go b/utils/filesystem/filepath.go index 5fdcfdb1c6..7b849a6041 100644 --- a/utils/filesystem/filepath.go +++ b/utils/filesystem/filepath.go @@ -260,6 +260,24 @@ func FileTreeDepth(fs FS, root, filePath string) (depth int64, err error) { return } +// EvalSymlinks has the same behaviour as filepath.EvalSymlinks , but can handle different filesystems. +func EvalSymlinks(fs FS, pathWithSymlinks string) (populatedPath string, err error) { + if fs == nil { + return "", commonerrors.UndefinedVariable("filesystem") + } + + // FIXME the following is only true for osfs + // Use https://github.com/spf13/afero/issues/562 whenever it is made available. + p, err := filepath.EvalSymlinks(FilePathToPlatformPathSeparator(fs, pathWithSymlinks)) + if err != nil { + err = commonerrors.WrapIfNotCommonErrorf(commonerrors.ErrUnexpected, ConvertFileSystemError(err), "could not evaluate the path '%v'", pathWithSymlinks) + return + } + + populatedPath = FilePathFromPlatformPathSeparator(fs, p) + return +} + // EndsWithPathSeparator states whether a path is ending with a path separator of not func EndsWithPathSeparator(fs FS, filePath string) bool { return strings.HasSuffix(filePath, "/") || strings.HasSuffix(filePath, string(fs.PathSeparator())) diff --git a/utils/filesystem/files_test.go b/utils/filesystem/files_test.go index c11753e90a..fc90cc0f3b 100644 --- a/utils/filesystem/files_test.go +++ b/utils/filesystem/files_test.go @@ -19,7 +19,6 @@ import ( "time" "github.com/go-faker/faker/v4" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -566,74 +565,6 @@ func TestIsFile(t *testing.T) { } } -func TestLink(t *testing.T) { - if platform.IsWindows() { - fmt.Println("In order to run TestLink on Windows, Developer mode must be enabled: https://github.com/golang/go/pull/24307") - } - for _, fsType := range FileSystemTypes { - t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) { - fs := NewFs(fsType) - tmpDir, err := fs.TempDirInTempDir("test-link-") - require.NoError(t, err) - defer func() { _ = fs.Rm(tmpDir) }() - - txt := fmt.Sprintf("This is a test sentence!!! %v", faker.Sentence()) - tmpFile, err := fs.TouchTempFile(tmpDir, "test-*.txt") - require.NoError(t, err) - err = fs.WriteFile(tmpFile, []byte(txt), 0755) - require.NoError(t, err) - - symlink := filepath.Join(tmpDir, "symlink-tofile") - hardlink := filepath.Join(tmpDir, "hardlink-tofile") - - err = fs.Symlink(tmpFile, symlink) - if commonerrors.Any(err, commonerrors.ErrNotImplemented, commonerrors.ErrForbidden, afero.ErrNoSymlink) { - return - } - require.NoError(t, err) - - err = fs.Link(tmpFile, hardlink) - require.NoError(t, err) - - assert.True(t, fs.Exists(symlink)) - assert.True(t, fs.Exists(hardlink)) - - isLink, err := fs.IsLink(symlink) - require.NoError(t, err) - assert.True(t, isLink) - - isFile, err := fs.IsFile(symlink) - require.NoError(t, err) - assert.True(t, isFile) - - isLink, err = fs.IsLink(hardlink) - require.NoError(t, err) - assert.False(t, isLink) - - isFile, err = fs.IsFile(hardlink) - require.NoError(t, err) - assert.True(t, isFile) - - link, err := fs.Readlink(symlink) - require.NoError(t, err) - assert.Equal(t, tmpFile, link) - - link, err = fs.Readlink(hardlink) - require.Error(t, err) - assert.Empty(t, link) - - bytes, err := fs.ReadFile(symlink) - require.NoError(t, err) - assert.Equal(t, txt, string(bytes)) - - bytes, err = fs.ReadFile(hardlink) - require.NoError(t, err) - assert.Equal(t, txt, string(bytes)) - _ = fs.Rm(tmpDir) - }) - } -} - func TestStatTimes(t *testing.T) { for _, fsType := range FileSystemTypes { t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) { diff --git a/utils/filesystem/filesystem.go b/utils/filesystem/filesystem.go index 8043901be7..c1926778c6 100644 --- a/utils/filesystem/filesystem.go +++ b/utils/filesystem/filesystem.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "os" + "syscall" "github.com/spf13/afero" @@ -113,7 +114,7 @@ func ConvertFileSystemError(err error) error { return commonerrors.WrapError(commonerrors.ErrExists, err, "") case commonerrors.CorrespondTo(err, "bad file descriptor") || os.IsPermission(err) || commonerrors.Any(err, os.ErrPermission, os.ErrClosed, afero.ErrFileClosed, ErrPathNotExist, io.ErrClosedPipe): return commonerrors.WrapError(commonerrors.ErrConflict, err, "") - case commonerrors.CorrespondTo(err, "required privilege is not held") || commonerrors.CorrespondTo(err, "operation not permitted"): + case commonerrors.Any(err, syscall.EPERM, syscall.ERROR_PRIVILEGE_NOT_HELD) || commonerrors.CorrespondTo(err, "required privilege is not held", "operation not permitted"): return commonerrors.WrapError(commonerrors.ErrForbidden, err, "") case os.IsNotExist(err) || commonerrors.Any(err, os.ErrNotExist, afero.ErrFileNotFound) || IsPathNotExist(err) || commonerrors.CorrespondTo(err, "No such file or directory"): return commonerrors.WrapError(commonerrors.ErrNotFound, err, "") @@ -130,6 +131,8 @@ func ConvertFileSystemError(err error) error { case commonerrors.Any(err, io.ErrUnexpectedEOF): // Do not add io.EOF as it is used to read files return commonerrors.WrapError(commonerrors.ErrEOF, err, "") + case commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP, syscall.EWINDOWS, afero.ErrNoSymlink, afero.ErrNoReadlink) || commonerrors.CorrespondTo(err, "not supported"): + return commonerrors.WrapError(commonerrors.ErrUnsupported, err, "") } return err diff --git a/utils/filesystem/links_test.go b/utils/filesystem/links_test.go new file mode 100644 index 0000000000..5c01da1fe7 --- /dev/null +++ b/utils/filesystem/links_test.go @@ -0,0 +1,179 @@ +package filesystem + +import ( + "fmt" + "strings" + "testing" + + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/commonerrors/errortest" + "github.com/ARM-software/golang-utils/utils/platform" + "github.com/go-faker/faker/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func printWarningOnWindows(t *testing.T) { + t.Helper() + if platform.IsWindows() { + t.Log("⚠️ In order to run TestLink on Windows, Developer mode must be enabled: https://github.com/golang/go/pull/24307") + } +} + +func skipIfLinksNotSupported(t *testing.T, err error) { + t.Helper() + if commonerrors.Any(err, commonerrors.ErrNotImplemented, commonerrors.ErrForbidden, commonerrors.ErrUnsupported) { + t.Skipf("⚠️ links not supported on this system: %v", err) + } else { + require.NoError(t, err) + } +} + +func TestLink(t *testing.T) { + printWarningOnWindows(t) + for _, fsType := range FileSystemTypes { + t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) { + fs := NewFs(fsType) + tmpDir, err := fs.TempDirInTempDir("test-link-") + require.NoError(t, err) + defer func() { _ = fs.Rm(tmpDir) }() + + txt := fmt.Sprintf("This is a test sentence!!! %v", faker.Sentence()) + tmpFile, err := fs.TouchTempFile(tmpDir, "test-*.txt") + require.NoError(t, err) + err = fs.WriteFile(tmpFile, []byte(txt), 0755) + require.NoError(t, err) + + symlink := FilePathJoin(fs, tmpDir, "symlink-tofile") + hardlink := FilePathJoin(fs, tmpDir, "hardlink-tofile") + + err = fs.Symlink(tmpFile, symlink) + skipIfLinksNotSupported(t, err) + + err = fs.Link(tmpFile, hardlink) + require.NoError(t, err) + + assert.True(t, fs.Exists(symlink)) + assert.True(t, fs.Exists(hardlink)) + + isLink, err := fs.IsLink(symlink) + require.NoError(t, err) + assert.True(t, isLink) + + isFile, err := fs.IsFile(symlink) + require.NoError(t, err) + assert.True(t, isFile) + + isLink, err = fs.IsLink(hardlink) + require.NoError(t, err) + assert.False(t, isLink) + + isFile, err = fs.IsFile(hardlink) + require.NoError(t, err) + assert.True(t, isFile) + + link, err := fs.Readlink(symlink) + require.NoError(t, err) + assert.Equal(t, tmpFile, link) + + link, err = fs.Readlink(hardlink) + require.Error(t, err) + assert.Empty(t, link) + + bytes, err := fs.ReadFile(symlink) + require.NoError(t, err) + assert.Equal(t, txt, string(bytes)) + + bytes, err = fs.ReadFile(hardlink) + require.NoError(t, err) + assert.Equal(t, txt, string(bytes)) + _ = fs.Rm(tmpDir) + }) + } +} + +func TestEvalSymlinks_ResolvesToRealPath(t *testing.T) { + printWarningOnWindows(t) + for _, fsType := range FileSystemTypes { + t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) { + fs := NewFs(fsType) + tmpDir, err := fs.TempDirInTempDir("test-eval-link-") + require.NoError(t, err) + defer func() { _ = fs.Rm(tmpDir) }() + realTmpDir, err := fs.TempDir(tmpDir, "real-dir-") + require.NoError(t, err) + symTmpDir, err := fs.TempDir(tmpDir, "sym-dir-") + require.NoError(t, err) + // real target: /real/nested/file.txt + realDir := FilePathJoin(fs, realTmpDir, "real", "nested") + require.NoError(t, fs.MkDir(realDir)) + realFile := FilePathJoin(fs, realDir, "file.txt") + expectedContent := fmt.Sprintf("hello world! hello %v! %v", faker.Name(), faker.Sentence()) + require.NoError(t, fs.WriteFile(realFile, []byte(expectedContent), 0o644)) + currentDir, err := fs.CurrentDirectory() + require.NoError(t, err) + expectedAbs := FilePathAbs(fs, realFile, currentDir) + + // First symlink: /random -> /real/nested + symDir := FilePathJoin(fs, symTmpDir, faker.Word()) + err = fs.Symlink(realDir, symDir) + skipIfLinksNotSupported(t, err) + + // symlink to a symlink + symTmpDir2, err := fs.TempDir(tmpDir, "sym-dir2-") + require.NoError(t, err) + symAgain := FilePathJoin(fs, symTmpDir2, fmt.Sprintf("%v-sym2sym", faker.Word())) + err = fs.Symlink(symDir, symAgain) + skipIfLinksNotSupported(t, err) + + pathThroughSymlinks := FilePathJoin(fs, symAgain, "file.txt") + symlinkFile := FilePathJoin(fs, symAgain, "symfile.txt") + err = fs.Symlink(pathThroughSymlinks, symlinkFile) + skipIfLinksNotSupported(t, err) + + resolved, err := EvalSymlinks(fs, pathThroughSymlinks) + require.NoError(t, err) + + resolvedAbs := FilePathAbs(fs, resolved, currentDir) + resolvedAbs = FilePathClean(fs, resolvedAbs) + expectedAbs = FilePathClean(fs, expectedAbs) + + resolved2, err := EvalSymlinks(fs, symlinkFile) + require.NoError(t, err) + resolvedAbs2 := FilePathAbs(fs, resolved2, currentDir) + resolvedAbs2 = FilePathClean(fs, resolvedAbs2) + + if platform.IsWindows() { + assert.True(t, strings.EqualFold(resolvedAbs, expectedAbs)) + assert.True(t, strings.EqualFold(resolvedAbs2, expectedAbs)) + } else { + assert.Equal(t, expectedAbs, resolvedAbs) + assert.Equal(t, expectedAbs, resolvedAbs2) + } + + content, err := fs.ReadFile(symlinkFile) + require.NoError(t, err) + assert.Equal(t, string(content), expectedContent) + + }) + } +} + +func TestEvalSymlinks_notExist(t *testing.T) { + printWarningOnWindows(t) + for _, fsType := range FileSystemTypes { + t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) { + fs := NewFs(fsType) + tmpDir, err := fs.TempDirInTempDir("test-eval-link-not-exist-") + require.NoError(t, err) + defer func() { _ = fs.Rm(tmpDir) }() + _, err = EvalSymlinks(fs, "notexist") + errortest.AssertError(t, err, commonerrors.ErrNotFound) + dest := FilePathJoin(fs, tmpDir, "link") + err = fs.Symlink("notexist", dest) + skipIfLinksNotSupported(t, err) + _, err = EvalSymlinks(fs, dest) + errortest.AssertError(t, err, commonerrors.ErrNotFound) + }) + } +} From c04105779bb38440c7128a4fa29b74145802b968 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Mon, 17 Nov 2025 14:15:13 +0000 Subject: [PATCH 2/2] :green_heart: fix portability failures --- utils/filesystem/files_posix.go | 8 ++++++++ utils/filesystem/files_windows.go | 10 ++++++++++ utils/filesystem/filesystem.go | 5 ++--- utils/filesystem/links_test.go | 7 ++++--- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/utils/filesystem/files_posix.go b/utils/filesystem/files_posix.go index 172578fd18..a1e41ef435 100644 --- a/utils/filesystem/files_posix.go +++ b/utils/filesystem/files_posix.go @@ -10,6 +10,14 @@ import ( "github.com/ARM-software/golang-utils/utils/commonerrors" ) +func isPrivilegeError(err error) bool { + return commonerrors.Any(err, syscall.EPERM) +} + +func isNotSupportedError(err error) bool { + return commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP) +} + func determineFileOwners(info os.FileInfo) (uid, gid int, err error) { if raw, ok := info.Sys().(*syscall.Stat_t); ok { gid = int(raw.Gid) diff --git a/utils/filesystem/files_windows.go b/utils/filesystem/files_windows.go index d16dcd4c07..c7bbf2728d 100644 --- a/utils/filesystem/files_windows.go +++ b/utils/filesystem/files_windows.go @@ -7,8 +7,18 @@ package filesystem import ( "os" "syscall" + + "github.com/ARM-software/golang-utils/utils/commonerrors" ) +func isPrivilegeError(err error) bool { + return commonerrors.Any(err, syscall.EPERM, syscall.ERROR_PRIVILEGE_NOT_HELD) +} + +func isNotSupportedError(err error) bool { + return commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP, syscall.EWINDOWS) +} + func determineFileOwners(_ os.FileInfo) (uid, gid int, err error) { uid = syscall.Getuid() gid = syscall.Getgid() diff --git a/utils/filesystem/filesystem.go b/utils/filesystem/filesystem.go index c1926778c6..7f42720111 100644 --- a/utils/filesystem/filesystem.go +++ b/utils/filesystem/filesystem.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "os" - "syscall" "github.com/spf13/afero" @@ -114,7 +113,7 @@ func ConvertFileSystemError(err error) error { return commonerrors.WrapError(commonerrors.ErrExists, err, "") case commonerrors.CorrespondTo(err, "bad file descriptor") || os.IsPermission(err) || commonerrors.Any(err, os.ErrPermission, os.ErrClosed, afero.ErrFileClosed, ErrPathNotExist, io.ErrClosedPipe): return commonerrors.WrapError(commonerrors.ErrConflict, err, "") - case commonerrors.Any(err, syscall.EPERM, syscall.ERROR_PRIVILEGE_NOT_HELD) || commonerrors.CorrespondTo(err, "required privilege is not held", "operation not permitted"): + case isPrivilegeError(err) || commonerrors.CorrespondTo(err, "required privilege is not held", "operation not permitted"): return commonerrors.WrapError(commonerrors.ErrForbidden, err, "") case os.IsNotExist(err) || commonerrors.Any(err, os.ErrNotExist, afero.ErrFileNotFound) || IsPathNotExist(err) || commonerrors.CorrespondTo(err, "No such file or directory"): return commonerrors.WrapError(commonerrors.ErrNotFound, err, "") @@ -131,7 +130,7 @@ func ConvertFileSystemError(err error) error { case commonerrors.Any(err, io.ErrUnexpectedEOF): // Do not add io.EOF as it is used to read files return commonerrors.WrapError(commonerrors.ErrEOF, err, "") - case commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP, syscall.EWINDOWS, afero.ErrNoSymlink, afero.ErrNoReadlink) || commonerrors.CorrespondTo(err, "not supported"): + case isNotSupportedError(err) || commonerrors.Any(err, afero.ErrNoSymlink, afero.ErrNoReadlink) || commonerrors.CorrespondTo(err, "not supported"): return commonerrors.WrapError(commonerrors.ErrUnsupported, err, "") } diff --git a/utils/filesystem/links_test.go b/utils/filesystem/links_test.go index 5c01da1fe7..0ccf770dec 100644 --- a/utils/filesystem/links_test.go +++ b/utils/filesystem/links_test.go @@ -5,12 +5,13 @@ import ( "strings" "testing" - "github.com/ARM-software/golang-utils/utils/commonerrors" - "github.com/ARM-software/golang-utils/utils/commonerrors/errortest" - "github.com/ARM-software/golang-utils/utils/platform" "github.com/go-faker/faker/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/commonerrors/errortest" + "github.com/ARM-software/golang-utils/utils/platform" ) func printWarningOnWindows(t *testing.T) {