Skip to content

Commit 22780f9

Browse files
committed
[filesystem] Added a way to follow symlink EvalSymlinks
1 parent 87d5e0a commit 22780f9

File tree

5 files changed

+202
-70
lines changed

5 files changed

+202
-70
lines changed

changes/20251117115305.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[filesystem]` Added a way to follow symlink `EvalSymlinks`

utils/filesystem/filepath.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,24 @@ func FileTreeDepth(fs FS, root, filePath string) (depth int64, err error) {
260260
return
261261
}
262262

263+
// EvalSymlinks has the same behaviour as filepath.EvalSymlinks , but can handle different filesystems.
264+
func EvalSymlinks(fs FS, pathWithSymlinks string) (populatedPath string, err error) {
265+
if fs == nil {
266+
return "", commonerrors.UndefinedVariable("filesystem")
267+
}
268+
269+
// FIXME the following is only true for osfs
270+
// Use https://github.com/spf13/afero/issues/562 whenever it is made available.
271+
p, err := filepath.EvalSymlinks(FilePathToPlatformPathSeparator(fs, pathWithSymlinks))
272+
if err != nil {
273+
err = commonerrors.WrapIfNotCommonErrorf(commonerrors.ErrUnexpected, ConvertFileSystemError(err), "could not evaluate the path '%v'", pathWithSymlinks)
274+
return
275+
}
276+
277+
populatedPath = FilePathFromPlatformPathSeparator(fs, p)
278+
return
279+
}
280+
263281
// EndsWithPathSeparator states whether a path is ending with a path separator of not
264282
func EndsWithPathSeparator(fs FS, filePath string) bool {
265283
return strings.HasSuffix(filePath, "/") || strings.HasSuffix(filePath, string(fs.PathSeparator()))

utils/filesystem/files_test.go

Lines changed: 0 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"time"
2020

2121
"github.com/go-faker/faker/v4"
22-
"github.com/spf13/afero"
2322
"github.com/stretchr/testify/assert"
2423
"github.com/stretchr/testify/require"
2524

@@ -566,74 +565,6 @@ func TestIsFile(t *testing.T) {
566565
}
567566
}
568567

569-
func TestLink(t *testing.T) {
570-
if platform.IsWindows() {
571-
fmt.Println("In order to run TestLink on Windows, Developer mode must be enabled: https://github.com/golang/go/pull/24307")
572-
}
573-
for _, fsType := range FileSystemTypes {
574-
t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) {
575-
fs := NewFs(fsType)
576-
tmpDir, err := fs.TempDirInTempDir("test-link-")
577-
require.NoError(t, err)
578-
defer func() { _ = fs.Rm(tmpDir) }()
579-
580-
txt := fmt.Sprintf("This is a test sentence!!! %v", faker.Sentence())
581-
tmpFile, err := fs.TouchTempFile(tmpDir, "test-*.txt")
582-
require.NoError(t, err)
583-
err = fs.WriteFile(tmpFile, []byte(txt), 0755)
584-
require.NoError(t, err)
585-
586-
symlink := filepath.Join(tmpDir, "symlink-tofile")
587-
hardlink := filepath.Join(tmpDir, "hardlink-tofile")
588-
589-
err = fs.Symlink(tmpFile, symlink)
590-
if commonerrors.Any(err, commonerrors.ErrNotImplemented, commonerrors.ErrForbidden, afero.ErrNoSymlink) {
591-
return
592-
}
593-
require.NoError(t, err)
594-
595-
err = fs.Link(tmpFile, hardlink)
596-
require.NoError(t, err)
597-
598-
assert.True(t, fs.Exists(symlink))
599-
assert.True(t, fs.Exists(hardlink))
600-
601-
isLink, err := fs.IsLink(symlink)
602-
require.NoError(t, err)
603-
assert.True(t, isLink)
604-
605-
isFile, err := fs.IsFile(symlink)
606-
require.NoError(t, err)
607-
assert.True(t, isFile)
608-
609-
isLink, err = fs.IsLink(hardlink)
610-
require.NoError(t, err)
611-
assert.False(t, isLink)
612-
613-
isFile, err = fs.IsFile(hardlink)
614-
require.NoError(t, err)
615-
assert.True(t, isFile)
616-
617-
link, err := fs.Readlink(symlink)
618-
require.NoError(t, err)
619-
assert.Equal(t, tmpFile, link)
620-
621-
link, err = fs.Readlink(hardlink)
622-
require.Error(t, err)
623-
assert.Empty(t, link)
624-
625-
bytes, err := fs.ReadFile(symlink)
626-
require.NoError(t, err)
627-
assert.Equal(t, txt, string(bytes))
628-
629-
bytes, err = fs.ReadFile(hardlink)
630-
require.NoError(t, err)
631-
assert.Equal(t, txt, string(bytes))
632-
_ = fs.Rm(tmpDir)
633-
})
634-
}
635-
}
636-
637568
func TestStatTimes(t *testing.T) {
638569
for _, fsType := range FileSystemTypes {
639570
t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) {

utils/filesystem/filesystem.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"io"
1212
"os"
13+
"syscall"
1314

1415
"github.com/spf13/afero"
1516

@@ -113,7 +114,7 @@ func ConvertFileSystemError(err error) error {
113114
return commonerrors.WrapError(commonerrors.ErrExists, err, "")
114115
case commonerrors.CorrespondTo(err, "bad file descriptor") || os.IsPermission(err) || commonerrors.Any(err, os.ErrPermission, os.ErrClosed, afero.ErrFileClosed, ErrPathNotExist, io.ErrClosedPipe):
115116
return commonerrors.WrapError(commonerrors.ErrConflict, err, "")
116-
case commonerrors.CorrespondTo(err, "required privilege is not held") || commonerrors.CorrespondTo(err, "operation not permitted"):
117+
case commonerrors.Any(err, syscall.EPERM, syscall.ERROR_PRIVILEGE_NOT_HELD) || commonerrors.CorrespondTo(err, "required privilege is not held", "operation not permitted"):
117118
return commonerrors.WrapError(commonerrors.ErrForbidden, err, "")
118119
case os.IsNotExist(err) || commonerrors.Any(err, os.ErrNotExist, afero.ErrFileNotFound) || IsPathNotExist(err) || commonerrors.CorrespondTo(err, "No such file or directory"):
119120
return commonerrors.WrapError(commonerrors.ErrNotFound, err, "")
@@ -130,6 +131,8 @@ func ConvertFileSystemError(err error) error {
130131
case commonerrors.Any(err, io.ErrUnexpectedEOF):
131132
// Do not add io.EOF as it is used to read files
132133
return commonerrors.WrapError(commonerrors.ErrEOF, err, "")
134+
case commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP, syscall.EWINDOWS, afero.ErrNoSymlink, afero.ErrNoReadlink) || commonerrors.CorrespondTo(err, "not supported"):
135+
return commonerrors.WrapError(commonerrors.ErrUnsupported, err, "")
133136
}
134137

135138
return err

utils/filesystem/links_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package filesystem
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/ARM-software/golang-utils/utils/commonerrors"
9+
"github.com/ARM-software/golang-utils/utils/commonerrors/errortest"
10+
"github.com/ARM-software/golang-utils/utils/platform"
11+
"github.com/go-faker/faker/v4"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func printWarningOnWindows(t *testing.T) {
17+
t.Helper()
18+
if platform.IsWindows() {
19+
t.Log("⚠️ In order to run TestLink on Windows, Developer mode must be enabled: https://github.com/golang/go/pull/24307")
20+
}
21+
}
22+
23+
func skipIfLinksNotSupported(t *testing.T, err error) {
24+
t.Helper()
25+
if commonerrors.Any(err, commonerrors.ErrNotImplemented, commonerrors.ErrForbidden, commonerrors.ErrUnsupported) {
26+
t.Skipf("⚠️ links not supported on this system: %v", err)
27+
} else {
28+
require.NoError(t, err)
29+
}
30+
}
31+
32+
func TestLink(t *testing.T) {
33+
printWarningOnWindows(t)
34+
for _, fsType := range FileSystemTypes {
35+
t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) {
36+
fs := NewFs(fsType)
37+
tmpDir, err := fs.TempDirInTempDir("test-link-")
38+
require.NoError(t, err)
39+
defer func() { _ = fs.Rm(tmpDir) }()
40+
41+
txt := fmt.Sprintf("This is a test sentence!!! %v", faker.Sentence())
42+
tmpFile, err := fs.TouchTempFile(tmpDir, "test-*.txt")
43+
require.NoError(t, err)
44+
err = fs.WriteFile(tmpFile, []byte(txt), 0755)
45+
require.NoError(t, err)
46+
47+
symlink := FilePathJoin(fs, tmpDir, "symlink-tofile")
48+
hardlink := FilePathJoin(fs, tmpDir, "hardlink-tofile")
49+
50+
err = fs.Symlink(tmpFile, symlink)
51+
skipIfLinksNotSupported(t, err)
52+
53+
err = fs.Link(tmpFile, hardlink)
54+
require.NoError(t, err)
55+
56+
assert.True(t, fs.Exists(symlink))
57+
assert.True(t, fs.Exists(hardlink))
58+
59+
isLink, err := fs.IsLink(symlink)
60+
require.NoError(t, err)
61+
assert.True(t, isLink)
62+
63+
isFile, err := fs.IsFile(symlink)
64+
require.NoError(t, err)
65+
assert.True(t, isFile)
66+
67+
isLink, err = fs.IsLink(hardlink)
68+
require.NoError(t, err)
69+
assert.False(t, isLink)
70+
71+
isFile, err = fs.IsFile(hardlink)
72+
require.NoError(t, err)
73+
assert.True(t, isFile)
74+
75+
link, err := fs.Readlink(symlink)
76+
require.NoError(t, err)
77+
assert.Equal(t, tmpFile, link)
78+
79+
link, err = fs.Readlink(hardlink)
80+
require.Error(t, err)
81+
assert.Empty(t, link)
82+
83+
bytes, err := fs.ReadFile(symlink)
84+
require.NoError(t, err)
85+
assert.Equal(t, txt, string(bytes))
86+
87+
bytes, err = fs.ReadFile(hardlink)
88+
require.NoError(t, err)
89+
assert.Equal(t, txt, string(bytes))
90+
_ = fs.Rm(tmpDir)
91+
})
92+
}
93+
}
94+
95+
func TestEvalSymlinks_ResolvesToRealPath(t *testing.T) {
96+
printWarningOnWindows(t)
97+
for _, fsType := range FileSystemTypes {
98+
t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) {
99+
fs := NewFs(fsType)
100+
tmpDir, err := fs.TempDirInTempDir("test-eval-link-")
101+
require.NoError(t, err)
102+
defer func() { _ = fs.Rm(tmpDir) }()
103+
realTmpDir, err := fs.TempDir(tmpDir, "real-dir-")
104+
require.NoError(t, err)
105+
symTmpDir, err := fs.TempDir(tmpDir, "sym-dir-")
106+
require.NoError(t, err)
107+
// real target: <realTmpDir>/real/nested/file.txt
108+
realDir := FilePathJoin(fs, realTmpDir, "real", "nested")
109+
require.NoError(t, fs.MkDir(realDir))
110+
realFile := FilePathJoin(fs, realDir, "file.txt")
111+
expectedContent := fmt.Sprintf("hello world! hello %v! %v", faker.Name(), faker.Sentence())
112+
require.NoError(t, fs.WriteFile(realFile, []byte(expectedContent), 0o644))
113+
currentDir, err := fs.CurrentDirectory()
114+
require.NoError(t, err)
115+
expectedAbs := FilePathAbs(fs, realFile, currentDir)
116+
117+
// First symlink: <symTmpDir>/random -> <realTmpDir>/real/nested
118+
symDir := FilePathJoin(fs, symTmpDir, faker.Word())
119+
err = fs.Symlink(realDir, symDir)
120+
skipIfLinksNotSupported(t, err)
121+
122+
// symlink to a symlink
123+
symTmpDir2, err := fs.TempDir(tmpDir, "sym-dir2-")
124+
require.NoError(t, err)
125+
symAgain := FilePathJoin(fs, symTmpDir2, fmt.Sprintf("%v-sym2sym", faker.Word()))
126+
err = fs.Symlink(symDir, symAgain)
127+
skipIfLinksNotSupported(t, err)
128+
129+
pathThroughSymlinks := FilePathJoin(fs, symAgain, "file.txt")
130+
symlinkFile := FilePathJoin(fs, symAgain, "symfile.txt")
131+
err = fs.Symlink(pathThroughSymlinks, symlinkFile)
132+
skipIfLinksNotSupported(t, err)
133+
134+
resolved, err := EvalSymlinks(fs, pathThroughSymlinks)
135+
require.NoError(t, err)
136+
137+
resolvedAbs := FilePathAbs(fs, resolved, currentDir)
138+
resolvedAbs = FilePathClean(fs, resolvedAbs)
139+
expectedAbs = FilePathClean(fs, expectedAbs)
140+
141+
resolved2, err := EvalSymlinks(fs, symlinkFile)
142+
require.NoError(t, err)
143+
resolvedAbs2 := FilePathAbs(fs, resolved2, currentDir)
144+
resolvedAbs2 = FilePathClean(fs, resolvedAbs2)
145+
146+
if platform.IsWindows() {
147+
assert.True(t, strings.EqualFold(resolvedAbs, expectedAbs))
148+
assert.True(t, strings.EqualFold(resolvedAbs2, expectedAbs))
149+
} else {
150+
assert.Equal(t, expectedAbs, resolvedAbs)
151+
assert.Equal(t, expectedAbs, resolvedAbs2)
152+
}
153+
154+
content, err := fs.ReadFile(symlinkFile)
155+
require.NoError(t, err)
156+
assert.Equal(t, string(content), expectedContent)
157+
158+
})
159+
}
160+
}
161+
162+
func TestEvalSymlinks_notExist(t *testing.T) {
163+
printWarningOnWindows(t)
164+
for _, fsType := range FileSystemTypes {
165+
t.Run(fmt.Sprintf("%v_for_fs_%v", t.Name(), fsType), func(t *testing.T) {
166+
fs := NewFs(fsType)
167+
tmpDir, err := fs.TempDirInTempDir("test-eval-link-not-exist-")
168+
require.NoError(t, err)
169+
defer func() { _ = fs.Rm(tmpDir) }()
170+
_, err = EvalSymlinks(fs, "notexist")
171+
errortest.AssertError(t, err, commonerrors.ErrNotFound)
172+
dest := FilePathJoin(fs, tmpDir, "link")
173+
err = fs.Symlink("notexist", dest)
174+
skipIfLinksNotSupported(t, err)
175+
_, err = EvalSymlinks(fs, dest)
176+
errortest.AssertError(t, err, commonerrors.ErrNotFound)
177+
})
178+
}
179+
}

0 commit comments

Comments
 (0)