diff --git a/internal/deploy/extractzip.go b/internal/deploy/extractzip.go new file mode 100644 index 0000000000000000000000000000000000000000..e024456a73eeb081f0505979a5290a3eff93d3fc --- /dev/null +++ b/internal/deploy/extractzip.go @@ -0,0 +1,75 @@ +package deploy + +import ( + "archive/zip" + "errors" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +const extractPrefix = "public/" + +var ErrNoPublicFiles = errors.New("error: archive has no files in public/") + +func ExtractZip(archivePath string, extractPath string) error { + archive, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer archive.Close() + + extracted := 0 + for _, f := range archive.File { + fi := f.FileInfo() + if fi.IsDir() { + continue + } + + cleanName := filepath.Clean(f.Name) + if !strings.HasPrefix(cleanName, extractPrefix) { + continue + } + + if err := extractZipFile(f, path.Join(extractPath, cleanName)); err != nil { + return err + } + + extracted++ + } + + if extracted == 0 { + return ErrNoPublicFiles + } + + return nil +} + +func extractZipFile(f *zip.File, extractPath string) error { + dir := filepath.Dir(extractPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // TODO handle symlinks + + r, err := f.Open() + if err != nil { + return err + } + defer r.Close() + + w, err := os.OpenFile(extractPath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + defer w.Close() + + if _, err := io.Copy(w, r); err != nil { + return err + } + + return w.Close() +} diff --git a/internal/deploy/extractzip_test.go b/internal/deploy/extractzip_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dd27050b1ec658656c51ec1bda4d071004044615 --- /dev/null +++ b/internal/deploy/extractzip_test.go @@ -0,0 +1,81 @@ +package deploy + +import ( + "io/ioutil" + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractZip(t *testing.T) { + testCases := []struct { + desc string + archive string + mustExist map[string]string + mustNotExist []string + matchError func(error) bool + }{ + { + desc: "archive with file inside and outside public/", + archive: "testdata/test1.zip", + mustExist: map[string]string{ + "public/h/e/l/l/o": "world\n", + }, + mustNotExist: []string{"foo"}, + }, + { + desc: "archive with no file in public/", + archive: "testdata/test2.zip", + matchError: func(err error) bool { + return err == ErrNoPublicFiles + }, + }, + { + desc: "archive with evil symlink", + archive: "testdata/test3.zip", + mustExist: map[string]string{ + // The test3.zip archive contains a symlink to /etc/passwd. This test + // asserts that instead of that symlink, we get a regular file whose + // contents are "/etc/passwd". If the extracted "public/passwd" was an + // actual symlink we would get the contents of the /etc/passwd file of + // the system where the test runs. + "public/passwd": "/etc/passwd", + // The "public/bar" symlink tries to point to "foo" but we don't support + // symlinks at the moment. Instead it creates a regular file with + // contents "bar". TODO: support valid symlinks? + "public/bar": "foo", + // "foo" is a regular file with contents "not-bar" + "public/foo": "not-bar\n", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "gitlab-pages") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + extractError := ExtractZip(tc.archive, tmpDir) + if tc.matchError != nil { + require.True(t, tc.matchError(extractError), "error %v does not match", extractError) + return + } + + require.NoError(t, extractError) + + for file, content := range tc.mustExist { + actualContent, err := ioutil.ReadFile(path.Join(tmpDir, file)) + require.NoError(t, err, "read %q", file) + require.Equal(t, content, string(actualContent), "content of %q", file) + } + + for _, file := range tc.mustNotExist { + _, err = os.Stat(path.Join(tmpDir, file)) + require.True(t, os.IsNotExist(err), "error should be '%q does not exist': %v", file, err) + } + }) + } +} diff --git a/internal/deploy/testdata/.gitignore b/internal/deploy/testdata/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c75eeccc8abd3ec866876d1929dff71e39468b21 --- /dev/null +++ b/internal/deploy/testdata/.gitignore @@ -0,0 +1 @@ +/public diff --git a/internal/deploy/testdata/test1.zip b/internal/deploy/testdata/test1.zip new file mode 100644 index 0000000000000000000000000000000000000000..10a3258f945890e6cd6e6c5c4cf52b52c9d9a4f9 Binary files /dev/null and b/internal/deploy/testdata/test1.zip differ diff --git a/internal/deploy/testdata/test2.zip b/internal/deploy/testdata/test2.zip new file mode 100644 index 0000000000000000000000000000000000000000..5420603cf0f818bf516d76e7ac39c8fc2534b436 Binary files /dev/null and b/internal/deploy/testdata/test2.zip differ diff --git a/internal/deploy/testdata/test3.zip b/internal/deploy/testdata/test3.zip new file mode 100644 index 0000000000000000000000000000000000000000..1af5da34ab1338885d12c862bed82d2ba573f668 Binary files /dev/null and b/internal/deploy/testdata/test3.zip differ