Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/20251117115305.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: `[filesystem]` Added a way to follow symlink `EvalSymlinks`
18 changes: 18 additions & 0 deletions utils/filesystem/filepath.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
8 changes: 8 additions & 0 deletions utils/filesystem/files_posix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
69 changes: 0 additions & 69 deletions utils/filesystem/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions utils/filesystem/files_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion utils/filesystem/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,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.CorrespondTo(err, "required privilege is not held") || commonerrors.CorrespondTo(err, "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, "")
Expand All @@ -130,6 +130,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 isNotSupportedError(err) || commonerrors.Any(err, afero.ErrNoSymlink, afero.ErrNoReadlink) || commonerrors.CorrespondTo(err, "not supported"):
return commonerrors.WrapError(commonerrors.ErrUnsupported, err, "")
}

return err
Expand Down
180 changes: 180 additions & 0 deletions utils/filesystem/links_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package filesystem

import (
"fmt"
"strings"
"testing"

"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) {
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: <realTmpDir>/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: <symTmpDir>/random -> <realTmpDir>/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)
})
}
}
Loading