Skip to content

Commit afca0b2

Browse files
committed
Add NewDefaultFS function to help create filesystem that allows absolute paths
1 parent a0e5ff7 commit afca0b2

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

echo.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,15 @@ type Echo struct {
6969
serveHTTPFunc func(http.ResponseWriter, *http.Request)
7070

7171
Binder Binder
72-
// Filesystem is the file system used for serving static files. Defaults to the current working directory.
73-
Filesystem fs.FS
72+
73+
// Filesystem is the file system used for serving static files. Defaults to the current working directory (os.Getwd()).
74+
//
75+
// Note: fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
76+
// so if you have `fs := os.DirFS("/tmp")` and you try to `fs.Open("/tmp/file.txt")` it will fail, but "file.txt"
77+
// would succeed. `echo.NewDefaultFS("/tmp")` overwrites this behavior and allows you to use Open with a matching
78+
// absolute path prefix.
79+
Filesystem fs.FS
80+
7481
Renderer Renderer
7582
Validator Validator
7683
JSONSerializer JSONSerializer
@@ -324,10 +331,11 @@ func NewWithConfig(config Config) *Echo {
324331

325332
// New creates an instance of Echo.
326333
func New() *Echo {
334+
dir, _ := os.Getwd()
327335
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
328336
e := &Echo{
329337
Logger: logger,
330-
Filesystem: newDefaultFS(),
338+
Filesystem: NewDefaultFS(dir),
331339
Binder: &DefaultBinder{},
332340
JSONSerializer: &DefaultJSONSerializer{},
333341
formParseMaxMemory: defaultMemory,
@@ -781,7 +789,7 @@ func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc {
781789
return h
782790
}
783791

784-
// defaultFS emulates os.Open behaviour with filesystem opened by `os.DirFs`. Difference between `os.Open` and `fs.Open`
792+
// defaultFS emulates os.Open behavior with filesystem opened by `os.DirFs`. Difference between `os.Open` and `fs.Open`
785793
// is that FS does not allow to open path that start with `..` or `/` etc. For example previously you could have `../images`
786794
// in your application but `fs := os.DirFS("./")` would not allow you to use `fs.Open("../images")` and this would break
787795
// all old applications that rely on being able to traverse up from current executable run path.
@@ -791,15 +799,29 @@ type defaultFS struct {
791799
prefix string
792800
}
793801

794-
func newDefaultFS() *defaultFS {
795-
dir, _ := os.Getwd()
802+
// NewDefaultFS returns a new defaultFS instance which allows `fs.FS.Open` to have absolute paths as input if it matches
803+
// then given dir as prefix.
804+
func NewDefaultFS(dir string) fs.FS {
796805
return &defaultFS{
797806
prefix: dir,
798-
fs: os.DirFS(dir),
807+
fs: os.DirFS(filepath.ToSlash(filepath.Clean(dir))),
799808
}
800809
}
801810

802811
func (fs defaultFS) Open(name string) (fs.File, error) {
812+
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
813+
// For example `f.Name()` returns file names as absolute paths (e.g. `/tmp/data.csv`) so in case user wants to open
814+
// a file with an absolute path we need to remove prefix and then call fs.FS.Open().
815+
// not to force users to cut prefix from file name we do it here.
816+
if filepath.IsAbs(name) {
817+
name = filepath.ToSlash(filepath.Clean(name))
818+
if strings.HasPrefix(name, fs.prefix) {
819+
name = name[len(fs.prefix):]
820+
if len(name) > 1 && name[0] == '/' {
821+
name = name[1:]
822+
}
823+
}
824+
}
803825
return fs.fs.Open(name)
804826
}
805827

echo_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
stdContext "context"
99
"errors"
1010
"fmt"
11+
"io"
1112
"io/fs"
1213
"log/slog"
1314
"net"
@@ -79,6 +80,57 @@ func TestNewWithConfig(t *testing.T) {
7980
assert.Equal(t, `Hello, World!`, rec.Body.String())
8081
}
8182

83+
func TestNewDefaultFS(t *testing.T) {
84+
tempDir := t.TempDir()
85+
filename := tempDir + "/file.txt"
86+
if err := os.WriteFile(filename, []byte("hello"), 0644); err != nil {
87+
t.Fatalf("failed to write file: %v", err)
88+
}
89+
90+
var testCases = []struct {
91+
name string
92+
givenDir string
93+
whenName string
94+
expectedError string
95+
}{
96+
{
97+
name: "ok, can open absolute path",
98+
givenDir: tempDir,
99+
whenName: filename,
100+
},
101+
{
102+
name: "ok, can open path to fs",
103+
givenDir: tempDir,
104+
whenName: "file.txt",
105+
},
106+
{
107+
name: "nok, can not use ./ in path",
108+
givenDir: tempDir,
109+
whenName: "./file.txt",
110+
expectedError: `open ./file.txt: invalid argument`,
111+
},
112+
}
113+
for _, tc := range testCases {
114+
t.Run(tc.name, func(t *testing.T) {
115+
myFs := NewDefaultFS(tc.givenDir)
116+
117+
f, err := myFs.Open(tc.whenName)
118+
if tc.expectedError != "" {
119+
assert.EqualError(t, err, tc.expectedError)
120+
return
121+
}
122+
if err != nil {
123+
t.Fatalf("failed to read file: %v", err)
124+
}
125+
defer f.Close()
126+
127+
contents, err := io.ReadAll(f)
128+
assert.NoError(t, err)
129+
assert.Equal(t, []byte("hello"), contents)
130+
})
131+
}
132+
}
133+
82134
func TestEcho_StaticFS(t *testing.T) {
83135
var testCases = []struct {
84136
givenFs fs.FS

0 commit comments

Comments
 (0)