Skip to content

Commit db7e30e

Browse files
authored
[filesystem] Add filepath utilities to work across file-systems no matter the underlying platform (#639)
!-- Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> ### Description - Add utilities to manage paths - Fix bug with regards to zipping between filesystem with different path separators ### 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 ea685ba commit db7e30e

File tree

16 files changed

+1133
-225
lines changed

16 files changed

+1133
-225
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ jobs:
107107
run: go build -v ./...
108108
working-directory: ${{ matrix.go-module }}
109109

110+
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
111+
name: Test [${{ matrix.go-module }} on ${{ matrix.os }}]
112+
run: go test -race -cover -v -tags integration -timeout 30m -coverprofile ${{ matrix.go-module }}_coverage.out ./...
113+
working-directory: ${{ matrix.go-module }}
114+
110115
# FIXME: Run tests on Mac and Windows
111116
# - if: ${{ startsWith(matrix.os, 'windows') || startsWith(matrix.os, 'macOS') }}
112117
# run: go test -race -cover -v ./...

.secrets.baseline

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,6 @@
6666
{
6767
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
6868
},
69-
{
70-
"path": "detect_secrets.filters.common.is_baseline_file",
71-
"filename": ".secrets.baseline"
72-
},
7369
{
7470
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
7571
"min_level": 2
@@ -276,5 +272,5 @@
276272
}
277273
]
278274
},
279-
"generated_at": "2025-06-13T15:31:00Z"
275+
"generated_at": "2025-06-27T16:00:59Z"
280276
}

