Skip to content

Commit a7005b2

Browse files
authored
Add NewDefaultFS function to help create filesystem that allows absolute paths (#2931)
Add NewDefaultFS function to help create filesystem that allows absolute paths
1 parent a0e5ff7 commit a7005b2

File tree

2 files changed

+80
-6
lines changed

2 files changed

+80
-6
lines changed

echo.go

Lines changed: 27 additions & 6 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,28 @@ 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,
798807
fs: os.DirFS(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+
if strings.HasPrefix(name, fs.prefix) {
818+
name = name[len(fs.prefix):]
819+
if len(name) > 1 && os.IsPathSeparator(name[0]) {
820+
name = name[1:]
821+
}
822+
}
823+
}
803824
return fs.fs.Open(name)
804825
}
805826

echo_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import (
88
stdContext "context"
99
"errors"
1010
"fmt"
11+
"io"
1112
"io/fs"
1213
"log/slog"
1314
"net"
1415
"net/http"
1516
"net/http/httptest"
1617
"net/url"
1718
"os"
19+
"path/filepath"
1820
"runtime"
1921
"strings"
2022
"testing"
@@ -79,6 +81,57 @@ func TestNewWithConfig(t *testing.T) {
7981
assert.Equal(t, `Hello, World!`, rec.Body.String())
8082
}
8183

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

0 commit comments

Comments
 (0)