diff --git a/Makefile b/Makefile index 4865d68..177802a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PHP_VERSION = 8.2.26 +PHP_VERSION = 8.2.27 LEGACY_CLI_VERSION = 4.22.0 GORELEASER_ID ?= platform @@ -49,14 +49,21 @@ internal/legacy/archives/php_linux_$(GOARCH): --progress=plain \ ext/static-php-cli/docker +PHP_WINDOWS_REMOTE_FILENAME := "php-$(PHP_VERSION)-nts-Win32-vs16-x64.zip" internal/legacy/archives/php_windows.zip: - mkdir -p internal/legacy/archives - wget https://windows.php.net/downloads/releases/php-$(PHP_VERSION)-nts-Win32-vs16-x64.zip -O internal/legacy/archives/php_windows.zip + ( \ + set -e ;\ + mkdir -p internal/legacy/archives ;\ + cd internal/legacy/archives ;\ + curl -f "https://windows.php.net/downloads/releases/$(PHP_WINDOWS_REMOTE_FILENAME)" > php_windows.zip ;\ + curl -f https://windows.php.net/downloads/releases/sha256sum.txt | grep "$(PHP_WINDOWS_REMOTE_FILENAME)" | sed s/"$(PHP_WINDOWS_REMOTE_FILENAME)"/"php_windows.zip"/g > php_windows.zip.sha256 ;\ + sha256sum -c php_windows.zip.sha256 ;\ + ) .PHONY: internal/legacy/archives/cacert.pem internal/legacy/archives/cacert.pem: mkdir -p internal/legacy/archives - wget https://curl.se/ca/cacert.pem -O internal/legacy/archives/cacert.pem + curl https://curl.se/ca/cacert.pem > internal/legacy/archives/cacert.pem php: $(PHP_BINARY_PATH) diff --git a/commands/completion.go b/commands/completion.go index 267e19b..c1a87ae 100644 --- a/commands/completion.go +++ b/commands/completion.go @@ -29,7 +29,10 @@ func newCompletionCommand(cnf *config.Config) *cobra.Command { exitWithError(err) } - pharPath := c.PharPath() + pharPath, err := c.PharPath() + if err != nil { + exitWithError(err) + } completions := strings.ReplaceAll( strings.ReplaceAll( diff --git a/go.mod b/go.mod index 4238caf..ff25159 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stretchr/testify v1.9.0 github.com/wk8/go-ordered-map/v2 v2.1.8 golang.org/x/crypto v0.31.0 + golang.org/x/sync v0.10.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 73d74f4..f524cb3 100644 --- a/go.sum +++ b/go.sum @@ -168,6 +168,8 @@ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af90197..c4f6f67 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/platformsh/cli/internal/config" ) @@ -25,6 +26,14 @@ func TestFromYAML(t *testing.T) { cnf, err := config.FromYAML([]byte(validConfig)) assert.NoError(t, err) + tempDir := t.TempDir() + require.NoError(t, os.Setenv(cnf.Application.EnvPrefix+"HOME", tempDir)) + require.NoError(t, os.Setenv(cnf.Application.EnvPrefix+"TMP", filepath.Join(tempDir, "tmp"))) + t.Cleanup(func() { + _ = os.Unsetenv(cnf.Application.EnvPrefix + "HOME") + _ = os.Unsetenv(cnf.Application.EnvPrefix + "TMP") + }) + // Test defaults assert.Equal(t, "state.json", cnf.Application.UserStateFile) assert.Equal(t, true, cnf.Updates.Check) @@ -33,13 +42,16 @@ func TestFromYAML(t *testing.T) { assert.Equal(t, "example-cli-tmp", cnf.Application.TempSubDir) assert.Equal(t, "platform", cnf.Service.ProjectConfigFlavor) + homeDir, err := cnf.HomeDir() + require.NoError(t, err) + assert.Equal(t, tempDir, homeDir) + writableDir, err := cnf.WritableUserDir() assert.NoError(t, err) + assert.Equal(t, filepath.Join(homeDir, cnf.Application.WritableUserDir), writableDir) - if homeDir, err := os.UserHomeDir(); err == nil { - assert.Equal(t, filepath.Join(homeDir, cnf.Application.WritableUserDir), writableDir) - } else { - assert.Equal(t, filepath.Join(os.TempDir(), cnf.Application.TempSubDir), writableDir) - } + d, err := cnf.TempDir() + assert.NoError(t, err) + assert.Equal(t, filepath.Join(tempDir, "tmp", cnf.Application.TempSubDir), d) }) } diff --git a/internal/config/dir.go b/internal/config/dir.go new file mode 100644 index 0000000..949c1a2 --- /dev/null +++ b/internal/config/dir.go @@ -0,0 +1,87 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" +) + +// TempDir returns the path to a user-specific temporary directory, suitable for caches. +// +// It creates the temporary directory if it does not already exist. +// +// The directory can be specified in the {ENV_PREFIX}TMP environment variable. +// +// This does not use os.TempDir, as on Linux/Unix systems that usually returns a +// global /tmp directory, which could conflict with other users. It also does not +// use os.MkdirTemp, as the CLI usually needs a stable (not random) directory +// path. It therefore uses os.UserCacheDir which in turn will use XDG_CACHE_HOME +// or the home directory. +func (c *Config) TempDir() (string, error) { + if c.tempDir != "" { + return c.tempDir, nil + } + d := os.Getenv(c.Application.EnvPrefix + "TMP") + if d == "" { + ucd, err := os.UserCacheDir() + if err != nil { + return "", err + } + d = ucd + } + + // Windows already has a user-specific temporary directory. + if runtime.GOOS == "windows" { + osTemp := os.TempDir() + if strings.HasPrefix(osTemp, d) { + d = osTemp + } + } + + path := filepath.Join(d, c.Application.TempSubDir) + + // If the subdirectory cannot be created due to a read-only filesystem, fall back to /tmp. + if err := os.MkdirAll(path, 0o700); err != nil { + if !errors.Is(err, syscall.EROFS) { + return "", err + } + path = filepath.Join(os.TempDir(), c.Application.TempSubDir) + if err := os.MkdirAll(path, 0o700); err != nil { + return "", err + } + } + c.tempDir = path + + return path, nil +} + +// WritableUserDir returns the path to a writable user-level directory. +// Deprecated: unless backwards compatibility is desired, TempDir is preferable. +func (c *Config) WritableUserDir() (string, error) { + if c.writableUserDir != "" { + return c.writableUserDir, nil + } + hd, err := c.HomeDir() + if err != nil { + return "", err + } + path := filepath.Join(hd, c.Application.WritableUserDir) + if err := os.MkdirAll(path, 0o700); err != nil { + return "", err + } + c.writableUserDir = path + + return path, nil +} + +// HomeDir returns the user's home directory, which can be overridden with the {ENV_PREFIX}HOME variable. +func (c *Config) HomeDir() (string, error) { + d := os.Getenv(c.Application.EnvPrefix + "HOME") + if d != "" { + return d, nil + } + return os.UserHomeDir() +} diff --git a/internal/config/schema.go b/internal/config/schema.go index 79a2a78..e282370 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -1,10 +1,5 @@ package config -import ( - "os" - "path/filepath" -) - // Config provides YAML configuration for the CLI. // This includes some translation strings for vendorization or white-label needs. // @@ -60,6 +55,9 @@ type Config struct { SSH struct { DomainWildcards []string `validate:"required" yaml:"domain_wildcards"` // e.g. ["*.platform.sh"] } `validate:"required"` + + tempDir string `yaml:"-"` + writableUserDir string `yaml:"-"` } // applyDefaults applies defaults to config before parsing. @@ -79,23 +77,3 @@ func (c *Config) applyDynamicDefaults() { c.Application.WritableUserDir = c.Application.UserConfigDir } } - -// WritableUserDir returns the path to a writable user-level directory. -func (c *Config) WritableUserDir() (string, error) { - // Attempt to create the directory under $HOME first. - if homeDir, err := os.UserHomeDir(); err == nil { - path := filepath.Join(homeDir, c.Application.WritableUserDir) - if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { - return "", err - } - return path, nil - } - - // Otherwise,attempt to create it in the temporary directory. - path := filepath.Join(os.TempDir(), c.Application.TempSubDir) - if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { - return "", err - } - - return path, nil -} diff --git a/internal/file/file.go b/internal/file/file.go new file mode 100644 index 0000000..595321f --- /dev/null +++ b/internal/file/file.go @@ -0,0 +1,58 @@ +package file + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" +) + +// WriteIfNeeded writes data to a destination file, only if the file does not exist or if it was partially written. +// To save time, it only checks that the file size is correct, and then matches the end of its contents (up to 32KB). +func WriteIfNeeded(destFilename string, source []byte, perm os.FileMode) error { + matches, err := probablyMatches(destFilename, source, 32*1024) + if err != nil || matches { + return err + } + return Write(destFilename, source, perm) +} + +// Write creates or overwrites a file, somewhat atomically, using a temporary file next to it. +func Write(path string, content []byte, fileMode fs.FileMode) error { + tmpFile := path + ".tmp" + if err := os.WriteFile(tmpFile, content, fileMode); err != nil { + return err + } + + return os.Rename(tmpFile, path) +} + +// probablyMatches checks if a file exists and matches the end of source data (up to checkSize bytes). +func probablyMatches(filename string, data []byte, checkSize int) (bool, error) { + f, err := os.Open(filename) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return false, err + } + if fi.Size() != int64(len(data)) { + return false, nil + } + + buf := make([]byte, min(checkSize, len(data))) + offset := max(0, len(data)-checkSize) + n, err := f.ReadAt(buf, int64(offset)) + if err != nil && err != io.EOF { + return false, err + } + + return bytes.Equal(data[offset:], buf[:n]), nil +} diff --git a/internal/file/file_test.go b/internal/file/file_test.go new file mode 100644 index 0000000..4874ad0 --- /dev/null +++ b/internal/file/file_test.go @@ -0,0 +1,81 @@ +package file + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteIfNeeded(t *testing.T) { + largeContentLength := 128 * 1024 + largeContent := make([]byte, largeContentLength) + largeContent[0] = 'f' + largeContent[largeContentLength-2] = 'o' + largeContent[largeContentLength-1] = 'o' + + largeContent2 := make([]byte, largeContentLength) + largeContent2[0] = 'b' + largeContent2[largeContentLength-2] = 'a' + largeContent2[largeContentLength-1] = 'r' + + assert.Equal(t, len(largeContent), len(largeContent2)) + + cases := []struct { + name string + initialData []byte + sourceData []byte + expectWrite bool + }{ + {"File does not exist", nil, []byte("new data"), true}, + {"File matches source", []byte("same data"), []byte("same data"), false}, + {"File content differs", []byte("old data"), []byte("new data"), true}, + {"Larger file content differs", largeContent, largeContent2, true}, + {"Larger file content matches", largeContent, largeContent, false}, + {"File size differs", []byte("short"), []byte("much longer data"), true}, + {"Empty source", []byte("existing data"), []byte{}, true}, + } + + tmpDir := t.TempDir() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + destFile := filepath.Join(tmpDir, "testfile") + + if c.initialData != nil { + require.NoError(t, os.WriteFile(destFile, c.initialData, 0o600)) + time.Sleep(time.Millisecond * 5) + } + + var modTimeBefore time.Time + stat, err := os.Stat(destFile) + if c.initialData == nil { + require.True(t, os.IsNotExist(err)) + } else { + require.NoError(t, err) + modTimeBefore = stat.ModTime() + } + + err = WriteIfNeeded(destFile, c.sourceData, 0o600) + require.NoError(t, err) + + statAfter, err := os.Stat(destFile) + require.NoError(t, err) + modTimeAfter := statAfter.ModTime() + + if c.expectWrite { + assert.Greater(t, modTimeAfter.Truncate(time.Millisecond), modTimeBefore.Truncate(time.Millisecond)) + } else { + assert.Equal(t, modTimeBefore.Truncate(time.Millisecond), modTimeAfter.Truncate(time.Millisecond)) + } + + data, err := os.ReadFile(destFile) + require.NoError(t, err) + + assert.True(t, bytes.Equal(data, c.sourceData)) + }) + } +} diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index f4e9f6b..1c44f66 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -1,19 +1,22 @@ package legacy import ( - "bytes" "context" _ "embed" + "errors" "fmt" "io" + "io/fs" "os" "os/exec" - "path" + "path/filepath" "time" "github.com/gofrs/flock" + "golang.org/x/sync/errgroup" "github.com/platformsh/cli/internal/config" + "github.com/platformsh/cli/internal/file" ) //go:embed archives/platform.phar @@ -24,48 +27,10 @@ var ( PHPVersion = "0.0.0" ) -var phpPath = fmt.Sprintf("php-%s", PHPVersion) -var pharPath = fmt.Sprintf("phar-%s", LegacyCLIVersion) - -// copyFile from the given bytes to destination -func copyFile(destination string, fin []byte) error { - if _, err := os.Stat(destination); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("could not stat file: %w", err) - } - - fout, err := os.Create(destination) - if err != nil { - return fmt.Errorf("could not create file: %w", err) - } - defer fout.Close() - - r := bytes.NewReader(fin) - - if _, err := io.Copy(fout, r); err != nil { - return fmt.Errorf("could copy file: %w", err) - } - - return nil -} - -// fileChanged checks if a file's content differs from the provided bytes. -func fileChanged(filename string, content []byte) (bool, error) { - stat, err := os.Stat(filename) - if err != nil { - if os.IsNotExist(err) { - return true, nil - } - return false, fmt.Errorf("could not stat file: %w", err) - } - if int(stat.Size()) != len(content) { - return true, nil - } - current, err := os.ReadFile(filename) - if err != nil { - return false, err - } - return !bytes.Equal(current, content), nil -} +const ( + pharBasename = "legacy-cli.phar" + configBasename = "config.yaml" +) // CLIWrapper wraps the legacy CLI type CLIWrapper struct { @@ -79,6 +44,7 @@ type CLIWrapper struct { DisableInteraction bool DebugLogFunc func(string, ...any) + _cacheDir string initialized bool } @@ -88,8 +54,20 @@ func (c *CLIWrapper) debug(msg string, args ...any) { } } -func (c *CLIWrapper) cacheDir() string { - return path.Join(os.TempDir(), fmt.Sprintf("%s-%s-%s", c.Config.Application.Slug, PHPVersion, LegacyCLIVersion)) +func (c *CLIWrapper) cacheDir() (string, error) { + if c._cacheDir == "" { + cd, err := c.Config.TempDir() + if err != nil { + return "", err + } + cd = filepath.Join(cd, fmt.Sprintf("legacy-%s-%s", PHPVersion, LegacyCLIVersion)) + if err := os.Mkdir(cd, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { + return "", err + } + c._cacheDir = cd + } + + return c._cacheDir, nil } // init initializes the CLI wrapper, creating a temporary directory and copying over files. @@ -99,70 +77,63 @@ func (c *CLIWrapper) init() error { } preInit := time.Now() - if _, err := os.Stat(c.cacheDir()); os.IsNotExist(err) { - c.debug("Cache directory does not exist, creating: %s", c.cacheDir()) - if err := os.Mkdir(c.cacheDir(), 0o700); err != nil { - return fmt.Errorf("could not create temporary directory: %w", err) - } + cacheDir, err := c.cacheDir() + if err != nil { + return err } + preLock := time.Now() - fileLock := flock.New(path.Join(c.cacheDir(), ".lock")) + fileLock := flock.New(filepath.Join(cacheDir, ".lock")) if err := fileLock.Lock(); err != nil { return fmt.Errorf("could not acquire lock: %w", err) } c.debug("lock acquired (%s): %s", time.Since(preLock), fileLock.Path()) - //nolint:errcheck - defer fileLock.Unlock() + defer fileLock.Unlock() //nolint:errcheck - if _, err := os.Stat(c.PharPath()); os.IsNotExist(err) { - if c.CustomPharPath != "" { - return fmt.Errorf("legacy CLI phar file not found: %w", err) - } - - c.debug("Phar file does not exist, copying: %s", c.PharPath()) - if err := copyFile(c.PharPath(), phar); err != nil { + g := errgroup.Group{} + g.Go(func() error { + if err := file.WriteIfNeeded(c.pharPath(cacheDir), phar, 0o644); err != nil { return fmt.Errorf("could not copy phar file: %w", err) } - } - - // Always write the config.yaml file if it changed. - configContent, err := config.LoadYAML() - if err != nil { - return fmt.Errorf("could not load config for checking: %w", err) - } - changed, err := fileChanged(c.ConfigPath(), configContent) - if err != nil { - return fmt.Errorf("could not check config file: %w", err) - } - if changed { - if err := copyFile(c.ConfigPath(), configContent); err != nil { - return fmt.Errorf("could not copy config: %w", err) - } - } - - if _, err := os.Stat(c.PHPPath()); os.IsNotExist(err) { - c.debug("PHP binary does not exist, copying: %s", c.PHPPath()) - if err := c.copyPHP(); err != nil { - return fmt.Errorf("could not copy files: %w", err) + return nil + }) + g.Go(func() error { + configContent, err := config.LoadYAML() + if err != nil { + return fmt.Errorf("could not load config for checking: %w", err) } - if err := os.Chmod(c.PHPPath(), 0o700); err != nil { - return fmt.Errorf("could not make PHP executable: %w", err) + if err := file.WriteIfNeeded(filepath.Join(cacheDir, configBasename), configContent, 0o644); err != nil { + return fmt.Errorf("could not write config: %w", err) } + return nil + }) + g.Go(func() error { + return c.copyPHP(cacheDir) + }) + + if err := g.Wait(); err != nil { + return err } c.initialized = true c.debug("initialized PHP CLI (%s)", time.Since(preInit)) + c.initialized = true + c.debug("initialized PHP CLI (%s)", time.Since(preInit)) + return nil } // Exec a legacy CLI command with the given arguments func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { if err := c.init(); err != nil { + return fmt.Errorf("failed to initialize PHP CLI: %w", err) + } + cacheDir, err := c.cacheDir() + if err != nil { return err } - - cmd := c.makeCmd(ctx, args) + cmd := c.makeCmd(ctx, args, cacheDir) if c.Stdin != nil { cmd.Stdin = c.Stdin } else { @@ -182,7 +153,7 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { envPrefix := c.Config.Application.EnvPrefix cmd.Env = append( cmd.Env, - "CLI_CONFIG_FILE="+c.ConfigPath(), + "CLI_CONFIG_FILE="+filepath.Join(cacheDir, configBasename), envPrefix+"UPDATES_CHECK=0", envPrefix+"MIGRATE_CHECK=0", envPrefix+"APPLICATION_PROMPT_SELF_INSTALL=0", @@ -203,37 +174,41 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { c.Version, )) if err := cmd.Run(); err != nil { - // Cleanup cache directory - c.debug("Removing cache directory: %s", c.cacheDir()) - os.RemoveAll(c.cacheDir()) - return fmt.Errorf("could not run legacy CLI command: %w", err) + return fmt.Errorf("could not run PHP CLI command: %w", err) } return nil } // makeCmd makes a legacy CLI command with the given context and arguments. -func (c *CLIWrapper) makeCmd(ctx context.Context, args []string) *exec.Cmd { - iniSettings := c.phpSettings() +func (c *CLIWrapper) makeCmd(ctx context.Context, args []string, cacheDir string) *exec.Cmd { + iniSettings := c.phpSettings(cacheDir) var cmdArgs = make([]string, 0, len(args)+2+len(iniSettings)*2) for _, s := range iniSettings { cmdArgs = append(cmdArgs, "-d", s) } - cmdArgs = append(cmdArgs, c.PharPath()) + cmdArgs = append(cmdArgs, c.pharPath(cacheDir)) cmdArgs = append(cmdArgs, args...) - return exec.CommandContext(ctx, c.PHPPath(), cmdArgs...) //nolint:gosec + return exec.CommandContext(ctx, c.phpPath(cacheDir), cmdArgs...) //nolint:gosec } // PharPath returns the path to the legacy CLI's Phar file. -func (c *CLIWrapper) PharPath() string { +func (c *CLIWrapper) PharPath() (string, error) { if c.CustomPharPath != "" { - return c.CustomPharPath + return c.CustomPharPath, nil + } + cacheDir, err := c.cacheDir() + if err != nil { + return "", err } - return path.Join(c.cacheDir(), pharPath) + return filepath.Join(cacheDir, pharBasename), nil } -// ConfigPath returns the path to the YAML config file that will be provided to the legacy CLI. -func (c *CLIWrapper) ConfigPath() string { - return path.Join(c.cacheDir(), "config.yaml") +func (c *CLIWrapper) pharPath(cacheDir string) string { + if c.CustomPharPath != "" { + return c.CustomPharPath + } + + return filepath.Join(cacheDir, pharBasename) } diff --git a/internal/legacy/php_unix.go b/internal/legacy/php_unix.go index 4e4c741..1316f18 100644 --- a/internal/legacy/php_unix.go +++ b/internal/legacy/php_unix.go @@ -4,24 +4,21 @@ package legacy import ( - "fmt" - "path" + "path/filepath" + + "github.com/platformsh/cli/internal/file" ) // copyPHP to destination, if it does not exist -func (c *CLIWrapper) copyPHP() error { - if err := copyFile(c.PHPPath(), phpCLI); err != nil { - return fmt.Errorf("could not copy PHP CLI: %w", err) - } - - return nil +func (c *CLIWrapper) copyPHP(cacheDir string) error { + return file.WriteIfNeeded(c.phpPath(cacheDir), phpCLI, 0o755) } -// PHPPath returns the path that the PHP CLI will reside -func (c *CLIWrapper) PHPPath() string { - return path.Join(c.cacheDir(), phpPath) +// phpPath returns the path to the temporary PHP-CLI binary +func (c *CLIWrapper) phpPath(cacheDir string) string { + return filepath.Join(cacheDir, "php") } -func (c *CLIWrapper) phpSettings() []string { +func (c *CLIWrapper) phpSettings(_ string) []string { return nil } diff --git a/internal/legacy/php_windows.go b/internal/legacy/php_windows.go index e31ae55..8ca08f0 100644 --- a/internal/legacy/php_windows.go +++ b/internal/legacy/php_windows.go @@ -7,9 +7,13 @@ import ( "fmt" "io" "os" - "path" "path/filepath" + "runtime" "strings" + + "golang.org/x/sync/errgroup" + + "github.com/platformsh/cli/internal/file" ) //go:embed archives/php_windows.zip @@ -19,57 +23,79 @@ var phpCLI []byte var caCert []byte // copyPHP to destination, if it does not exist -func (c *CLIWrapper) copyPHP() error { - dest := path.Join(c.cacheDir(), "php") - br := bytes.NewReader(phpCLI) - r, err := zip.NewReader(br, int64(len(phpCLI))) +func (c *CLIWrapper) copyPHP(cacheDir string) error { + destDir := filepath.Join(cacheDir, "php") + + r, err := zip.NewReader(bytes.NewReader(phpCLI), int64(len(phpCLI))) if err != nil { return fmt.Errorf("could not open zip reader: %w", err) } + g := errgroup.Group{} + g.SetLimit(runtime.NumCPU() * 4) for _, f := range r.File { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("could not open zipped file %s: %w", f.Name, err) - } - defer rc.Close() + g.Go(func() error { + return copyZipFile(f, destDir) + }) + } + if err := g.Wait(); err != nil { + return err + } - fpath := filepath.Join(dest, f.Name[strings.Index(f.Name, string(os.PathSeparator))+1:]) - if f.FileInfo().IsDir() { - continue - } + if err := file.WriteIfNeeded(filepath.Join(destDir, "extras", "cacert.pem"), caCert, 0o644); err != nil { + return err + } - if lastIndex := strings.LastIndex(fpath, string(os.PathSeparator)); lastIndex > -1 { - fdir := fpath[:lastIndex] - if err := os.MkdirAll(fdir, 0755); err != nil { - return fmt.Errorf("could create parent directory %s: %w", fdir, err) - } - } + return nil +} - f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return fmt.Errorf("could not open file to unzip %s: %w", fpath, err) - } - defer f.Close() +// phpPath returns the path to the temporary PHP-CLI binary +func (c *CLIWrapper) phpPath(cacheDir string) string { + return filepath.Join(cacheDir, "php", "php.exe") +} + +// copyZipFile extracts a file from the Zip to the destination directory. +// If the file already exists and has the correct size, it will be skipped. +func copyZipFile(f *zip.File, destDir string) error { + destPath := filepath.Join(destDir, f.Name) + if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("invalid file path: %s", destPath) + } - if _, err := io.Copy(f, rc); err != nil { - return fmt.Errorf("could not write zipped file %s: %w", fpath, err) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("could not create extracted directory %s: %w", destPath, err) } + return nil } - copyFile(path.Join(c.cacheDir(), "php", "extras", "cacert.pem"), caCert) + if existingFileInfo, err := os.Lstat(destPath); err == nil && uint64(existingFileInfo.Size()) == f.UncompressedSize64 { + return nil + } - return nil -} + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("could not create parent directory for extracted file %s: %w", destPath, err) + } -// PHPPath returns the path that the PHP CLI will reside -func (c *CLIWrapper) PHPPath() string { - return path.Join(c.cacheDir(), "php", "php.exe") -} + rc, err := f.Open() + if err != nil { + return fmt.Errorf("could not open file in zip archive %s: %w", f.Name, err) + } + defer rc.Close() -func (c *CLIWrapper) phpSettings() []string { - cacheDir := c.cacheDir() + b, err := io.ReadAll(rc) + if err != nil { + return fmt.Errorf("could not extract zipped file %s: %w", f.Name, err) + } + + if err := file.Write(destPath, b, f.Mode()); err != nil { + return fmt.Errorf("could not write extracted file %s: %w", destPath, err) + } + + return nil +} +func (c *CLIWrapper) phpSettings(cacheDir string) []string { return []string{ "extension=" + filepath.Join(cacheDir, "php", "ext", "php_curl.dll"), "extension=" + filepath.Join(cacheDir, "php", "ext", "php_openssl.dll"),