218 lines
6.7 KiB
Go
218 lines
6.7 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type releaseManifest struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
ReleaseID string `json:"release_id"`
|
|
Version string `json:"version"`
|
|
DeploymentSchema int `json:"deployment_schema"`
|
|
Platforms []string `json:"platforms"`
|
|
}
|
|
type releaseInfo struct {
|
|
releaseManifest
|
|
Repository string `json:"source_repository"`
|
|
Commit string `json:"source_commit"`
|
|
}
|
|
|
|
var errReleaseIncompatible = errors.New("release layout or platform is incompatible")
|
|
|
|
var releaseCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
|
var releaseID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
|
|
const releaseLimit = 32 << 20
|
|
|
|
func releaseSource() (string, string, error) {
|
|
repository := os.Getenv("DSH_RELEASE_REPOSITORY")
|
|
if repository == "" {
|
|
repository = "https://git.misaka.ren/m1saka/dsh-web.git"
|
|
}
|
|
branch := os.Getenv("DSH_RELEASE_BRANCH")
|
|
if branch == "" {
|
|
branch = "dockerfile"
|
|
}
|
|
u, err := url.Parse(repository)
|
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(repository, "\r\n") || !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(branch) || strings.Contains(branch, "..") {
|
|
return "", "", errors.New("invalid trusted release source configuration")
|
|
}
|
|
return repository, branch, nil
|
|
}
|
|
|
|
type boundedReleaseBuffer struct{ bytes.Buffer }
|
|
|
|
func (b *boundedReleaseBuffer) Write(p []byte) (int, error) {
|
|
if b.Len()+len(p) > releaseLimit {
|
|
return 0, errors.New("release exceeds size limit")
|
|
}
|
|
return b.Buffer.Write(p)
|
|
}
|
|
func gitReleaseCommand(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
|
gitArgs := append([]string{"-c", "credential.helper=", "-c", "http.followRedirects=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "fetch.unpackLimit=1"}, args...)
|
|
// The inherited file-size limit also bounds Git's on-disk pack, not just stdout.
|
|
cmd := exec.CommandContext(ctx, "/bin/sh", append([]string{"-c", "ulimit -f 32768; exec git \"$@\"", "git"}, gitArgs...)...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
|
cmd.Dir = dir
|
|
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_LFS_SKIP_SMUDGE=1"}
|
|
cmd.WaitDelay = time.Second
|
|
var out boundedReleaseBuffer
|
|
cmd.Stdout = &out
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, errors.New("trusted release Git operation failed")
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
var releaseFetchSlot = make(chan struct{}, 1)
|
|
|
|
func readRelease(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
|
|
var info releaseInfo
|
|
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
|
defer cancel()
|
|
select {
|
|
case releaseFetchSlot <- struct{}{}:
|
|
defer func() { <-releaseFetchSlot }()
|
|
case <-ctx.Done():
|
|
return info, nil, ctx.Err()
|
|
}
|
|
dir, err := os.MkdirTemp("", "dsh-release-")
|
|
if err != nil {
|
|
return info, nil, err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
if _, err = gitReleaseCommand(ctx, dir, "init", "--quiet"); err != nil {
|
|
return info, nil, err
|
|
}
|
|
if _, err = gitReleaseCommand(ctx, dir, "fetch", "--quiet", "--depth=1", "--no-tags", repository, ref); err != nil {
|
|
return info, nil, err
|
|
}
|
|
commit, err := gitReleaseCommand(ctx, dir, "rev-parse", "FETCH_HEAD^{commit}")
|
|
if err != nil {
|
|
return info, nil, err
|
|
}
|
|
info.Commit = strings.TrimSpace(string(commit))
|
|
info.Repository = repository
|
|
if !releaseCommit.MatchString(info.Commit) || (releaseCommit.MatchString(ref) && info.Commit != ref) {
|
|
return info, nil, errors.New("release commit mismatch")
|
|
}
|
|
archive, err := gitReleaseCommand(ctx, dir, "archive", "--format=tar", info.Commit, "--", "release.json", "image")
|
|
if err != nil {
|
|
return info, nil, err
|
|
}
|
|
return parseReleaseArchive(info, archive)
|
|
}
|
|
|
|
func parseReleaseArchive(info releaseInfo, archive []byte) (releaseInfo, io.Reader, error) {
|
|
var manifest []byte
|
|
tr := tar.NewReader(bytes.NewReader(archive))
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return info, nil, err
|
|
}
|
|
if h.Name == "release.json" && h.Typeflag == tar.TypeReg {
|
|
if h.Size > 16384 {
|
|
return info, nil, errors.New("release manifest too large")
|
|
}
|
|
manifest, err = io.ReadAll(tr)
|
|
if err != nil {
|
|
return info, nil, err
|
|
}
|
|
}
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(manifest))
|
|
dec.DisallowUnknownFields()
|
|
if dec.Decode(&info.releaseManifest) != nil || dec.Decode(new(any)) != io.EOF {
|
|
return info, nil, errors.New("invalid release manifest")
|
|
}
|
|
m := info.releaseManifest
|
|
compatible := false
|
|
for _, p := range m.Platforms {
|
|
if p == imagePlatform {
|
|
compatible = true
|
|
}
|
|
}
|
|
if m.SchemaVersion != 1 || !releaseID.MatchString(m.ReleaseID) || !validBuildVersion(m.Version) || m.DeploymentSchema < 1 || len(m.Platforms) == 0 {
|
|
return info, nil, errors.New("unsupported release manifest or platform")
|
|
}
|
|
if m.DeploymentSchema != 7 || !compatible {
|
|
return info, nil, errReleaseIncompatible
|
|
}
|
|
var out bytes.Buffer
|
|
tw := tar.NewWriter(&out)
|
|
tr = tar.NewReader(bytes.NewReader(archive))
|
|
dockerfile := false
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return info, nil, err
|
|
}
|
|
name := h.Name
|
|
if !strings.HasPrefix(name, "image/") {
|
|
continue
|
|
}
|
|
name = strings.TrimPrefix(name, "image/")
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if path.IsAbs(name) || path.Clean(strings.TrimSuffix(name, "/")) != strings.TrimSuffix(name, "/") || name == ".." || strings.HasPrefix(name, "../") {
|
|
return info, nil, errors.New("unsafe release context path")
|
|
}
|
|
excluded := false
|
|
for _, part := range strings.Split(name, "/") {
|
|
if part == ".git" || part == ".env" || strings.HasPrefix(part, ".env.") || part == ".netrc" || part == ".npmrc" || part == ".ssh" || part == ".git-credentials" {
|
|
excluded = true
|
|
}
|
|
}
|
|
if excluded {
|
|
continue
|
|
}
|
|
if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeDir {
|
|
return info, nil, errors.New("release context links and special files are not supported")
|
|
}
|
|
h.Name = name
|
|
h.PAXRecords = nil
|
|
h.Uname = ""
|
|
h.Gname = ""
|
|
h.Uid = 0
|
|
h.Gid = 0
|
|
if name == "Dockerfile" && h.Typeflag == tar.TypeReg {
|
|
dockerfile = true
|
|
}
|
|
if err = tw.WriteHeader(h); err != nil {
|
|
return info, nil, err
|
|
}
|
|
if _, err = io.Copy(tw, tr); err != nil {
|
|
return info, nil, err
|
|
}
|
|
}
|
|
if !dockerfile {
|
|
return info, nil, errors.New("release context Dockerfile missing")
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return info, nil, err
|
|
}
|
|
return info, bytes.NewReader(out.Bytes()), nil
|
|
}
|