diff --git a/.dockerignore b/.dockerignore index 5f3cf91..55549ca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,4 +10,4 @@ !testdata/*.php !testdata/*.txt !build-static.sh -!embed/* +!app.tar diff --git a/Dockerfile b/Dockerfile index 0c9d1b1..c931df7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,7 +80,6 @@ WORKDIR /go/src/app COPY --link *.* ./ COPY --link caddy caddy COPY --link C-Thread-Pool C-Thread-Pool -COPY --link embed embed COPY --link internal internal COPY --link testdata testdata diff --git a/alpine.Dockerfile b/alpine.Dockerfile index bac83de..9c77a2c 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -77,7 +77,6 @@ WORKDIR /go/src/app COPY --link *.* ./ COPY --link caddy caddy COPY --link C-Thread-Pool C-Thread-Pool -COPY --link embed embed COPY --link internal internal COPY --link testdata testdata diff --git a/embed/.gitignore b/app.tar similarity index 100% rename from embed/.gitignore rename to app.tar diff --git a/build-static.sh b/build-static.sh index 15d1f20..27f14ef 100755 --- a/build-static.sh +++ b/build-static.sh @@ -105,8 +105,7 @@ cd ../.. # Embed PHP app, if any if [ -d "$EMBED" ]; then - mv embed embed.bak - cp -R "$EMBED" embed + tar -cf app.tar -C "$EMBED" . fi cd caddy/frankenphp/ @@ -115,8 +114,7 @@ go build -buildmode=pie -tags "cgo netgo osusergo static_build" -ldflags "-linkm cd ../.. if [ -d "$EMBED" ]; then - rm -Rf embed - mv embed.bak embed + truncate -s 0 app.tar fi "dist/$bin" version diff --git a/embed.go b/embed.go index 1c65051..6cdd58f 100644 --- a/embed.go +++ b/embed.go @@ -1,53 +1,43 @@ package frankenphp import ( + "archive/tar" + "bytes" "crypto/md5" - "embed" _ "embed" "encoding/hex" + "errors" + "fmt" + "io" "io/fs" + "log" "os" + "path" "path/filepath" + "runtime" "strings" + "time" ) -const embedDir = "embed" - // The path of the embedded PHP application (empty if none) var EmbeddedAppPath string -//go:embed all:embed -var embeddedApp embed.FS +//go:embed app.tar +var embeddedApp []byte func init() { - entries, err := embeddedApp.ReadDir(embedDir) - if err != nil { - panic(err) - } - - if len(entries) == 1 && entries[0].Name() == ".gitignore" { - //no embedded app + if len(embeddedApp) == 0 { + // No embedded app return } - e, err := os.Executable() - if err != nil { - panic(err) - } - - e, err = filepath.EvalSymlinks(e) - if err != nil { - panic(err) - } - - // TODO: use XXH3 instead of MD5 - h := md5.Sum([]byte(e)) + h := md5.Sum(embeddedApp) appPath := filepath.Join(os.TempDir(), "frankenphp_"+hex.EncodeToString(h[:])) if err := os.RemoveAll(appPath); err != nil { panic(err) } - if err := copyToDisk(appPath, embedDir, entries); err != nil { + if err := untar(appPath); err != nil { os.RemoveAll(appPath) panic(err) } @@ -55,37 +45,145 @@ func init() { EmbeddedAppPath = appPath } -func copyToDisk(appPath string, currentDir string, entries []fs.DirEntry) error { - if err := os.Mkdir(appPath+strings.TrimPrefix(currentDir, embedDir), 0700); err != nil { - return err - } +// untar reads the tar file from r and writes it into dir. +// +// Adapted from https://github.com/golang/build/blob/master/cmd/buildlet/buildlet.go +func untar(dir string) (err error) { + t0 := time.Now() + nFiles := 0 + madeDir := map[string]bool{} - for _, entry := range entries { - name := entry.Name() + tr := tar.NewReader(bytes.NewReader(embeddedApp)) + loggedChtimesError := false + for { + f, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar error: %w", err) + } + if f.Typeflag == tar.TypeXGlobalHeader { + // golang.org/issue/22748: git archive exports + // a global header ('g') which after Go 1.9 + // (for a bit?) contained an empty filename. + // Ignore it. + continue + } + rel, err := nativeRelPath(f.Name) + if err != nil { + return fmt.Errorf("tar file contained invalid name %q: %v", f.Name, err) + } + abs := filepath.Join(dir, rel) - if entry.IsDir() { - entries, err := embeddedApp.ReadDir(currentDir + "/" + name) + fi := f.FileInfo() + mode := fi.Mode() + switch { + case mode.IsRegular(): + // Make the directory. This is redundant because it should + // already be made by a directory entry in the tar + // beforehand. Thus, don't check for errors; the next + // write will fail with the same error. + dir := filepath.Dir(abs) + if !madeDir[dir] { + if err := os.MkdirAll(filepath.Dir(abs), mode.Perm()); err != nil { + return err + } + madeDir[dir] = true + } + if runtime.GOOS == "darwin" && mode&0111 != 0 { + // See comment in writeFile. + err := os.Remove(abs) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + wf, err := os.OpenFile(abs, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm()) if err != nil { return err } - - if err := copyToDisk(appPath, currentDir+"/"+name, entries); err != nil { + n, err := io.Copy(wf, tr) + if closeErr := wf.Close(); closeErr != nil && err == nil { + err = closeErr + } + if err != nil { + return fmt.Errorf("error writing to %s: %v", abs, err) + } + if n != f.Size { + return fmt.Errorf("only wrote %d bytes to %s; expected %d", n, abs, f.Size) + } + modTime := f.ModTime + if modTime.After(t0) { + // Clamp modtimes at system time. See + // golang.org/issue/19062 when clock on + // buildlet was behind the gitmirror server + // doing the git-archive. + modTime = t0 + } + if !modTime.IsZero() { + if err := os.Chtimes(abs, modTime, modTime); err != nil && !loggedChtimesError { + // benign error. Gerrit doesn't even set the + // modtime in these, and we don't end up relying + // on it anywhere (the gomote push command relies + // on digests only), so this is a little pointless + // for now. + log.Printf("error changing modtime: %v (further Chtimes errors suppressed)", err) + loggedChtimesError = true // once is enough + } + } + nFiles++ + case mode.IsDir(): + if err := os.MkdirAll(abs, mode.Perm()); err != nil { return err } - - continue - } - - data, err := embeddedApp.ReadFile(currentDir + "/" + name) - if err != nil { - return err - } - - f := appPath + "/" + strings.TrimPrefix(currentDir, embedDir) + "/" + name - if err := os.WriteFile(f, data, 0500); err != nil { - return err + madeDir[abs] = true + case mode&os.ModeSymlink != 0: + // TODO: ignore these for now. They were breaking x/build tests. + // Implement these if/when we ever have a test that needs them. + // But maybe we'd have to skip creating them on Windows for some builders + // without permissions. + default: + return fmt.Errorf("tar file entry %s contained unsupported file type %v", f.Name, mode) } } - return nil } + +// nativeRelPath verifies that p is a non-empty relative path +// using either slashes or the buildlet's native path separator, +// and returns it canonicalized to the native path separator. +func nativeRelPath(p string) (string, error) { + if p == "" { + return "", errors.New("path not provided") + } + + if filepath.Separator != '/' && strings.Contains(p, string(filepath.Separator)) { + clean := filepath.Clean(p) + if filepath.IsAbs(clean) { + return "", fmt.Errorf("path %q is not relative", p) + } + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path %q refers to a parent directory", p) + } + if strings.HasPrefix(p, string(filepath.Separator)) || filepath.VolumeName(clean) != "" { + // On Windows, this catches semi-relative paths like "C:" (meaning “the + // current working directory on volume C:”) and "\windows" (meaning “the + // windows subdirectory of the current drive letter”). + return "", fmt.Errorf("path %q is relative to volume", p) + } + return p, nil + } + + clean := path.Clean(p) + if path.IsAbs(clean) { + return "", fmt.Errorf("path %q is not relative", p) + } + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("path %q refers to a parent directory", p) + } + canon := filepath.FromSlash(p) + if filepath.VolumeName(canon) != "" { + return "", fmt.Errorf("path %q begins with a native volume name", p) + } + return canon, nil +} diff --git a/static-builder.Dockerfile b/static-builder.Dockerfile index 76f32ce..4525579 100644 --- a/static-builder.Dockerfile +++ b/static-builder.Dockerfile @@ -70,6 +70,5 @@ WORKDIR /go/src/app COPY *.* ./ COPY caddy caddy COPY C-Thread-Pool C-Thread-Pool -COPY embed embed RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) ./build-static.sh