Skip to content

Commit 1c3e957

Browse files
authored
[filesystem] Added a way to follow symlink EvalSymlinks (#747)
<!-- Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> ### Description - Added a way to follow symlink `EvalSymlinks` similar to https://pkg.go.dev/path/filepath#EvalSymlinks ### Test Coverage <!-- Please put an `x` in the correct box e.g. `[x]` to indicate the testing coverage of this change. --> - [x] This change is covered by existing or additional automated tests. - [ ] Manual testing has been performed (and evidence provided) as automated testing was not feasible. - [ ] Additional tests are not required for this change (e.g. documentation update).
1 parent 202d10c commit 1c3e957

File tree

7 files changed

+220
-70
lines changed

7 files changed

+220
-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_posix.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import (
1010
"github.com/ARM-software/golang-utils/utils/commonerrors"
1111
)
1212

13+
func isPrivilegeError(err error) bool {
14+
return commonerrors.Any(err, syscall.EPERM)
15+
}
16+
17+
func isNotSupportedError(err error) bool {
18+
return commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP)
19+
}
20+
1321
func determineFileOwners(info os.FileInfo) (uid, gid int, err error) {
1422
if raw, ok := info.Sys().(*syscall.Stat_t); ok {
1523
gid = int(raw.Gid)

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/files_windows.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,18 @@ package filesystem
77
import (
88
"os"
99
"syscall"
10+
11+
"github.com/ARM-software/golang-utils/utils/commonerrors"
1012
)
1113

14+
func isPrivilegeError(err error) bool {
15+
return commonerrors.Any(err, syscall.EPERM, syscall.ERROR_PRIVILEGE_NOT_HELD)
16+
}
17+
18+
func isNotSupportedError(err error) bool {
19+
return commonerrors.Any(err, syscall.ENOTSUP, syscall.EOPNOTSUPP, syscall.EWINDOWS)
20+
}
21+
1222
func determineFileOwners(_ os.FileInfo) (uid, gid int, err error) {
1323
uid = syscall.Getuid()
1424
gid = syscall.Getgid()

utils/filesystem/filesystem.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func ConvertFileSystemError(err error) error {
113113
return commonerrors.WrapError(commonerrors.ErrExists, err, "")
114114
case commonerrors.CorrespondTo(err, "bad file descriptor") || os.IsPermission(err) || commonerrors.Any(err, os.ErrPermission, os.ErrClosed, afero.ErrFileClosed, ErrPathNotExist, io.ErrClosedPipe):
115115
return commonerrors.WrapError(commonerrors.ErrConflict, err, "")
116-
case commonerrors.CorrespondTo(err, "required privilege is not held") || commonerrors.CorrespondTo(err, "operation not permitted"):
116+
case isPrivilegeError(err) || commonerrors.CorrespondTo(err, "required privilege is not held", "operation not permitted"):
117117
return commonerrors.WrapError(commonerrors.ErrForbidden, err, "")
118118
case os.IsNotExist(err) || commonerrors.Any(err, os.ErrNotExist, afero.ErrFileNotFound) || IsPathNotExist(err) || commonerrors.CorrespondTo(err, "No such file or directory"):
119119
return commonerrors.WrapError(commonerrors.ErrNotFound, err, "")
@@ -130,6 +130,8 @@ func ConvertFileSystemError(err error) error {
130130
case commonerrors.Any(err, io.ErrUnexpectedEOF):
131131
// Do not add io.EOF as it is used to read files
132132
return commonerrors.WrapError(commonerrors.ErrEOF, err, "")
133+
case isNotSupportedError(err) || commonerrors.Any(err, afero.ErrNoSymlink, afero.ErrNoReadlink) || commonerrors.CorrespondTo(err, "not supported"):
134+
return commonerrors.WrapError(commonerrors.ErrUnsupported, err, "")
133135
}
134136

135137
return err

utils/filesystem/links_test.go

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

0 commit comments

Comments
 (0)