232 lines
7.3 KiB
Go
232 lines
7.3 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
|
|
// Reject absolute paths and paths containing ".."
|
|
if filepath.IsAbs(header.Linkname) {
|
|
return fmt.Errorf("symlink target must not be absolute: %s -> %s", header.Name, header.Linkname)
|
|
}
|
|
if strings.Contains(header.Linkname, "..") {
|
|
return fmt.Errorf("symlink target must not contain '..': %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
|
|
}
|