Files
kandev/apps/backend/internal/tools/installer/github_tarball_strategy.go
T

228 lines
7.1 KiB
Go

package installer
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// GithubTarballConfig configures a GitHub release tarball download.
type GithubTarballConfig struct {
Owner string // e.g. "coder"
Repo string // e.g. "code-server"
Version string // e.g. "4.96.4"
AssetPattern string // e.g. "code-server-{version}-{os}-{arch}.tar.gz"
BinaryPath string // relative path inside tarball, e.g. "code-server-{version}-{os}-{arch}/bin/code-server"
Targets map[string]string // "darwin/arm64" -> "macos-arm64", "linux/amd64" -> "linux-amd64"
}
// GithubTarballStrategy downloads and extracts tar.gz archives from GitHub releases.
type GithubTarballStrategy struct {
installDir string // base directory for extracted files
binary string // binary name for logging
config GithubTarballConfig
logger *logger.Logger
}
// NewGithubTarballStrategy creates a new GitHub tarball download strategy.
func NewGithubTarballStrategy(installDir, binary string, config GithubTarballConfig, log *logger.Logger) *GithubTarballStrategy {
return &GithubTarballStrategy{
installDir: installDir,
binary: binary,
config: config,
logger: log,
}
}
func (s *GithubTarballStrategy) Name() string {
return fmt.Sprintf("github tarball %s/%s v%s", s.config.Owner, s.config.Repo, s.config.Version)
}
func (s *GithubTarballStrategy) Install(ctx context.Context) (*InstallResult, error) {
target, err := s.resolveTarget()
if err != nil {
return nil, err
}
binaryPath := s.resolveBinaryPath(target)
// Skip download if the binary already exists from a previous install.
if _, err := os.Stat(binaryPath); err == nil {
s.logger.Info("binary already installed, skipping download", zap.String("binary", binaryPath))
return &InstallResult{BinaryPath: binaryPath}, nil
}
url := s.buildURL(target)
s.logger.Info("downloading tarball from GitHub releases",
zap.String("url", url),
zap.String("target", target))
if err := s.download(ctx, url); err != nil {
return nil, err
}
if _, err := os.Stat(binaryPath); err != nil {
return nil, fmt.Errorf("binary not found after extraction: %s", binaryPath)
}
s.logger.Info("tarball install completed", zap.String("binary", binaryPath))
return &InstallResult{BinaryPath: binaryPath}, nil
}
func (s *GithubTarballStrategy) resolveTarget() (string, error) {
targetKey := runtime.GOOS + "/" + runtime.GOARCH
target, ok := s.config.Targets[targetKey]
if !ok {
return "", fmt.Errorf("unsupported platform: %s", targetKey)
}
return target, nil
}
func (s *GithubTarballStrategy) buildURL(target string) string {
asset := s.expandTemplate(s.config.AssetPattern, target)
return fmt.Sprintf("https://github.com/%s/%s/releases/download/v%s/%s",
s.config.Owner, s.config.Repo, s.config.Version, asset)
}
func (s *GithubTarballStrategy) resolveBinaryPath(target string) string {
relPath := s.expandTemplate(s.config.BinaryPath, target)
return filepath.Join(s.installDir, relPath)
}
func (s *GithubTarballStrategy) expandTemplate(tmpl, target string) string {
r := strings.NewReplacer(
"{version}", s.config.Version,
"{os}-{arch}", target,
)
return r.Replace(tmpl)
}
func (s *GithubTarballStrategy) download(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d for %s", resp.StatusCode, url)
}
if err := os.MkdirAll(s.installDir, 0o755); err != nil {
return fmt.Errorf("failed to create install directory %s: %w", s.installDir, err)
}
return extractTarGz(resp.Body, s.installDir)
}
// extractTarGz decompresses and extracts a tar.gz stream into destDir.
func extractTarGz(r io.Reader, destDir string) error {
gzReader, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() { _ = gzReader.Close() }()
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tar read error: %w", err)
}
if err := extractTarEntry(tarReader, header, destDir); err != nil {
return err
}
}
return nil
}
func extractTarEntry(tr *tar.Reader, header *tar.Header, destDir string) error {
cleanName, err := sanitizeTarPath(header.Name, destDir)
if err != nil {
return err
}
target := filepath.Join(destDir, cleanName)
switch header.Typeflag {
case tar.TypeDir:
return os.MkdirAll(target, os.FileMode(header.Mode))
case tar.TypeReg:
return writeFileFromTar(tr, target, os.FileMode(header.Mode))
case tar.TypeSymlink:
// Validate symlink target to prevent path traversal attacks
if filepath.IsAbs(header.Linkname) {
return fmt.Errorf("symlink target must not be absolute: %s -> %s", header.Name, header.Linkname)
}
// Resolve the symlink target path relative to the symlink's location
symlinkDir := filepath.Dir(target)
linkTarget := filepath.Join(symlinkDir, header.Linkname)
// Ensure the resolved symlink target is within destDir
cleanLinkTarget := filepath.Clean(linkTarget)
cleanDestDir := filepath.Clean(destDir)
if !strings.HasPrefix(cleanLinkTarget, cleanDestDir+string(os.PathSeparator)) && cleanLinkTarget != cleanDestDir {
return fmt.Errorf("symlink target escapes destination: %s -> %s", header.Name, header.Linkname)
}
// Remove existing symlink/file before creating (handles re-installs)
_ = os.Remove(target)
return os.Symlink(header.Linkname, target)
default:
// Skip unsupported types (block devices, char devices, etc.)
return nil
}
}
func writeFileFromTar(tr *tar.Reader, target string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", target, err)
}
defer func() { _ = f.Close() }()
// Limit copy size to prevent decompression bombs (1 GB)
const maxFileSize = 1 << 30
if _, err := io.Copy(f, io.LimitReader(tr, maxFileSize)); err != nil {
return fmt.Errorf("failed to write file %s: %w", target, err)
}
return nil
}
// sanitizeTarPath prevents path traversal attacks in tar archives.
func sanitizeTarPath(name, destDir string) (string, error) {
cleanName := filepath.Clean(name)
if strings.HasPrefix(cleanName, "..") || strings.HasPrefix(cleanName, "/") {
return "", fmt.Errorf("invalid tar entry path: %s", name)
}
absTarget := filepath.Join(destDir, cleanName)
if !strings.HasPrefix(absTarget, filepath.Clean(destDir)+string(os.PathSeparator)) && absTarget != filepath.Clean(destDir) {
return "", fmt.Errorf("tar entry %s would escape destination directory", name)
}
return cleanName, nil
}