diff --git a/go-runtime/devel.go b/go-runtime/devel.go index 46ad9f29e5..1df533b6e9 100644 --- a/go-runtime/devel.go +++ b/go-runtime/devel.go @@ -3,20 +3,45 @@ package goruntime import ( - "io/fs" + "archive/zip" "os" "os/exec" "path/filepath" "strings" + + "github.com/TBD54566975/ftl/internal" ) // Files is the FTL Go runtime scaffolding files. -var Files = func() fs.FS { +var Files = func() *zip.Reader { cmd := exec.Command("git", "rev-parse", "--show-toplevel") out, err := cmd.CombinedOutput() if err != nil { panic(err) } dir := filepath.Join(strings.TrimSpace(string(out)), "go-runtime", "scaffolding") - return os.DirFS(dir) + w, err := os.CreateTemp("", "") + if err != nil { + panic(err) + } + defer os.Remove(w.Name()) // This is okay because the zip.Reader will keep it open. + if err != nil { + panic(err) + } + + err = internal.ZipDir(dir, w.Name()) + if err != nil { + panic(err) + } + + info, err := w.Stat() + if err != nil { + panic(err) + } + _, _ = w.Seek(0, 0) + zr, err := zip.NewReader(w, info.Size()) + if err != nil { + panic(err) + } + return zr }() diff --git a/go-runtime/release.go b/go-runtime/release.go index 7f0d95d65e..d342bb4d84 100644 --- a/go-runtime/release.go +++ b/go-runtime/release.go @@ -6,7 +6,6 @@ import ( "archive/zip" "bytes" _ "embed" - "io/fs" ) //go:embed scaffolding.zip @@ -16,7 +15,7 @@ var archive []byte // // scaffolding.zip can be generated by running `bit go-runtime/scaffolding.zip` // or indirectly via `bit build/release/ftl`. -var Files fs.FS = func() fs.FS { +var Files = func() *zip.Reader { zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) if err != nil { panic(err) diff --git a/internal/scaffolder.go b/internal/scaffolder.go index e021b33b6f..93efb594d0 100644 --- a/internal/scaffolder.go +++ b/internal/scaffolder.go @@ -1,6 +1,7 @@ package internal import ( + "archive/zip" "io/fs" "os" "path/filepath" @@ -9,7 +10,6 @@ import ( "github.com/alecthomas/errors" "github.com/iancoleman/strcase" - "github.com/otiai10/copy" ) // Scaffold copies the scaffolding files from the given source to the given @@ -19,8 +19,8 @@ import ( // // The functions "snake", "camel", "lowerCamel", "kebab", "upper", and "lower" // are available. -func Scaffold(source fs.FS, destination string, ctx any) error { - err := copy.Copy(".", destination, copy.Options{FS: source, PermissionControl: copy.AddPermission(0600)}) +func Scaffold(source *zip.Reader, destination string, ctx any) error { + err := UnzipDir(source, destination) if err != nil { return errors.WithStack(err) } diff --git a/internal/zip.go b/internal/zip.go new file mode 100644 index 0000000000..1f8b2f3aa6 --- /dev/null +++ b/internal/zip.go @@ -0,0 +1,161 @@ +package internal + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/alecthomas/errors" +) + +// UnzipDir unzips a ZIP archive into the specified directory. +func UnzipDir(zipReader *zip.Reader, destDir string) error { + err := os.MkdirAll(destDir, 0700) + if err != nil { + return errors.WithStack(err) + } + for _, file := range zipReader.File { + destPath, err := sanitizeArchivePath(destDir, file.Name) + if err != nil { + return errors.WithStack(err) + } + + // Create directory if it doesn't exist + if file.FileInfo().IsDir() { + err := os.MkdirAll(destPath, file.Mode()) + if err != nil { + return errors.WithStack(err) + } + continue + } + + // Handle symlinks + if file.Mode()&os.ModeSymlink != 0 { + reader, err := file.Open() + if err != nil { + return errors.WithStack(err) + } + buf := &bytes.Buffer{} + _, err = io.Copy(buf, reader) //nolint:gosec + if err != nil { + return errors.WithStack(err) + } + // This is probably a little bit aggressive, in that the symlink can + // only be beneath its parent directory, rather than the root of the + // zip file. But it's good enough for now. + symlinkDir := filepath.Dir(destPath) + symlinkPath, err := sanitizeArchivePath(symlinkDir, buf.String()) + if err != nil { + return errors.WithStack(err) + } + symlinkPath = strings.TrimPrefix(symlinkPath, symlinkDir+"/") + err = os.Symlink(symlinkPath, destPath) + if err != nil { + return errors.WithStack(err) + } + continue + } + + // Handle regular files + fileReader, err := file.Open() + if err != nil { + return errors.WithStack(err) + } + defer fileReader.Close() + + destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return errors.WithStack(err) + } + defer destFile.Close() + + _, err = io.Copy(destFile, fileReader) //nolint:gosec + if err != nil { + return errors.WithStack(err) + } + } + return nil +} + +func ZipDir(srcDir, destZipFile string) error { + zipFile, err := os.Create(destZipFile) + if err != nil { + return errors.WithStack(err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + return errors.WithStack(filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return errors.WithStack(err) + } + + // Determine path for the zip file header + headerPath := strings.TrimPrefix(path, srcDir) + if strings.HasPrefix(headerPath, string(filepath.Separator)) { + headerPath = headerPath[1:] + } + + // Add trailing slash to directory paths + if info.IsDir() { + headerPath += "/" + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return errors.WithStack(err) + } + header.Name = headerPath + + // Handle symlink + if info.Mode()&os.ModeSymlink != 0 { + dest, err := os.Readlink(path) + if err != nil { + return errors.WithStack(err) + } + + header.Method = zip.Store + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return errors.WithStack(err) + } + _, err = writer.Write([]byte(dest)) + return errors.WithStack(err) + } + + // Handle regular files and directories + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return errors.WithStack(err) + } + + if !info.IsDir() { + file, err := os.Open(path) + if err != nil { + return errors.WithStack(err) + } + defer file.Close() + + _, err = io.Copy(writer, file) + return errors.WithStack(err) + } + + return nil + })) +} + +// Sanitize archive file pathing from "G305: Zip Slip vulnerability" +func sanitizeArchivePath(d, t string) (v string, err error) { + v = filepath.Join(d, t) + if strings.HasPrefix(v, filepath.Clean(d)) { + return v, nil + } + + return "", fmt.Errorf("%s: %s", "content filepath is tainted", t) +}