github.com/symfony-cli/symfony-cli@v0.0.0-20240514161054-ece2df437dfa/local/php/composer.go (about)

     1  /*
     2   * Copyright (c) 2021-present Fabien Potencier <fabien@symfony.com>
     3   *
     4   * This file is part of Symfony CLI project
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU Affero General Public License as
     8   * published by the Free Software Foundation, either version 3 of the
     9   * License, or (at your option) any later version.
    10   *
    11   * This program is distributed in the hope that it will be useful,
    12   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    13   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    14   * GNU Affero General Public License for more details.
    15   *
    16   * You should have received a copy of the GNU Affero General Public License
    17   * along with this program. If not, see <http://www.gnu.org/licenses/>.
    18   */
    19  
    20  package php
    21  
    22  import (
    23  	"bufio"
    24  	"bytes"
    25  	"crypto/sha512"
    26  	"encoding/hex"
    27  	"encoding/json"
    28  	"fmt"
    29  	"io"
    30  	"net/http"
    31  	"os"
    32  	"path/filepath"
    33  	"strings"
    34  
    35  	"github.com/pkg/errors"
    36  	"github.com/rs/zerolog"
    37  	"github.com/symfony-cli/symfony-cli/util"
    38  )
    39  
    40  const DefaultComposerVersion = 2
    41  
    42  type ComposerResult struct {
    43  	code  int
    44  	error error
    45  }
    46  
    47  func (c ComposerResult) Error() string {
    48  	if c.error != nil {
    49  		return c.error.Error()
    50  	}
    51  
    52  	return ""
    53  }
    54  
    55  func (c ComposerResult) ExitCode() int {
    56  	return c.code
    57  }
    58  
    59  func Composer(dir string, args, env []string, stdout, stderr, logger io.Writer, debugLogger zerolog.Logger) ComposerResult {
    60  	if os.Getenv("COMPOSER_MEMORY_LIMIT") == "" {
    61  		env = append(env, "COMPOSER_MEMORY_LIMIT=-1")
    62  	}
    63  	e := &Executor{
    64  		Dir:        dir,
    65  		BinName:    "php",
    66  		Stdout:     stdout,
    67  		Stderr:     stderr,
    68  		SkipNbArgs: -1,
    69  		ExtraEnv:   env,
    70  		Logger:     debugLogger,
    71  	}
    72  	composerBin := "composer1"
    73  	if composerVersion() == 2 {
    74  		composerBin = "composer2"
    75  	}
    76  	path, err := e.findComposer(composerBin)
    77  	if err != nil || !isPHPScript(path) {
    78  		fmt.Fprintln(logger, "  WARNING: Unable to find Composer, downloading one. It is recommended to install Composer yourself at https://getcomposer.org/download/")
    79  		// we don't store it under bin/ to avoid it being found by findComposer as we want to only use it as a fallback
    80  		binDir := filepath.Join(util.GetHomeDir(), "composer")
    81  		if path, err = downloadComposer(binDir); err != nil {
    82  			return ComposerResult{
    83  				code:  1,
    84  				error: errors.Wrap(err, "unable to find composer, get it at https://getcomposer.org/download/"),
    85  			}
    86  		}
    87  	}
    88  
    89  	e.Args = append([]string{"php", path}, args...)
    90  	fmt.Fprintf(logger, "  (running %s %s)\n\n", path, strings.TrimSpace(strings.Join(args, " ")))
    91  	ret := e.Execute(false)
    92  	if ret != 0 {
    93  		return ComposerResult{
    94  			code:  ret,
    95  			error: errors.Errorf("unable to run %s %s", path, strings.Join(args, " ")),
    96  		}
    97  	}
    98  	return ComposerResult{}
    99  }
   100  
   101  // isPHPScript checks that the composer file is indeed a phar/PHP script (not a .bat file)
   102  func isPHPScript(path string) bool {
   103  	file, err := os.Open(path)
   104  	if err != nil {
   105  		return false
   106  	}
   107  	defer file.Close()
   108  	reader := bufio.NewReader(file)
   109  	byteSlice, _, err := reader.ReadLine()
   110  	if err != nil {
   111  		return false
   112  	}
   113  
   114  	return bytes.HasPrefix(byteSlice, []byte("#!/")) && bytes.HasSuffix(byteSlice, []byte("php"))
   115  }
   116  
   117  func composerVersion() int {
   118  	var lock struct {
   119  		Version string `json:"plugin-api-version"`
   120  	}
   121  	cwd, err := os.Getwd()
   122  	if err != nil {
   123  		return DefaultComposerVersion
   124  	}
   125  	contents, err := os.ReadFile(filepath.Join(cwd, "composer.lock"))
   126  	if err != nil {
   127  		return DefaultComposerVersion
   128  	}
   129  	if err = json.Unmarshal(contents, &lock); err != nil {
   130  		return DefaultComposerVersion
   131  	}
   132  	if strings.HasPrefix(lock.Version, "1.") {
   133  		return 1
   134  	}
   135  	return DefaultComposerVersion
   136  }
   137  
   138  func findComposer(extraBin string) (string, error) {
   139  	// Special support for OS specific things. They need to run before the
   140  	// PATH detection because most of them adds shell wrappers that we
   141  	// can't run via PHP.
   142  	if pharPath := findComposerSystemSpecific(extraBin); pharPath != "" {
   143  		return pharPath, nil
   144  	}
   145  	for _, file := range []string{extraBin, "composer", "composer.phar"} {
   146  		if pharPath, _ := LookPath(file); pharPath != "" {
   147  			// On Windows, we don't want the .bat, but the real composer phar/PHP file
   148  			if strings.HasSuffix(pharPath, ".bat") {
   149  				pharPath = pharPath[:len(pharPath)-4] + ".phar"
   150  			}
   151  			return pharPath, nil
   152  		}
   153  	}
   154  
   155  	return "", os.ErrNotExist
   156  }
   157  
   158  func downloadComposer(dir string) (string, error) {
   159  	if err := os.MkdirAll(dir, 0755); err != nil {
   160  		return "", err
   161  	}
   162  	path := filepath.Join(dir, "composer.phar")
   163  	if _, err := os.Stat(path); err == nil {
   164  		return path, nil
   165  	}
   166  
   167  	sig, err := downloadComposerInstallerSignature()
   168  	if err != nil {
   169  		return "", err
   170  	}
   171  	installer, err := downloadComposerInstaller()
   172  	if err != nil {
   173  		return "", err
   174  	}
   175  	h := sha512.New384()
   176  	h.Write(installer)
   177  	sigh := h.Sum(nil)
   178  	sigd := make([]byte, hex.EncodedLen(len(sigh)))
   179  	hex.Encode(sigd, sigh)
   180  	if !bytes.Equal(sigd, sig) {
   181  		return "", errors.New("signature was wrong when downloading Composer; please try again")
   182  	}
   183  	setupPath := filepath.Join(dir, "composer-setup.php")
   184  	os.WriteFile(setupPath, installer, 0666)
   185  
   186  	var stdout bytes.Buffer
   187  	e := &Executor{
   188  		Dir:        dir,
   189  		BinName:    "php",
   190  		Args:       []string{"php", setupPath, "--quiet"},
   191  		SkipNbArgs: 1,
   192  		Stdout:     &stdout,
   193  		Stderr:     &stdout,
   194  	}
   195  	ret := e.Execute(false)
   196  	if ret == 1 {
   197  		return "", errors.New("unable to setup Composer")
   198  	}
   199  	if err := os.Chmod(path, 0755); err != nil {
   200  		return "", err
   201  	}
   202  	if err := os.Remove(filepath.Join(dir, "composer-setup.php")); err != nil {
   203  		return "", err
   204  	}
   205  
   206  	return path, nil
   207  }
   208  
   209  func downloadComposerInstaller() ([]byte, error) {
   210  	resp, err := http.Get("https://getcomposer.org/installer")
   211  	if err != nil {
   212  		return nil, err
   213  	}
   214  	defer resp.Body.Close()
   215  	return io.ReadAll(resp.Body)
   216  }
   217  
   218  func downloadComposerInstallerSignature() ([]byte, error) {
   219  	resp, err := http.Get("https://composer.github.io/installer.sig")
   220  	if err != nil {
   221  		return nil, err
   222  	}
   223  	defer resp.Body.Close()
   224  	return io.ReadAll(resp.Body)
   225  }