diff options
author | Jaime Martinez <jmartinez@gitlab.com> | 2020-08-05 10:04:07 +0300 |
---|---|---|
committer | Jaime Martinez <jmartinez@gitlab.com> | 2020-08-05 10:04:07 +0300 |
commit | 96a1293252ccf454260d57ce8ffbb956fb606c53 (patch) | |
tree | 69d96ff1a2ec373b6e4939475d64531b370ac582 | |
parent | 6e04f521a7a66cd9a171e968293759705fd40892 (diff) |
PoC with zip support from file resolver421-extract-to-use-in-zip
-rw-r--r-- | internal/serving/fileresolver/fileresolver.go | 35 | ||||
-rw-r--r-- | internal/serving/fileresolver/fileresolver_test.go | 26 | ||||
-rw-r--r-- | internal/serving/fileresolver/fileresolver_zip_test.go | 187 | ||||
-rw-r--r-- | shared/pages/group.no.projects/public.zip | bin | 0 -> 164 bytes | |||
-rw-r--r-- | shared/pages/group/group.test.io/public.zip | bin | 0 -> 2650 bytes | |||
-rw-r--r-- | shared/pages/group/symlink/public.zip | bin | 0 -> 978 bytes |
6 files changed, 213 insertions, 35 deletions
diff --git a/internal/serving/fileresolver/fileresolver.go b/internal/serving/fileresolver/fileresolver.go index f306e4ba..85cc9693 100644 --- a/internal/serving/fileresolver/fileresolver.go +++ b/internal/serving/fileresolver/fileresolver.go @@ -2,6 +2,7 @@ package fileresolver import ( "errors" + "io" "path/filepath" "strings" ) @@ -16,24 +17,28 @@ var ( type evalSymlinkFunc func(string) (string, error) -// ResolveFilePath takes a lookupPath and any subPath to determine the file location. +type openFileFunc func(string) (io.ReadCloser, error) + +func OpenFile(lookupPath, subPath string, evalSymLink evalSymlinkFunc, openFile openFileFunc) (io.ReadCloser, error) { + filePath, err := resolveFilePath(lookupPath, subPath, evalSymLink) + if err != nil { + return nil, err + } + + return openFile(filePath) +} + +// resolveFilePath takes a archivePath and any subPath to determine the file location. // Requires the original requestURLPath to try to resolve index.html // Requires an evalSymlinkFunc to determine if the file exists or not. Useful for resolving files in disk -func ResolveFilePath(lookupPath, subPath, requestURLPath string, evalSymLink evalSymlinkFunc) (string, error) { +func resolveFilePath(lookupPath, subPath string, evalSymLink evalSymlinkFunc) (string, error) { fullPath, err := resolvePath(evalSymLink, lookupPath, subPath) if err != nil { if err == errIsDirectory { // try to resolve index.html from the path we're currently in - if endsWithSlash(requestURLPath) { - fullPath, err = resolvePath(evalSymLink, lookupPath, subPath, "index.html") - if err != nil { - return "", err - } - - return fullPath, nil - } + return resolvePath(evalSymLink, lookupPath, subPath, "index.html") } else if err == errNoExtension { - // assume .html extension + // assume .html extension and try to resolve return resolvePath(evalSymLink, lookupPath, strings.TrimSuffix(subPath, "/")+".html") } @@ -46,8 +51,7 @@ func ResolveFilePath(lookupPath, subPath, requestURLPath string, evalSymLink eva // Resolve the HTTP request to a path on disk, converting requests for // directories to requests for index.html inside the directory if appropriate. // Takes a `evalSymLinkFunc` to try to follow any symlinks. For disk use `filepath.EvalSymlinks`. -// Returns the resolved fullPath or an error -// TODO: handle zip archives +// Returns the resolved fullPath, fileName (filepath.Base) and error func resolvePath(evalSymLink evalSymlinkFunc, publicPath string, subPath ...string) (string, error) { // Ensure that publicPath always ends with "/" publicPath = strings.TrimSuffix(publicPath, "/") + "/" @@ -62,6 +66,10 @@ func resolvePath(evalSymLink evalSymlinkFunc, publicPath string, subPath ...stri return "", errNoExtension } + if evalSymLink == nil { + return testPath, nil + } + fullPath, err := evalSymLink(testPath) if err != nil { return "", errFileNotFound @@ -83,6 +91,7 @@ func endsWithoutHTMLExtension(path string) bool { return !strings.HasSuffix(path, ".html") } +// cleanEmpty removes empty string elements in the slice func cleanEmpty(in []string) []string { var out []string diff --git a/internal/serving/fileresolver/fileresolver_test.go b/internal/serving/fileresolver/fileresolver_test.go index dc26c353..a864f445 100644 --- a/internal/serving/fileresolver/fileresolver_test.go +++ b/internal/serving/fileresolver/fileresolver_test.go @@ -1,7 +1,7 @@ package fileresolver import ( - "archive/zip" + "io" "io/ioutil" "os" "path/filepath" @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestResolveFilePathFromDisk(t *testing.T) { +func TestOpenFileFromDisk(t *testing.T) { cleanup := setUpTests(t) defer cleanup() @@ -18,7 +18,6 @@ func TestResolveFilePathFromDisk(t *testing.T) { name string lookupPath string subPath string - urlPath string expectedFullPath string expectedContent string expectedErr error @@ -27,7 +26,6 @@ func TestResolveFilePathFromDisk(t *testing.T) { name: "file_exists_with_subpath_and_extension", lookupPath: "group/group.test.io/public/", subPath: "index.html", - urlPath: "/index.html", expectedFullPath: "group/group.test.io/public/index.html", expectedContent: "main-dir\n", }, @@ -35,7 +33,6 @@ func TestResolveFilePathFromDisk(t *testing.T) { name: "file_exists_without_extension", lookupPath: "group/group.test.io/public/", subPath: "index", - urlPath: "/index", expectedFullPath: "group/group.test.io/public/index.html", expectedContent: "main-dir\n", }, @@ -43,7 +40,6 @@ func TestResolveFilePathFromDisk(t *testing.T) { name: "file_exists_without_subpath", lookupPath: "group/group.test.io/public/", subPath: "", - urlPath: "/", expectedFullPath: "group/group.test.io/public/index.html", expectedContent: "main-dir\n", }, @@ -51,21 +47,18 @@ func TestResolveFilePathFromDisk(t *testing.T) { name: "file_does_not_exist_without_subpath", lookupPath: "group.no.projects/", subPath: "", - urlPath: "/", expectedErr: errFileNotFound, }, { name: "file_does_not_exist", lookupPath: "group/group.test.io/public/", subPath: "unknown_file.html", - urlPath: "/group.test.io/unknown_file.html", expectedErr: errFileNotFound, }, { name: "symlink_inside_public", lookupPath: "group/symlink/public/", subPath: "index.html", - urlPath: "/symlink/index.html", expectedFullPath: "group/symlink/public/content/index.html", expectedContent: "group/symlink/public/content/index.html\n", }, @@ -73,24 +66,18 @@ func TestResolveFilePathFromDisk(t *testing.T) { name: "symlink_outside_of_public_dir", lookupPath: "group/symlink/public/", subPath: "outside.html", - urlPath: "/symlink/outside.html", expectedErr: errFileNotInPublicDir, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fullPath, err := ResolveFilePath(tt.lookupPath, tt.subPath, tt.urlPath, filepath.EvalSymlinks) + file, err := OpenFile(tt.lookupPath, tt.subPath, filepath.EvalSymlinks, openFSFile) if tt.expectedErr != nil { require.Equal(t, tt.expectedErr, err) return } - - require.Equal(t, tt.expectedFullPath, fullPath) - - file, err := openFSFile(fullPath) require.NoError(t, err) - defer file.Close() content, err := ioutil.ReadAll(file) require.NoError(t, err) @@ -120,12 +107,7 @@ func chdirInPath(t *testing.T, path string) func() { } } -func openZipFile(t *testing.T, fullPath string, archive *zip.Reader) (*zip.File, error) { - t.Helper() - - return nil, nil -} -func openFSFile(fullPath string) (*os.File, error) { +func openFSFile(fullPath string) (io.ReadCloser, error) { fi, err := os.Lstat(fullPath) if err != nil { return nil, errFileNotFound diff --git a/internal/serving/fileresolver/fileresolver_zip_test.go b/internal/serving/fileresolver/fileresolver_zip_test.go new file mode 100644 index 00000000..3a0898ff --- /dev/null +++ b/internal/serving/fileresolver/fileresolver_zip_test.go @@ -0,0 +1,187 @@ +package fileresolver + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenFileFromZip(t *testing.T) { + cleanup := setUpTests(t) + defer cleanup() + + tests := []struct { + name string + archivePath string + subPath string + expectedContent string + expectedErrMsg string + }{ + { + name: "file_exists_with_subpath_and_extension", + archivePath: "group/group.test.io/public.zip", + subPath: "index.html", + expectedContent: "main-dir\n", + }, + { + name: "file_exists_without_extension", + archivePath: "group/group.test.io/public.zip", + subPath: "index", + expectedContent: "main-dir\n", + }, + { + name: "file_exists_without_subpath", + archivePath: "group/group.test.io/public.zip", + subPath: "", + expectedContent: "main-dir\n", + }, + { + name: "file_does_not_exist_without_subpath", + archivePath: "group.no.projects/public.zip", + subPath: "", + expectedErrMsg: "not found", + }, + { + name: "file_does_not_exist", + archivePath: "group/group.test.io/public.zip", + subPath: "unknown_file.html", + expectedErrMsg: "not found", + }, + { + name: "symlink_inside_public", + archivePath: "group/symlink/public.zip", + subPath: "index.html", + expectedContent: "group/symlink/public/content/index.html\n", + }, + } + + z := z{ + maxSymlinkSize: 4096, + maxSymlinkDepth: 3, + zipDeployPath: "public", + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, err := zip.OpenReader(tt.archivePath) + require.NoError(t, err) + defer reader.Close() + z.archive = reader + + file, err := OpenFile("", tt.subPath, nil, z.resolvePublic) + if tt.expectedErrMsg != "" { + require.NotNil(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + return + } + require.NoError(t, err) + + content, err := ioutil.ReadAll(file) + require.NoError(t, err) + require.Contains(t, string(content), tt.expectedContent) + }) + } +} + +// const zipDeployPath = "public" +// const maxSymlinkSize = 4096 +// const maxSymlinkDepth = 3 + +type z struct { + archive *zip.ReadCloser + + zipDeployPath string + maxSymlinkSize int64 + maxSymlinkDepth int +} + +func (z *z) readSymlink(file *zip.File) (string, error) { + fi := file.FileInfo() + + if (fi.Mode() & os.ModeSymlink) != os.ModeSymlink { + return "", nil + } + + if fi.Size() > z.maxSymlinkSize { + return "", errors.New("symlink size too long") + } + + rc, err := file.Open() + if err != nil { + return "", err + } + defer rc.Close() + + data, err := ioutil.ReadAll(rc) + if err != nil { + return "", err + } + + // resolve symlink location relative to current file + targetPath, err := filepath.Rel(filepath.Dir(file.Name), string(data)) + if err != nil { + return "", err + } + + return targetPath, nil +} + +func (z *z) resolveUnchecked(path string) (*zip.File, error) { + // limit the resolve depth of symlink + for depth := 0; depth < z.maxSymlinkDepth; depth++ { + file := z.find(path) + if file == nil { + break + } + + targetPath, err := z.readSymlink(file) + if err != nil { + return nil, err + } + + // not a symlink + if targetPath == "" { + return file, nil + } + + path = targetPath + } + + return nil, fmt.Errorf("%q: not found", path) +} + +func (z *z) resolvePublic(path string) (io.ReadCloser, error) { + path = filepath.Join(z.zipDeployPath, path) + file, err := z.resolveUnchecked(path) + if err != nil { + return nil, err + } + + if !strings.HasPrefix(file.Name, z.zipDeployPath+"/") { + return nil, fmt.Errorf("%q: is not in %s/", file.Name, z.zipDeployPath) + } + + return file.Open() +} + +func (z *z) find(path string) *zip.File { + if z.archive == nil { + return nil + } + + // This is O(n) search, very, very, very slow + for _, file := range z.archive.File { + if file.Name == path || file.Name == path+"/" { + return file + } + } + + return nil +} diff --git a/shared/pages/group.no.projects/public.zip b/shared/pages/group.no.projects/public.zip Binary files differnew file mode 100644 index 00000000..bc9d63b5 --- /dev/null +++ b/shared/pages/group.no.projects/public.zip diff --git a/shared/pages/group/group.test.io/public.zip b/shared/pages/group/group.test.io/public.zip Binary files differnew file mode 100644 index 00000000..6168d2c8 --- /dev/null +++ b/shared/pages/group/group.test.io/public.zip diff --git a/shared/pages/group/symlink/public.zip b/shared/pages/group/symlink/public.zip Binary files differnew file mode 100644 index 00000000..8a73cc7b --- /dev/null +++ b/shared/pages/group/symlink/public.zip |