contrib/golang-ci-lint/main.go
// Package main provides a tool for downloading and running specific versions of golangci-lint
// across different architectures and repositories. It supports version pinning through
// .golangci-version files and maintains a local cache of downloaded binaries.
//
// Security Features:
// - Validates all file paths and URLs
// - Enforces secure file permissions (0400 for files, 0750 for directories)
// - Prevents command injection
// - Implements secure archive extraction
// - Uses cryptographic verification for downloads
// - Handles decompression bombs (50MB limit)
// - Validates binary paths and permissions
//
// Usage:
//
// golang-ci-lint [flags] [golangci-lint arguments]
//
// The tool automatically:
// - Downloads the correct version for the current architecture
// - Caches binaries for future use
// - Validates security constraints
// - Propagates exit codes from golangci-lint
// - Supports cross-repository usage via go-findroot
//
// Environment:
// - Uses go-findroot for repository detection
// - Maintains secure file permissions
// - Supports various architectures and operating systems
// - Handles timeouts and cancellation via context
//
// Example:
//
// golang-ci-lint run --fix --config=/path/to/.golangci.yml
//
// Note: This tool is designed to be used across different repositories and
// automatically detects the repository root using go-findroot.
package main
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/integralist/go-findroot/find"
"github.com/synapsecns/sanguine/contrib/golang-ci-lint/permissions"
)
const (
downloadURLTemplate = "https://github.com/golangci/golangci-lint/releases/download/v%s/golangci-lint-%s-%s-%s.tar.gz"
cacheDir = "cache"
filePerms = permissions.FilePerms
dirPerms = permissions.DirPerms
execPerms = permissions.ExecPerms
maxDecompressSize = 50 * 1024 * 1024 // 50MB limit for decompression
httpTimeout = 30 * time.Second
maxIdleConns = 10 // Maximum number of idle connections
idleConnTimeout = 5 * time.Second // Idle connection timeout
splitParts = 2 // For strings.SplitN in validateURL
extraArgsCapacity = 3 // Extra capacity for processArgs slice
minTLSVersion = tls.VersionTLS12 // Minimum TLS version for security
defaultArgCapacity = 3 // Default capacity for processArgs slice
)
var (
// ErrBinaryNotFound indicates the golangci-lint binary wasn't found in the archive.
ErrBinaryNotFound = errors.New("golangci-lint binary not found in archive")
// ErrInvalidPath indicates a path is outside the repository root.
ErrInvalidPath = errors.New("path is outside repository root")
// ErrInvalidURL indicates the download URL is not from GitHub releases.
ErrInvalidURL = errors.New("invalid URL: must be from github.com/golangci/golangci-lint/releases")
)
// validateURL ensures the download URL is from GitHub releases.
func validateURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("failed to parse URL: %w", err)
}
if u.Scheme != "https" || u.Host != "github.com" {
return ErrInvalidURL
}
path := strings.TrimPrefix(u.Path, "/")
parts := strings.Split(path, "/")
if len(parts) < 5 || parts[0] != "golangci" || parts[1] != "golangci-lint" || parts[2] != "releases" {
return ErrInvalidURL
}
return nil
}
// validatePath ensures a path is safe to use and within allowed directories.
// nolint: cyclop
func validatePath(path string, allowedDirs ...string) error {
// Get repository root using go-findroot
root, err := find.Repo()
if err == nil {
// Add repository root to allowed directories
allowedDirs = append(allowedDirs, root.Path)
}
// Resolve to absolute path and handle symlinks
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve absolute path: %w", err)
}
// Get the real path, following all symlinks
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
// If the file doesn't exist yet, use the absolute path
if !os.IsNotExist(err) {
return fmt.Errorf("failed to resolve symlinks: %w", err)
}
realPath = absPath
}
// Normalize paths for MacOS by removing /private prefix from /var paths
if runtime.GOOS == "darwin" {
if strings.HasPrefix(realPath, "/private/var") {
realPath = strings.Replace(realPath, "/private/var", "/var", 1)
}
if strings.HasPrefix(absPath, "/private/var") {
absPath = strings.Replace(absPath, "/private/var", "/var", 1)
}
}
if strings.Contains(realPath, "..") {
return fmt.Errorf("path contains directory traversal: %s", realPath)
}
// Always allow temp dir and cache dir
tmpDir := filepath.Clean(os.TempDir())
if runtime.GOOS == "darwin" && strings.HasPrefix(tmpDir, "/private/var") {
tmpDir = strings.Replace(tmpDir, "/private/var", "/var", 1)
}
defaultDirs := []string{
tmpDir,
filepath.Clean(cacheDir),
}
// Combine default and additional allowed directories
allowedDirs = append(defaultDirs, allowedDirs...)
// Check against allowed directories
for _, dir := range allowedDirs {
absDir, err := filepath.Abs(dir)
if err != nil {
continue
}
cleanDir := filepath.Clean(absDir)
if runtime.GOOS == "darwin" && strings.HasPrefix(cleanDir, "/private/var") {
cleanDir = strings.Replace(cleanDir, "/private/var", "/var", 1)
}
// Check both realPath and absPath for compatibility
if strings.HasPrefix(realPath, cleanDir) || strings.HasPrefix(absPath, cleanDir) {
return nil
}
}
return fmt.Errorf("path outside allowed directories: %s", realPath)
}
// safeJoin safely joins paths, preventing directory traversal.
func safeJoin(root, path string) (string, error) {
// Clean both paths
root = filepath.Clean(root)
path = filepath.Clean(path)
// Join paths and verify the result is still under root
joined := filepath.Join(root, path)
if !strings.HasPrefix(filepath.Clean(joined), root) {
return "", ErrInvalidPath
}
return joined, nil
}
// validateBinaryPermissions ensures binary files have correct permissions.
func validateBinaryPermissions(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("failed to stat binary: %w", err)
}
if info.Mode().Perm() != execPerms {
return fmt.Errorf("invalid binary permissions: %o (expected %o)", info.Mode().Perm(), execPerms)
}
return nil
}
// setupLinter prepares the golangci-lint binary, downloading it if necessary.
func setupLinter(ctx context.Context, version, osName, arch string) (string, error) {
root, err := find.Repo()
if err != nil {
return "", fmt.Errorf("failed to get repository root: %w", err)
}
// Create cache directory first with proper permissions
cacheDir := filepath.Join(root.Path, "contrib/golang-ci-lint/cache")
if err := os.MkdirAll(cacheDir, dirPerms); err != nil {
return "", fmt.Errorf("failed to create cache directory: %w", err)
}
binaryName := fmt.Sprintf("golangci-lint-%s-%s-%s/golangci-lint", version, osName, arch)
cachePath := filepath.Join(cacheDir, binaryName)
// Create binary directory if it doesn't exist
if err := os.MkdirAll(filepath.Dir(cachePath), dirPerms); err != nil {
return "", fmt.Errorf("failed to create binary directory: %w", err)
}
// Check cache and download if needed
if _, err := os.Stat(cachePath); err != nil {
if err := downloadAndExtract(ctx, version, osName, arch, cachePath); err != nil {
return "", fmt.Errorf("failed to setup golangci-lint: %w", err)
}
}
return cachePath, nil
}
// findWorkDir locates the current working directory.
func findWorkDir() (string, error) {
// Get repository root using go-findroot
root, err := find.Repo()
if err != nil {
return "", fmt.Errorf("failed to get repository root: %w", err)
}
// Use current directory as working directory
workDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
// Validate the path
if err := validatePath(workDir, root.Path); err != nil {
return "", fmt.Errorf("invalid working directory: %w", err)
}
return workDir, nil
}
// processArgs ensures proper argument formatting and adds default configuration.
// nolint: cyclop
func processArgs(args []string, root string) ([]string, string) {
var workDir string
processedArgs := make([]string, 0, len(args)+defaultArgCapacity)
// Process arguments
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--path":
if i+1 < len(args) {
workDir = args[i+1]
i++ // Skip the next argument since we consumed it
}
default:
// Replace $(GIT_ROOT) in all arguments
arg = strings.ReplaceAll(arg, "$(GIT_ROOT)", root)
processedArgs = append(processedArgs, arg)
}
}
// Ensure "run" is the first argument if not present
hasRun := false
for _, arg := range processedArgs {
if arg == "run" {
hasRun = true
break
}
}
if !hasRun {
processedArgs = append([]string{"run"}, processedArgs...)
}
// Add default config if not specified
hasConfig := false
for _, arg := range processedArgs {
if strings.HasPrefix(arg, "--config") {
hasConfig = true
break
}
}
if !hasConfig {
configPath := filepath.Join(root, ".golangci.yml")
processedArgs = append(processedArgs, "--config", configPath)
}
return processedArgs, workDir
}
// extractTarGz extracts a specific file from a tar.gz archive.
func extractTarGz(tr *tar.Reader, destPath string) error {
// Set secure permissions for file operations
cleanup, err := permissions.SetSecureUmask()
if err != nil {
return fmt.Errorf("failed to set secure permissions: %w", err)
}
defer cleanup()
// Create parent directory with secure permissions
if err := os.MkdirAll(filepath.Dir(destPath), dirPerms); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
return findAndExtractBinary(tr, destPath)
}
// findAndExtractBinary locates and extracts the golangci-lint binary from the archive.
func findAndExtractBinary(tr *tar.Reader, destPath string) error {
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if strings.HasSuffix(header.Name, "golangci-lint") {
return extractBinaryFile(tr, header, destPath)
}
}
return ErrBinaryNotFound
}
// extractBinaryFile handles the extraction of a single binary file.
func extractBinaryFile(tr *tar.Reader, header *tar.Header, destPath string) error {
// Validate file size
if header.Size > maxDecompressSize {
return fmt.Errorf("file too large: %d bytes", header.Size)
}
// Create temporary file
tmpFile := destPath + ".tmp"
if err := createAndCopyBinary(tr, tmpFile); err != nil {
return err
}
// Move to final destination with correct permissions
if err := finalizeBinary(tmpFile, destPath); err != nil {
return err
}
return nil
}
func createAndCopyBinary(tr *tar.Reader, tmpFile string) error {
var errs []error
// Validate path before file operations
if err := validatePath(tmpFile, os.TempDir()); err != nil {
return fmt.Errorf("invalid temporary file path: %w", err)
}
// Safe to use os.OpenFile here as path is validated by validatePath above
//nolint:gosec // G304: path is validated by validatePath above
file, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerms)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
errs = append(errs, fmt.Errorf("failed to close file: %w", closeErr))
}
}()
// Copy with size limit and verify hash
hasher := sha256.New()
reader := io.TeeReader(io.LimitReader(tr, maxDecompressSize), hasher)
if _, err := io.Copy(file, reader); err != nil {
if removeErr := os.Remove(tmpFile); removeErr != nil {
errs = append(errs, fmt.Errorf("failed to remove temporary file: %w", removeErr))
}
errs = append(errs, fmt.Errorf("failed to extract file: %w", err))
return errors.Join(errs...)
}
// Log hash for verification
hash := hex.EncodeToString(hasher.Sum(nil))
fmt.Printf("Extracted file hash: %s\n", hash)
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
// finalizeBinary moves the temporary file to its final destination.
func finalizeBinary(tmpFile, destPath string) error {
var errs []error
// Set correct permissions
if err := os.Chmod(tmpFile, execPerms); err != nil {
if removeErr := os.Remove(tmpFile); removeErr != nil {
errs = append(errs, fmt.Errorf("failed to remove temporary file: %w", removeErr))
}
errs = append(errs, fmt.Errorf("failed to set file permissions: %w", err))
return errors.Join(errs...)
}
// Move file to final destination
if err := os.Rename(tmpFile, destPath); err != nil {
if removeErr := os.Remove(tmpFile); removeErr != nil {
errs = append(errs, fmt.Errorf("failed to remove temporary file: %w", removeErr))
}
errs = append(errs, fmt.Errorf("failed to move file to destination: %w", err))
return errors.Join(errs...)
}
return validateBinaryPermissions(destPath)
}
func readVersion(root string) (string, error) {
versionPath, err := safeJoin(root, ".golangci-version")
if err != nil {
return "", fmt.Errorf("invalid version file path: %w", err)
}
// Validate path before reading
if err := validatePath(versionPath, root); err != nil {
return "", fmt.Errorf("invalid version file path: %w", err)
}
// Safe to use os.ReadFile here as path is validated by validatePath above
//nolint:gosec // G304: path is validated by validatePath above
version, err := os.ReadFile(versionPath)
if err != nil {
return "", fmt.Errorf("reading .golangci-version: %w", err)
}
return strings.TrimSpace(string(version)), nil
}
// nolint: cyclop
func runLinter(ctx context.Context, binaryPath, workDir string, args []string) error {
// Validate binary permissions before execution
info, err := os.Stat(binaryPath)
if err != nil {
return fmt.Errorf("checking binary before execution: %w", err)
}
if info.Mode().Perm() != execPerms {
if err := os.Chmod(binaryPath, execPerms); err != nil {
return fmt.Errorf("setting binary permissions: %w", err)
}
}
// Find repository root for go.work
root, err := find.Repo()
if err != nil {
return fmt.Errorf("finding repository root: %w", err)
}
// If workDir is specified, validate and use it
if workDir != "" {
if err := validatePath(workDir); err != nil {
return fmt.Errorf("invalid working directory: %w", err)
}
// Check if workDir contains go.mod
if _, err := os.Stat(filepath.Join(workDir, "go.mod")); err != nil {
return fmt.Errorf("working directory must contain go.mod file: %w", err)
}
} else {
// Use current directory if no workDir specified
workDir, err = os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
}
// Create command with validated paths and args
cmd := exec.CommandContext(ctx, binaryPath, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = workDir
cmd.Env = append(os.Environ(),
"GO111MODULE=on",
fmt.Sprintf("GOWORK=%s", filepath.Join(root.Path, "go.work")),
)
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return fmt.Errorf("running linter: %w", err)
}
return nil
}
func setupGitRoot() error {
root, err := find.Repo()
if err != nil {
return fmt.Errorf("failed to find repository root: %w", err)
}
if err := os.Setenv("GIT_ROOT", root.Path); err != nil {
return fmt.Errorf("failed to set GIT_ROOT: %w", err)
}
return nil
}
func main() {
if err := setupGitRoot(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if err := run(context.Background()); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
// Set secure permissions for file operations
cleanup, err := permissions.SetSecureUmask()
if err != nil {
return fmt.Errorf("failed to set secure permissions: %w", err)
}
defer cleanup()
// Find repository root using go-findroot
root, err := find.Repo()
if err != nil {
return fmt.Errorf("finding repository root: %w", err)
}
// Read version from .golangci-version file
version, err := readVersion(root.Path)
if err != nil {
return fmt.Errorf("reading version: %w", err)
}
// Setup linter with correct version
cachePath, err := setupLinter(ctx, version, runtime.GOOS, runtime.GOARCH)
if err != nil {
return fmt.Errorf("setting up linter: %w", err)
}
// Process arguments first to get working directory
args, workDir := processArgs(os.Args[1:], root.Path)
// If no working directory specified via --path, use current directory
if workDir == "" {
workDir, err = findWorkDir()
if err != nil {
return fmt.Errorf("finding working directory: %w", err)
}
}
// Run linter with processed arguments and working directory
if err := runLinter(ctx, cachePath, workDir, args); err != nil {
return fmt.Errorf("running linter: %w", err)
}
return nil
}
func setupHTTPClient() *http.Client {
return &http.Client{
Timeout: httpTimeout,
Transport: &http.Transport{
DisableCompression: true,
MaxIdleConns: maxIdleConns,
IdleConnTimeout: idleConnTimeout,
ForceAttemptHTTP2: true,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
Renegotiation: tls.RenegotiateNever,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
},
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
},
},
}
}
func downloadFile(ctx context.Context, url string, tmpFile *os.File) error {
client := setupHTTPClient()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "golangci-lint-manager/1.0")
req.Header.Set("Accept", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download golangci-lint: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close response body: %w", closeErr))
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download golangci-lint: HTTP %d: %s", resp.StatusCode, resp.Status)
}
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "application/octet-stream") &&
!strings.Contains(contentType, "application/x-gzip") {
return fmt.Errorf("unexpected content type: %s", contentType)
}
hasher := sha256.New()
reader := io.TeeReader(io.LimitReader(resp.Body, maxDecompressSize), hasher)
if _, err := io.Copy(tmpFile, reader); err != nil {
return fmt.Errorf("failed to save download: %w", err)
}
hash := hex.EncodeToString(hasher.Sum(nil))
fmt.Printf("Downloaded file hash: %s\n", hash)
return nil
}
func downloadAndExtract(ctx context.Context, version, osName, arch, destPath string) error {
tmpFile, err := os.CreateTemp("", "golangci-lint-*.tar.gz")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer func() {
if closeErr := tmpFile.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close temp file: %w", closeErr))
}
if removeErr := os.Remove(tmpFile.Name()); removeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to remove temp file: %w", removeErr))
}
}()
url := fmt.Sprintf(downloadURLTemplate, version, version, osName, arch)
if err := validateURL(url); err != nil {
return fmt.Errorf("invalid download URL: %w", err)
}
fmt.Printf("Downloading golangci-lint v%s from %s\n", version, url)
if err := downloadFile(ctx, url, tmpFile); err != nil {
return err
}
cacheDir := filepath.Clean(filepath.Dir(destPath))
if err := os.MkdirAll(cacheDir, dirPerms); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
return extractBinary(ctx, tmpFile.Name(), destPath)
}
func extractBinary(_ context.Context, tarPath, destPath string) error {
// Validate paths before operations
if err := validatePath(tarPath, os.TempDir()); err != nil {
return fmt.Errorf("invalid tar file path: %w", err)
}
if err := validatePath(destPath, cacheDir); err != nil {
return fmt.Errorf("invalid destination path: %w", err)
}
// Set secure permissions for file operations
cleanup, err := permissions.SetSecureUmask()
if err != nil {
return fmt.Errorf("failed to set secure permissions: %w", err)
}
defer cleanup()
// Open archive with secure permissions
// Safe to use os.OpenFile here as path is validated by validatePath above
//nolint:gosec // G304: path is validated by validatePath above
file, err := os.OpenFile(tarPath, os.O_RDONLY, filePerms)
if err != nil {
return fmt.Errorf("failed to open archive: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close archive: %w", closeErr))
}
}()
// Create gzip reader with size limit
gzr, err := gzip.NewReader(io.LimitReader(file, maxDecompressSize))
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() {
if closeErr := gzr.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close gzip reader: %w", closeErr))
}
}()
return extractTarGz(tar.NewReader(gzr), destPath)
}