changes/20250627145431.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[filesystem]` Add filepath utilities to work across file-systems no matter the underlying platform

changes/20250627161647.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:bug: `[filesystem]` fix [bug](https://unix.stackexchange.com/questions/166159/convert-a-windows-created-zip-to-linux-internal-paths-issue) when zipping a package on Windows and unzipping it on a different platform such as Linux

utils/collection/parseLists_test.go

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ package collection
66

77
import (
88
"maps"
9-
"math/rand"
109
"testing"
11-
"time"
1210

1311
"github.com/go-faker/faker/v4"
1412
"github.com/stretchr/testify/assert"
@@ -18,10 +16,6 @@ import (
1816
"github.com/ARM-software/golang-utils/utils/commonerrors/errortest"
1917
)
2018

21-
var (
22-
random = rand.New(rand.NewSource(time.Now().Unix())) //nolint:gosec //causes G404: Use of weak random number generator (math/rand instead of crypto/rand) (gosec), So disable gosec as this is just for
23-
)
24-
2519
func TestParseCommaSeparatedListWordsOnly(t *testing.T) {
2620
t.Run("simple", func(t *testing.T) {
2721
stringArray := []string{faker.Word(), faker.Word(), faker.Word(), faker.UUIDDigit(), faker.URL(), faker.Username(), faker.DomainName()}
@@ -30,14 +24,15 @@ func TestParseCommaSeparatedListWordsOnly(t *testing.T) {
3024
t.Run("with whitespaces", func(t *testing.T) {
3125
stringList := ""
3226
var stringArray []string
33-
// we don't need cryptographically secure random numbers for generating a number of elements in a list
34-
lengthOfList := random.Intn(10) //nolint:gosec
35-
for i := 0; i < lengthOfList; i++ {
27+
lengthOfList, err := faker.RandomInt(1, 10, 1)
28+
require.NoError(t, err)
29+
for i := 0; i < lengthOfList[0]; i++ {
3630
word := faker.Word()
3731
stringList += word
3832
stringArray = append(stringArray, word)
39-
numSpacesToAdd := random.Intn(5) //nolint:gosec
40-
for j := 0; j < numSpacesToAdd; j++ {
33+
numSpacesToAdd, err := faker.RandomInt(0, 5, 1)
34+
require.NoError(t, err)
35+
for j := 0; j < numSpacesToAdd[0]; j++ {
4136
stringList += " "
4237
}
4338
stringList += ","
@@ -56,14 +51,15 @@ func TestParseCommaSeparatedListWithSpacesBetweenWords(t *testing.T) {
5651
t.Run("with whitespaces", func(t *testing.T) {
5752
stringList := ""
5853
var stringArray []string
59-
// we don't need cryptographically secure random numbers for generating a number of elements in a list
60-
lengthOfList := random.Intn(10) //nolint:gosec
61-
for i := 0; i < lengthOfList; i++ {
54+
lengthOfList, err := faker.RandomInt(1, 10, 1)
55+
require.NoError(t, err)
56+
for i := 0; i < lengthOfList[0]; i++ {
6257
word := faker.Sentence()
6358
stringList += word
6459
stringArray = append(stringArray, word)
65-
numSpacesToAdd := random.Intn(5) //nolint:gosec
66-
for j := 0; j < numSpacesToAdd; j++ {
60+
numSpacesToAdd, err := faker.RandomInt(0, 5, 1)
61+
require.NoError(t, err)
62+
for j := 0; j < numSpacesToAdd[0]; j++ {
6763
stringList += " "
6864
}
6965
stringList += ","
@@ -76,20 +72,22 @@ func TestParseCommaSeparatedListWithSpacesBetweenWords(t *testing.T) {
7672
func TestParseCommaSeparatedListWithSpacesBetweenWordsKeepBlanks(t *testing.T) {
7773
stringList := ""
7874
var stringArray []string
79-
// we don't need cryptographically secure random numbers for generating a number of elements in a list
80-
lengthOfList := random.Intn(10) + 8 //nolint:gosec
81-
for i := 0; i < lengthOfList; i++ {
75+
lengthOfList, err := faker.RandomInt(8, 18, 1)
76+
require.NoError(t, err)
77+
for i := 0; i < lengthOfList[0]; i++ {
8278
word := faker.Sentence()
8379
stringList += word
8480
stringArray = append(stringArray, word)
85-
numSpacesToAdd := random.Intn(5) //nolint:gosec
86-
for j := 0; j < numSpacesToAdd; j++ {
81+
numSpacesToAdd, err := faker.RandomInt(0, 5, 1)
82+
require.NoError(t, err)
83+
for j := 0; j < numSpacesToAdd[0]; j++ {
8784
stringList += " "
8885
}
8986
stringList += ","
9087
if i%3 == 2 {
91-
numSpacesToAdd := random.Intn(5) //nolint:gosec
92-
for j := 0; j < numSpacesToAdd; j++ {
88+
numSpacesToAdd, err := faker.RandomInt(0, 5, 1)
89+
require.NoError(t, err)
90+
for j := 0; j < numSpacesToAdd[0]; j++ {
9391
stringList += " "
9492
}
9593
stringArray = append(stringArray, "")

utils/filesystem/embedfs_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
//go:embed *
2020
var testContent embed.FS
2121

22-
const testFile1Content = "this is a text file with some content\n"
22+
const testFile1Content = "this is a text file with some content"
2323

2424
func Test_embedFS_Exists(t *testing.T) {
2525
fs, err := NewEmbedFileSystem(&testContent)
@@ -97,7 +97,7 @@ func Test_embedFS_Read(t *testing.T) {
9797
t.Run("embed read", func(t *testing.T) {
9898
c, err := testContent.ReadFile("testdata/embed/test.txt")
9999
require.NoError(t, err)
100-
assert.Equal(t, testFile1Content, string(c))
100+
assert.Contains(t, string(c), testFile1Content)
101101
})
102102

103103
t.Run("using file opening", func(t *testing.T) {
@@ -106,7 +106,7 @@ func Test_embedFS_Read(t *testing.T) {
106106
defer func() { _ = f.Close() }()
107107
c, err := io.ReadAll(f)
108108
require.NoError(t, err)
109-
assert.Equal(t, testFile1Content, string(c))
109+
assert.Contains(t, string(c), testFile1Content)
110110
require.NoError(t, f.Close())
111111
})
112112

@@ -116,14 +116,14 @@ func Test_embedFS_Read(t *testing.T) {
116116
defer func() { _ = f.Close() }()
117117
c, err := io.ReadAll(f)
118118
require.NoError(t, err)
119-
assert.Equal(t, testFile1Content, string(c))
119+
assert.Contains(t, string(c), testFile1Content)
120120
require.NoError(t, f.Close())
121121
})
122122

123123
t.Run("using file read", func(t *testing.T) {
124124
c, err := efs.ReadFile("testdata/embed/test.txt")
125125
require.NoError(t, err)
126-
assert.Equal(t, testFile1Content, string(c))
126+
assert.Contains(t, string(c), testFile1Content)
127127
})
128128

129129
}

utils/filesystem/filepath.go

Lines changed: 159 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import (
1616

1717
// FilepathStem returns the final path component, without its suffix. It's similar to `stem` in python's [pathlib](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.stem)
1818
func FilepathStem(fp string) string {
19-
return strings.TrimSuffix(filepath.Base(fp), filepath.Ext(fp))
19+
return FilePathStemOnFilesystem(GetGlobalFileSystem(), fp)
2020
}
2121

22-
// FilepathParents returns a list of to the logical ancestors of the path and it's similar to `parents` in python's [pathlib](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parents)
22+
// FilepathParents returns a list of to the logical ancestors of the path, and it's similar to `parents` in python's [pathlib](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parents)
2323
func FilepathParents(fp string) []string {
2424
return FilePathParentsOnFilesystem(GetGlobalFileSystem(), fp)
2525
}
@@ -55,16 +55,16 @@ func FilePathJoin(fs FS, element ...string) string {
5555
return ""
5656
}
5757

58-
if fs.PathSeparator() == '/' {
58+
if isSlashSeparator(fs) {
5959
return path.Join(element...)
6060
}
6161

62-
joinedPath := filepath.Join(element...)
63-
if fs.PathSeparator() != platform.PathListSeparator {
64-
joinedPath = strings.ReplaceAll(joinedPath, string(platform.PathListSeparator), string(fs.PathSeparator()))
62+
elements := make([]string, len(element))
63+
for i := range elements {
64+
elements[i] = FilePathToPlatformPathSeparator(fs, element[i])
6565
}
6666

67-
return joinedPath
67+
return FilePathFromPlatformPathSeparator(fs, filepath.Join(elements...))
6868
}
6969

7070
// FilePathBase has the same behaviour as filepath.Base, but can handle different filesystems.
@@ -73,15 +73,145 @@ func FilePathBase(fs FS, fp string) string {
7373
return ""
7474
}
7575

76-
if fs.PathSeparator() == '/' {
76+
if isSlashSeparator(fs) {
7777
return path.Base(fp)
7878
}
7979

80-
if fs.PathSeparator() != platform.PathListSeparator {
81-
fp = strings.ReplaceAll(fp, string(fs.PathSeparator()), string(platform.PathListSeparator))
80+
return FilePathFromPlatformPathSeparator(fs, filepath.Base(FilePathToPlatformPathSeparator(fs, fp)))
81+
}
82+
83+
// FilePathDir has the same behaviour as filepath.Dir, but can handle different filesystems.
84+
func FilePathDir(fs FS, fp string) string {
85+
if fs == nil {
86+
return ""
87+
}
88+
89+
if isSlashSeparator(fs) {
90+
return path.Dir(fp)
91+
}
92+
return FilePathFromPlatformPathSeparator(fs, filepath.Dir(FilePathToPlatformPathSeparator(fs, fp)))
93+
}
94+
95+
// FilePathIsAbs has the same behaviour as filepath.IsAbs, but can handle different filesystems.
96+
func FilePathIsAbs(fs FS, fp string) bool {
97+
if fs == nil {
98+
return false
99+
}
100+
if isSlashSeparator(fs) {
101+
return path.IsAbs(fp)
102+
}
103+
return filepath.IsAbs(FilePathToPlatformPathSeparator(fs, fp))
104+
}
105+
106+
// FilePathRel has the same behaviour as filepath.FilePathRel, but can handle different filesystems.
107+
func FilePathRel(fs FS, basepath, targpath string) (rel string, err error) {
108+
if fs == nil {
109+
err = commonerrors.UndefinedVariable("filesystem specification")
110+
return
111+
}
112+
rel, err = filepath.Rel(FilePathToPlatformPathSeparator(fs, basepath), FilePathToPlatformPathSeparator(fs, targpath))
113+
if err != nil {
114+
return
115+
}
116+
rel = FilePathFromPlatformPathSeparator(fs, rel)
117+
return
118+
}
119+
120+
// FilePathSplit has the same behaviour as filepath.Split, but can handle different filesystems
121+
func FilePathSplit(fs FS, fp string) (dir, file string) {
122+
if fs == nil {
123+
return
124+
}
125+
126+
if isSlashSeparator(fs) {
127+
return path.Split(fp)
128+
}
129+
dir, file = filepath.Split(FilePathToPlatformPathSeparator(fs, fp))
130+
dir = FilePathFromPlatformPathSeparator(fs, dir)
131+
return
132+
}
133+
134+
// FilePathAbs tries to be similar to filepath.Abs behaviour but without using platform information or location.
135+
func FilePathAbs(fs FS, fp, currentDirectory string) string {
136+
if FilePathIsAbs(fs, fp) {
137+
return FilePathClean(fs, fp)
138+
}
139+
return FilePathJoin(fs, currentDirectory, fp)
140+
}
141+
142+
// FilePathToPlatformPathSeparator returns the result of replacing each separator character in path with a platform path separator character
143+
func FilePathToPlatformPathSeparator(fs FS, path string) string {
144+
if fs.PathSeparator() == platform.PathSeparator {
145+
return path
146+
}
147+
return strings.ReplaceAll(path, string(fs.PathSeparator()), string(platform.PathSeparator))
148+
}
149+
150+
// FilePathFromPlatformPathSeparator returns the result of replacing each platform path separator character in path with filesystem's path separator character
151+
func FilePathFromPlatformPathSeparator(fs FS, path string) string {
152+
if fs.PathSeparator() == platform.PathSeparator {
153+
return path
82154
}
155+
return strings.ReplaceAll(path, string(platform.PathSeparator), string(fs.PathSeparator()))
156+
}
83157

84-
return filepath.Base(fp)
158+
// FilePathToSlash is filepath.ToSlash but using filesystem path separator rather than platform's
159+
func FilePathToSlash(fs FS, path string) string {
160+
if isSlashSeparator(fs) {
161+
return path
162+
}
163+
return strings.ReplaceAll(path, string(fs.PathSeparator()), "/")
164+
}
165+
166+
// FilePathsToSlash just applying FilePathToSlash to a slice of path
167+
func FilePathsToSlash(fs FS, path ...string) (toSlashes []string) {
168+
if len(path) == 0 {
169+
return
170+
}
171+
toSlashes = make([]string, len(path))
172+
for i := range path {
173+
toSlashes[i] = FilePathToSlash(fs, path[i])
174+
}
175+
return
176+
}
177+
178+
// FilePathFromSlash is filepath.FromSlash but using filesystem path separator rather than platform's
179+
func FilePathFromSlash(fs FS, path string) string {
180+
if isSlashSeparator(fs) {
181+
return path
182+
}
183+
return strings.ReplaceAll(path, "/", string(fs.PathSeparator()))
184+
}
185+
186+
// FilePathsFromSlash just applying FilePathFromSlash to a slice of path
187+
func FilePathsFromSlash(fs FS, path ...string) (fromlashes []string) {
188+
if len(path) == 0 {
189+
return
190+
}
191+
fromlashes = make([]string, len(path))
192+
for i := range path {
193+
fromlashes[i] = FilePathFromSlash(fs, path[i])
194+
}
195+
return
196+
}
197+
198+
// FilePathStemOnFilesystem has the same behaviour as FilePathStem, but can handle different filesystems.
199+
func FilePathStemOnFilesystem(fs FS, fp string) string {
200+
if fs == nil {
201+
return ""
202+
}
203+
return strings.TrimSuffix(FilePathBase(fs, fp), FilePathExt(fs, fp))
204+
}
205+
206+
// FilePathExt has the same behaviour as filepath.Ext, but can handle different filesystems.
207+
func FilePathExt(fs FS, fp string) string {
208+
if fs == nil {
209+
return ""
210+
}
211+
if isSlashSeparator(fs) {
212+
return path.Ext(fp)
213+
}
214+
return filepath.Ext(FilePathToPlatformPathSeparator(fs, fp))
85215
}
86216

87217
// FilePathClean has the same behaviour as filepath.Clean, but can handle different filesystems.
@@ -90,11 +220,26 @@ func FilePathClean(fs FS, fp string) string {
90220
return ""
91221
}
92222

93-
if fs.PathSeparator() == '/' {
223+
if isSlashSeparator(fs) {
94224
return path.Clean(fp)
95225
}
226+
return FilePathFromPlatformPathSeparator(fs, filepath.Clean(FilePathToPlatformPathSeparator(fs, fp)))
227+
}
228+
229+
// FilePathVolumeName has the same behaviour as filepath.VolumeName, but can handle different filesystems.
230+
func FilePathVolumeName(fs FS, fp string) string {
231+
if fs == nil {
232+
return ""
233+
}
96234

97-
return filepath.Clean(fp)
235+
return filepath.VolumeName(FilePathToPlatformPathSeparator(fs, fp))
236+
}
237+
238+
func isSlashSeparator(fs FS) bool {
239+
if fs == nil {
240+
return false
241+
}
242+
return fs.PathSeparator() == '/'
98243
}
99244

100245
// FileTreeDepth returns the depth of a file in a tree starting from root
@@ -110,7 +255,7 @@ func FileTreeDepth(fs FS, root, filePath string) (depth int64, err error) {
110255
if reflection.IsEmpty(diff) {
111256
return
112257
}
113-
diff = strings.ReplaceAll(diff, string(fs.PathSeparator()), "/")
258+
diff = FilePathToSlash(fs, diff)
114259
depth = int64(len(strings.Split(diff, "/")) - 1)
115260
return
116261
}

0 commit comments

Comments
 (0)