Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ The CLI operates as a wrapper around a legacy PHP CLI:
- All platforms use static binaries built with [static-php-cli](https://github.com/crazywhalecc/static-php-cli)
- Supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64
- Extensions included: curl, filter, openssl, pcntl (Unix), phar, posix (Unix), zlib
- On Windows, curl is built with Schannel, which verifies certificates with Windows (its certificate store, distrust list and revocation). No CA file is set, as Schannel would use it instead of the store, so the legacy CLI must make HTTPS requests with curl (Guzzle), not PHP streams (OpenSSL), and uses `CURLSSLOPT_REVOKE_BEST_EFFORT`, as roots installed for TLS inspection often have no revocation information
- On Windows, curl is built with Schannel, which verifies certificates with Windows (its certificate store, distrust list and revocation). No CA file is set, as Schannel would use it instead of the store, unless `SSL_CERT_FILE` is set (the wrapper sets `openssl.cafile` to it, and the legacy CLI also sets `CURLOPT_PROXY_CAINFO`, as PHP only passes it to curl for servers: this matches Go). So the legacy CLI must make HTTPS requests with curl (Guzzle), not PHP streams (OpenSSL), and uses `CURLSSLOPT_REVOKE_BEST_EFFORT`, as roots installed for TLS inspection often have no revocation information

**Downloading PHP Binaries**:
```bash
Expand Down
9 changes: 8 additions & 1 deletion internal/legacy/cert_store_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ curl_setopt($ch, CURLOPT_SSL_OPTIONS, (int) getenv('TEST_SSL_OPTIONS'));
if (getenv('TEST_CAINFO') !== false) {
curl_setopt($ch, CURLOPT_CAINFO, getenv('TEST_CAINFO'));
}
if (getenv('TEST_PROXY') !== false) {
curl_setopt($ch, CURLOPT_PROXY, getenv('TEST_PROXY'));
curl_setopt($ch, CURLOPT_PROXY_SSL_OPTIONS, (int) getenv('TEST_SSL_OPTIONS'));
if (getenv('TEST_PROXY_CAINFO_FROM_INI') !== false) {
curl_setopt($ch, CURLOPT_PROXY_CAINFO, ini_get('openssl.cafile'));
}
}
$body = curl_exec($ch);
echo curl_errno($ch) === 0 ? 'OK:' . $body : 'ERR:' . curl_errno($ch) . ':' . curl_error($ch);`

Expand Down Expand Up @@ -105,7 +112,7 @@ func TestWindowsCertStoreTrust(t *testing.T) {
want: curlSSLConnectError,
},
{
// So the CLI must not set openssl.cafile, which PHP passes to curl.
// So the CLI only sets openssl.cafile, which PHP passes to curl, when SSL_CERT_FILE is set.
name: "a CA file replaces the store",
sslOptions: curlSSLOptRevokeBestEffort,
caFile: otherCAFile,
Expand Down
6 changes: 5 additions & 1 deletion internal/legacy/legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,16 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error {
// makeCmd makes a legacy CLI command with the given context and arguments.
func (c *CLIWrapper) makeCmd(ctx context.Context, args []string, cacheDir string) *exec.Cmd {
phpMgr := newPHPManager(cacheDir)
settings := phpMgr.settings()
// "-n" tells PHP to ignore any php.ini and scan directory on the host.
// The embedded PHP binary is statically built with all extensions it
// needs compiled in, and cannot load shared extensions referenced by a
// system php.ini (e.g. inside Lando containers).
var cmdArgs = make([]string, 0, len(args)+2)
var cmdArgs = make([]string, 0, len(args)+2+len(settings)*2)
cmdArgs = append(cmdArgs, "-n")
for _, s := range settings {
cmdArgs = append(cmdArgs, "-d", s)
}
cmdArgs = append(cmdArgs, c.pharPath(cacheDir))
cmdArgs = append(cmdArgs, args...)
return exec.CommandContext(ctx, phpMgr.binPath(), cmdArgs...) //nolint:gosec
Expand Down
3 changes: 3 additions & 0 deletions internal/legacy/php_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ type phpManager interface {

// binPath returns the path to the temporary PHP binary.
binPath() string

// settings returns PHP INI entries (key=value format).
settings() []string
}

type phpManagerPerOS struct {
Expand Down
5 changes: 5 additions & 0 deletions internal/legacy/php_manager_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ func (m *phpManagerPerOS) copy() error {
func (m *phpManagerPerOS) binPath() string {
return filepath.Join(m.cacheDir, "php")
}

// settings returns no settings: the legacy CLI reads SSL_CERT_FILE itself, through Composer\CaBundle.
func (m *phpManagerPerOS) settings() []string {
return nil
}
15 changes: 14 additions & 1 deletion internal/legacy/php_manager_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package legacy

import (
_ "embed"
"os"
"path/filepath"

"github.com/upsun/cli/internal/file"
Expand All @@ -12,11 +13,23 @@ var phpCLI []byte

// copy writes the PHP binary. Its curl is built with Schannel, which verifies
// certificates with Windows (its certificate store, and its distrust and
// revocation lists) unless given a CA file, so none is written.
// revocation lists) unless given a CA file (see settings).
func (m *phpManagerPerOS) copy() error {
return file.WriteIfNeeded(m.binPath(), phpCLI, 0o755)
}

func (m *phpManagerPerOS) binPath() string {
return filepath.Join(m.cacheDir, "php.exe")
}

// settings passes SSL_CERT_FILE to PHP as openssl.cafile, which PHP gives to
// curl. Schannel then verifies certificates against that file instead of the
// Windows certificate store, as Go does for the rest of the CLI.
//
// PHP reads the path from the environment itself, so it needs no escaping.
func (m *phpManagerPerOS) settings() []string {
if os.Getenv("SSL_CERT_FILE") != "" {
return []string{`openssl.cafile="${SSL_CERT_FILE}"`}
Comment thread
upsun-dispatch[bot] marked this conversation as resolved.
}
return nil
}
153 changes: 153 additions & 0 deletions internal/legacy/php_manager_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package legacy

import (
"crypto/tls"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestWindowsSSLCertFileSetting checks that PHP reads SSL_CERT_FILE back unchanged, whatever characters the path has.
func TestWindowsSSLCertFileSetting(t *testing.T) {
manager := newPHPManager(t.TempDir())
require.NoError(t, manager.copy())

paths := []string{
`C:\Users\runneradmin\AppData\Local\cacert.pem`,
`C:\Users\RUNNER~1\AppData\Local\cacert.pem`,
`\\server\share\cacert.pem`,
`certs\ca.pem`,
`C:\certs\${NOT_A_VAR}\$HOME;"x"\ca.pem`,
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
t.Setenv("SSL_CERT_FILE", path)
args := []string{"-n"}
for _, s := range manager.settings() {
args = append(args, "-d", s)
}
args = append(args, "-r", `echo ini_get("openssl.cafile");`)
output, err := exec.Command(manager.binPath(), args...).CombinedOutput() //nolint:gosec
require.NoError(t, err, "PHP exited with an error: %s", output)
assert.Equal(t, path, string(output))
})
}
}

// TestWindowsSSLCertFile checks that curl in PHP verifies against SSL_CERT_FILE, when it is set, instead of the
// Windows certificate store.
func TestWindowsSSLCertFile(t *testing.T) {
ca := generateTestCA(t)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
server.TLS = &tls.Config{Certificates: []tls.Certificate{ca.serverCert}} //nolint:gosec // test server; Go picks the minimum version.
server.StartTLS()
defer server.Close()

// An HTTPS proxy, with a certificate from the same CA.
proxy := httptest.NewUnstartedServer(http.HandlerFunc(serveConnect))
proxy.TLS = &tls.Config{Certificates: []tls.Certificate{ca.serverCert}} //nolint:gosec // test server; Go picks the minimum version.
proxy.StartTLS()
defer proxy.Close()

cacheDir := t.TempDir()
manager := newPHPManager(cacheDir)
require.NoError(t, manager.copy())

writeCAFile := func(name string, certDER []byte) string {
path := filepath.Join(cacheDir, name)
require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0o600))
return path
}
serverCAFile := writeCAFile("server.pem", ca.certDER)
otherCAFile := writeCAFile("other.pem", generateTestCA(t).certDER)

cases := []struct {
name string
certFile string
env []string
want string
}{
{"a file with the server's root", serverCAFile, nil, "OK:hello"},
{"a file without the server's root", otherCAFile, nil, curlPeerFailedVerification},
{"a missing file", filepath.Join(cacheDir, "missing.pem"), nil, "ERR:77:"}, // CURLE_SSL_CACERT_BADFILE
{
// So the legacy CLI sets CURLOPT_PROXY_CAINFO from openssl.cafile.
name: "an HTTPS proxy is not verified with the file by default",
certFile: serverCAFile,
env: []string{"TEST_PROXY=" + proxy.URL},
want: curlPeerFailedVerification,
},
{
name: "an HTTPS proxy verified with the file",
certFile: serverCAFile,
env: []string{"TEST_PROXY=" + proxy.URL, "TEST_PROXY_CAINFO_FROM_INI=1"},
want: "OK:hello",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv("SSL_CERT_FILE", c.certFile)

// Request the server as the legacy CLI does: with the wrapper's settings and best-effort revocation checks.
args := []string{"-n"}
for _, s := range manager.settings() {
args = append(args, "-d", s)
}
args = append(args, "-r", phpRequestScript)
cmd := exec.Command(manager.binPath(), args...) //nolint:gosec
cmd.Env = append(os.Environ(), "TEST_URL="+server.URL, "TEST_SSL_OPTIONS="+strconv.Itoa(curlSSLOptRevokeBestEffort))
cmd.Env = append(cmd.Env, c.env...)
output, err := cmd.CombinedOutput()
require.NoError(t, err, "PHP exited with an error: %s", output)

assert.True(t, strings.HasPrefix(string(output), c.want), "expected %q to start with %q", output, c.want)
})
}

t.Run("no settings without SSL_CERT_FILE", func(t *testing.T) {
t.Setenv("SSL_CERT_FILE", "")
assert.Empty(t, manager.settings())
})
}

// serveConnect is a minimal HTTPS proxy handler: it tunnels CONNECT requests.
func serveConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "CONNECT only", http.StatusMethodNotAllowed)
return
}
upstream, err := net.Dial("tcp", r.Host)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
conn, buf, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer conn.Close()
if _, err := conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
return
}
done := make(chan struct{})
go func() {
_, _ = io.Copy(upstream, buf)
close(done)
}()
_, _ = io.Copy(conn, upstream)
<-done
}
10 changes: 8 additions & 2 deletions legacy/src/Service/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ public function getNoProxy(): array
/**
* Returns Guzzle request options for HTTP requests: TLS verification, proxies and the user agent.
*
* @return array{headers: array<string, string>, proxy: array<string, mixed>, verify: bool|string, curl?: array<int, int>}
* @return array{headers: array<string, string>, proxy: array<string, mixed>, verify: bool|string, curl?: array<int, int|string>}
*/
public function getGuzzleOptions(): array
{
Expand All @@ -637,14 +637,20 @@ public function getGuzzleOptions(): array
$options['verify'] = false;
} elseif (self::curlUsesSchannel()) {
// Schannel (on Windows) verifies certificates with Windows: its certificate store, distrust list and
// revocation. Given a CA file it would use that instead of the store, so none is set. Revocation is
// revocation. Given a CA file it would use that instead of the store, so none is set here: the wrapper
// sets openssl.cafile, which PHP passes to curl, only when SSL_CERT_FILE is set. Revocation is
// checked where possible, as roots installed to inspect TLS traffic often have no revocation information:
// for the server, and for an HTTPS proxy.
$options['verify'] = true;
$options['curl'] = [
\CURLOPT_SSL_OPTIONS => \CURLSSLOPT_REVOKE_BEST_EFFORT,
\CURLOPT_PROXY_SSL_OPTIONS => \CURLSSLOPT_REVOKE_BEST_EFFORT,
];
// PHP passes openssl.cafile to curl for servers only, so an HTTPS proxy is verified with it too, as in Go.
$caFile = \ini_get('openssl.cafile');
if (\is_string($caFile) && $caFile !== '') {
$options['curl'][\CURLOPT_PROXY_CAINFO] = $caFile;
}
} else {
$options['verify'] = $this->caBundlePath();
}
Expand Down
10 changes: 8 additions & 2 deletions legacy/tests/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,16 @@ public function testGetGuzzleOptions(): void
if (Config::curlUsesSchannel()) {
// Schannel verifies with Windows when it is not given a CA file.
$this->assertTrue($options['verify']);
$this->assertEquals([
$expected = [
CURLOPT_SSL_OPTIONS => CURLSSLOPT_REVOKE_BEST_EFFORT,
CURLOPT_PROXY_SSL_OPTIONS => CURLSSLOPT_REVOKE_BEST_EFFORT,
], $options['curl'] ?? null);
];
// The wrapper sets openssl.cafile from SSL_CERT_FILE, and the proxy is verified with it too.
$caFile = ini_get('openssl.cafile');
if (is_string($caFile) && $caFile !== '') {
$expected[CURLOPT_PROXY_CAINFO] = $caFile;
}
$this->assertEquals($expected, $options['curl'] ?? null);
} else {
$this->assertIsString($options['verify']);
$this->assertFileExists($options['verify']);
Expand Down
Loading