diff --git a/app/main.go b/app/main.go index 0e2da56b4..d010935ce 100644 --- a/app/main.go +++ b/app/main.go @@ -20,6 +20,7 @@ import ( "github.com/cashapp/hermit" "github.com/cashapp/hermit/cache" "github.com/cashapp/hermit/github" + "github.com/cashapp/hermit/github/auth" "github.com/cashapp/hermit/sources" "github.com/cashapp/hermit/state" "github.com/cashapp/hermit/ui" @@ -170,14 +171,6 @@ func Main(config Config) { cli = &unactivated{cliBase: common} } - githubToken := os.Getenv("HERMIT_GITHUB_TOKEN") - if githubToken == "" { - githubToken = os.Getenv("GITHUB_TOKEN") - p.Tracef("GitHub token set from GITHUB_TOKEN") - } else { - p.Tracef("GitHub token set from HERMIT_GITHUB_TOKEN") - } - kongOptions := []kong.Option{ kong.Groups{ "env": "Environment:\nCommands for creating and managing environments.", @@ -212,6 +205,23 @@ func Main(config Config) { log.Fatalf("failed to initialise CLI: %s", err) } + ctx, err := parser.Parse(os.Args[1:]) + parser.FatalIfErrorf(err) + configureLogging(cli, ctx, p) + + userConfig := NewUserConfigWithDefaults() + userConfigPath := cli.getUserConfigFile() + + if IsUserConfigExists(userConfigPath) { + p.Tracef("Loading user config from: %s", userConfigPath) + userConfig, err = LoadUserConfig(userConfigPath) + if err != nil { + log.Printf("%s: %s", userConfigPath, err) + } + } else { + p.Tracef("No user config found at: %s", userConfigPath) + } + var envInfo *hermit.EnvInfo if isActivated { envInfo, err = hermit.LoadEnvInfo(envPath) @@ -220,12 +230,36 @@ func Main(config Config) { } } + // Initialize GitHub auth provider if needed + var ( + githubAuthProvider auth.Provider + githubToken string + ) + if envInfo != nil && len(envInfo.Config.GitHubTokenAuth.Match) > 0 { + providerType := auth.ProviderTypeEnv + if userConfig.GHCliAuth { + providerType = auth.ProviderTypeGHCli + } + provider, err := auth.NewProvider(providerType, p) + if err != nil { + p.Fatalf("Failed to create GitHub auth provider: %v", err) + } + githubAuthProvider = provider + if token, tokenErr := provider.GetToken(); tokenErr != nil { + p.Tracef("GitHub auth provider %s did not return token: %v", providerType, tokenErr) + } else { + githubToken = token + } + } + getSource := config.PackageSourceSelector if config.PackageSourceSelector == nil { getSource = cache.GetSource } defaultHTTPClient := config.defaultHTTPClient(p) - ghClient := github.New(defaultHTTPClient, githubToken) + + // Use the auth provider for GitHub client + ghClient := github.New(p, defaultHTTPClient, githubAuthProvider) var matcher github.RepoMatcher if envInfo != nil { @@ -249,23 +283,6 @@ func Main(config Config) { log.Fatalf("failed to open cache: %s", err) } - ctx, err := parser.Parse(os.Args[1:]) - parser.FatalIfErrorf(err) - configureLogging(cli, ctx, p) - - userConfig := NewUserConfigWithDefaults() - userConfigPath := cli.getUserConfigFile() - - if IsUserConfigExists(userConfigPath) { - p.Tracef("Loading user config from: %s", userConfigPath) - userConfig, err = LoadUserConfig(userConfigPath) - if err != nil { - log.Printf("%s: %s", userConfigPath, err) - } - } else { - p.Tracef("No user config found at: %s", userConfigPath) - } - config.State.LockTimeout = cli.getLockTimeout() sta, err = state.Open(hermit.UserStateDir, config.State, cache) if err != nil { diff --git a/app/user_config.go b/app/user_config.go index d68099d7a..52773ff6c 100644 --- a/app/user_config.go +++ b/app/user_config.go @@ -29,6 +29,7 @@ type UserConfig struct { NoGit bool `hcl:"no-git,optional" help:"If true Hermit will never add/remove files from Git automatically."` Idea bool `hcl:"idea,optional" help:"If true Hermit will try to add the IntelliJ IDEA plugin automatically."` Defaults hermit.Config `hcl:"defaults,block,optional" help:"Default configuration values for new Hermit environments."` + GHCliAuth bool `hcl:"gh-cli-auth,optional" help:"If true, use GitHub CLI (gh) for token authentication instead of environment variables."` } func NewUserConfigWithDefaults() UserConfig { diff --git a/docs/docs/usage/user-config-schema.hcl b/docs/docs/usage/user-config-schema.hcl index 9ce7e8b74..46d019795 100644 --- a/docs/docs/usage/user-config-schema.hcl +++ b/docs/docs/usage/user-config-schema.hcl @@ -8,7 +8,8 @@ short-prompt = boolean # (optional) no-git = boolean # (optional) # If true Hermit will try to add the IntelliJ IDEA plugin automatically. idea = boolean # (optional) - +# If true, use GitHub CLI (gh) for token authentication instead of environment variables. +gh-cli-auth = boolean # (optional) # Default configuration values for new Hermit environments. defaults { # Extra environment variables. diff --git a/github/api.go b/github/api.go index c1370a7cc..36329efca 100644 --- a/github/api.go +++ b/github/api.go @@ -13,6 +13,8 @@ import ( "sync" "github.com/cashapp/hermit/errors" + "github.com/cashapp/hermit/github/auth" + "github.com/cashapp/hermit/ui" ) const ( @@ -49,14 +51,14 @@ type Client struct { } // New creates a new GitHub API client. -func New(client *http.Client, token string) *Client { +func New(ui *ui.UI, client *http.Client, provider auth.Provider) *Client { if client == nil { client = http.DefaultClient } - if token == "" { + if provider == nil { client = http.DefaultClient } else { - client = &http.Client{Transport: TokenAuthenticatedTransport(client.Transport, token)} + client = &http.Client{Transport: AuthenticatedTransport(ui, client.Transport, provider)} } return &Client{client: client} } diff --git a/github/auth/provider.go b/github/auth/provider.go new file mode 100644 index 000000000..ea3da620f --- /dev/null +++ b/github/auth/provider.go @@ -0,0 +1,93 @@ +package auth + +import ( + "os" + "os/exec" + "strings" + "sync" + + "github.com/cashapp/hermit/errors" + "github.com/cashapp/hermit/ui" +) + +const ( + ProviderTypeEnv = "env" + ProviderTypeGHCli = "gh-cli" +) + +// Provider is an interface for GitHub token providers +type Provider interface { + // GetToken returns a GitHub token or an error if one cannot be obtained + GetToken() (string, error) +} + +// EnvProvider implements Provider using environment variables +type EnvProvider struct { + ui *ui.UI +} + +// GetToken returns a token from environment variables +func (p *EnvProvider) GetToken() (string, error) { + p.ui.Debugf("Getting GitHub token from environment variables") + if token := os.Getenv("HERMIT_GITHUB_TOKEN"); token != "" { + p.ui.Tracef("Using HERMIT_GITHUB_TOKEN for GitHub authentication") + return token, nil + } + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + p.ui.Tracef("Using GITHUB_TOKEN for GitHub authentication") + return token, nil + } + p.ui.Tracef("No GitHub token found in environment") + return "", errors.New("no GitHub token found in environment") +} + +// GHCliProvider implements Provider using the gh CLI tool +type GHCliProvider struct { + // cache the token and only refresh when needed + token string + tokenLock sync.Mutex + ui *ui.UI +} + +// GetToken returns a token from gh CLI +func (p *GHCliProvider) GetToken() (string, error) { + p.ui.Debugf("Getting GitHub token from gh") + p.tokenLock.Lock() + defer p.tokenLock.Unlock() + + // Return cached token if available + if p.token != "" { + return p.token, nil + } + + // Check if gh is installed + ghPath, err := exec.LookPath("gh") + if err != nil { + return "", errors.New("gh CLI not found in PATH") + } + + p.ui.Tracef("gh found: %s", ghPath) + + // Run gh auth token + cmd := exec.Command("gh", "auth", "token") + output, err := cmd.CombinedOutput() + if err != nil { + p.ui.Warnf("gh auth failed: %s", strings.TrimSpace(string(output))) + return "", errors.Wrap(err, "gh auth failed") + } + + p.token = strings.TrimSpace(string(output)) + return p.token, nil +} + +// NewProvider creates a new token provider based on the specified type +func NewProvider(providerType string, ui *ui.UI) (Provider, error) { + switch providerType { + case ProviderTypeEnv, "": + return &EnvProvider{ui: ui}, nil + case ProviderTypeGHCli: + return &GHCliProvider{ui: ui}, nil + default: + return nil, errors.Errorf("unknown GitHub token provider: %s", providerType) + } +} diff --git a/github/auth/provider_test.go b/github/auth/provider_test.go new file mode 100644 index 000000000..141633ad5 --- /dev/null +++ b/github/auth/provider_test.go @@ -0,0 +1,125 @@ +package auth + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/cashapp/hermit/ui" +) + +func TestEnvProvider(t *testing.T) { + ui, _ := ui.NewForTesting() + provider := &EnvProvider{ui: ui} + + t.Run("no tokens set", func(t *testing.T) { + os.Unsetenv("HERMIT_GITHUB_TOKEN") + os.Unsetenv("GITHUB_TOKEN") + token, err := provider.GetToken() + assert.Error(t, err) + assert.Equal(t, "", token) + }) + + t.Run("HERMIT_GITHUB_TOKEN set", func(t *testing.T) { + t.Setenv("HERMIT_GITHUB_TOKEN", "hermit-token") + t.Setenv("GITHUB_TOKEN", "") + token, err := provider.GetToken() + assert.NoError(t, err) + assert.Equal(t, "hermit-token", token) + }) + + t.Run("GITHUB_TOKEN set", func(t *testing.T) { + t.Setenv("HERMIT_GITHUB_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "github-token") + token, err := provider.GetToken() + assert.NoError(t, err) + assert.Equal(t, "github-token", token) + }) + + t.Run("both tokens set, HERMIT_GITHUB_TOKEN takes precedence", func(t *testing.T) { + t.Setenv("HERMIT_GITHUB_TOKEN", "hermit-token") + t.Setenv("GITHUB_TOKEN", "github-token") + token, err := provider.GetToken() + assert.NoError(t, err) + assert.Equal(t, "hermit-token", token) + }) +} + +func TestGHCliProvider(t *testing.T) { + ui, _ := ui.NewForTesting() + provider := &GHCliProvider{ui: ui} + + t.Run("gh not installed", func(t *testing.T) { + t.Setenv("PATH", "") + + token, err := provider.GetToken() + assert.Error(t, err) + assert.Equal(t, "", token) + assert.Contains(t, err.Error(), "gh CLI not found") + }) + + t.Run("token caching", func(t *testing.T) { + // Skip if gh not installed + if _, err := exec.LookPath("gh"); err != nil { + t.Skip("gh CLI not installed") + } + + // First call should get a real token + token1, err := provider.GetToken() + if err != nil { + t.Skip("gh auth token failed, probably not authenticated") + } + assert.NotEqual(t, "", token1) + + // Second call should return cached token + token2, err := provider.GetToken() + assert.NoError(t, err) + assert.Equal(t, token1, token2) + }) +} + +func TestNewProvider(t *testing.T) { + tests := []struct { + name string + provider string + wantType Provider + wantErrText string + }{ + { + name: "env provider", + provider: "env", + wantType: &EnvProvider{}, + }, + { + name: "empty string defaults to env", + provider: "", + wantType: &EnvProvider{}, + }, + { + name: "gh-cli provider", + provider: "gh-cli", + wantType: &GHCliProvider{}, + }, + { + name: "unknown provider", + provider: "unknown", + wantErrText: "unknown GitHub token provider: unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ui, _ := ui.NewForTesting() + got, err := NewProvider(tt.provider, ui) + if tt.wantErrText != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrText) + return + } + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("%T", tt.wantType), fmt.Sprintf("%T", got)) + }) + } +} diff --git a/github/http.go b/github/http.go index 77b29cf7c..f9d20b9a5 100644 --- a/github/http.go +++ b/github/http.go @@ -1,30 +1,37 @@ +//go:build !localgithub + package github import ( "net/http" + + "github.com/cashapp/hermit/github/auth" + "github.com/cashapp/hermit/ui" ) -// TokenAuthenticatedTransport returns a HTTP transport that will inject a -// GitHub authentication token into any requests to github.com. -// -// Conceptually similar to -// https://github.com/google/go-github/blob/d23570d44313ca73dbcaadec71fc43eca4d29f8b/github/github.go#L841-L875 -func TokenAuthenticatedTransport(transport http.RoundTripper, token string) http.RoundTripper { +// AuthenticatedTransport returns a HTTP transport that will inject a +// GitHub authentication token into any requests to github.com, fetching the token +// from the provided auth.Provider only when needed. +func AuthenticatedTransport(_ *ui.UI, transport http.RoundTripper, provider auth.Provider) http.RoundTripper { if transport == nil { transport = http.DefaultTransport } - return &githubAuthenticatedHTTPClient{rt: transport, token: token} + return &githubProviderAuthenticatedHTTPClient{rt: transport, provider: provider} } -type githubAuthenticatedHTTPClient struct { - token string - rt http.RoundTripper +type githubProviderAuthenticatedHTTPClient struct { + provider auth.Provider + rt http.RoundTripper } -func (g *githubAuthenticatedHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { +func (g *githubProviderAuthenticatedHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) // The stdlib docs recommend not mutating the request in place. - if (req.URL.Host == "github.com" || req.URL.Host == "api.github.com") && g.token != "" { - req.Header.Set("Authorization", "token "+g.token) + if req.URL.Host == "github.com" || req.URL.Host == "api.github.com" { + // Only fetch the token when needed for GitHub API requests + token, err := g.provider.GetToken() + if err == nil && token != "" { + req.Header.Set("Authorization", "token "+token) + } } return g.rt.RoundTrip(req) } diff --git a/github/http_localgithub.go b/github/http_localgithub.go new file mode 100644 index 000000000..251cc18bc --- /dev/null +++ b/github/http_localgithub.go @@ -0,0 +1,78 @@ +//go:build localgithub + +package github + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/cashapp/hermit/github/auth" + "github.com/cashapp/hermit/ui" +) + +// AuthenticatedTransport returns a HTTP transport that will inject a +// GitHub authentication token into any requests and handle test-specific URL overrides, +// fetching the token from the provided auth.Provider only when needed. +func AuthenticatedTransport(ui *ui.UI, transport http.RoundTripper, provider auth.Provider) http.RoundTripper { + if transport == nil { + transport = http.DefaultTransport + } + return &testGitHubProviderClient{ + rt: transport, + provider: provider, + ui: ui, + } +} + +type testGitHubProviderClient struct { + ui *ui.UI + rt http.RoundTripper + provider auth.Provider +} + +func (g *testGitHubProviderClient) RoundTrip(req *http.Request) (*http.Response, error) { + // Check if this is a GitHub request or if it's already been rewritten to our mock server + isGitHubRequest := req.URL.Host == "github.com" || req.URL.Host == "api.github.com" + isMockServerRequest := strings.Contains(req.URL.String(), os.Getenv("HERMIT_GITHUB_BASE_URL")) + + if !isGitHubRequest && !isMockServerRequest { + return g.rt.RoundTrip(req) + } + + baseURL := os.Getenv("HERMIT_GITHUB_BASE_URL") + if baseURL == "" { + return nil, fmt.Errorf("HERMIT_GITHUB_BASE_URL must be set when making GitHub requests with localgithub build tag") + } + + g.ui.Tracef("Using mock github server URL: %s", baseURL) + req = req.Clone(req.Context()) + + mockURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid HERMIT_GITHUB_BASE_URL: %v", err) + } + + // Rewrite GitHub URLs to use the mock server if not already using it + if !isMockServerRequest { + req.URL.Scheme = mockURL.Scheme + req.URL.Host = mockURL.Host + } + + // Only fetch the token when needed + if g.provider != nil { + token, err := g.provider.GetToken() + if err == nil && token != "" { + req.Header.Set("Authorization", "token "+token) + } + } + + resp, err := g.rt.RoundTrip(req) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/github/mock_server.go b/github/mock_server.go new file mode 100644 index 000000000..649b0a645 --- /dev/null +++ b/github/mock_server.go @@ -0,0 +1,251 @@ +package github + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cashapp/hermit/errors" +) + +// MockRelease represents a GitHub release to be served by the mock server +type MockRelease struct { + Repo string // owner/repo format + TagName string // tag name for the release + Name string // name of the release asset + Files []string // paths to files to include in the release +} + +// toInternalRelease converts a MockRelease to the internal representation +func (mr *MockRelease) toInternalRelease() (*Release, map[string][]byte, error) { + files := make(map[string][]byte) + for _, file := range mr.Files { + // Create a tar.gz archive containing the file + archiveContent, err := createArchiveFromFile(file) + if err != nil { + return nil, nil, errors.Wrap(err, "creating archive") + } + files[mr.Name] = archiveContent + } + + release := &Release{ + TagName: mr.TagName, + Assets: []Asset{ + { + Name: mr.Name, + }, + }, + } + + return release, files, nil +} + +type MockGitHubServer struct { + *httptest.Server + releases map[string]map[string]*Release // map[repo][tag]release + assets map[string][]byte // map[assetName]content + config mockServerConfig +} + +type mockServerConfig struct { + requiredAuthorizationToken string + releases []MockRelease +} + +// MockServerOption is a function that configures the mock server +type MockServerOption func(*mockServerConfig) + +// WithRequiredToken configures the mock server to require a specific authorization token +func WithRequiredToken(token string) MockServerOption { + return func(c *mockServerConfig) { + c.requiredAuthorizationToken = token + } +} + +// WithMockRelease adds a mock release to be served by the server +func WithMockRelease(release MockRelease) MockServerOption { + return func(c *mockServerConfig) { + c.releases = append(c.releases, release) + } +} + +func NewMockGitHubServer(t *testing.T, opts ...MockServerOption) *MockGitHubServer { + t.Helper() + m := &MockGitHubServer{ + releases: make(map[string]map[string]*Release), + assets: make(map[string][]byte), + } + + // Apply options + for _, opt := range opts { + opt(&m.config) + } + + m.Server = httptest.NewServer(http.HandlerFunc(m.ServeHTTP)) + + // Add all provided releases + for _, mr := range m.config.releases { + if _, ok := m.releases[mr.Repo]; !ok { + m.releases[mr.Repo] = make(map[string]*Release) + } + + release, files, err := mr.toInternalRelease() + if err != nil { + panic(fmt.Sprintf("failed to create release: %v", err)) + } + + m.releases[mr.Repo][mr.TagName] = release + + // Store all files as assets + for name, content := range files { + m.assets[name] = content + } + } + + return m +} + +// createArchiveFromFile creates a tar.gz archive containing a single file from the test fixture +func createArchiveFromFile(scriptPath string) ([]byte, error) { + content, err := os.ReadFile(scriptPath) + if err != nil { + return nil, errors.Wrap(err, "reading script file") + } + + // Create a tar.gz archive containing the script + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Add the script to the archive with the base filename + hdr := &tar.Header{ + Name: filepath.Base(scriptPath), + Mode: 0755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + return nil, errors.Wrap(err, "writing tar header") + } + if _, err := tw.Write(content); err != nil { + return nil, errors.Wrap(err, "writing file content") + } + + // Close the archive + if err := tw.Close(); err != nil { + return nil, errors.Wrap(err, "closing tar writer") + } + if err := gw.Close(); err != nil { + return nil, errors.Wrap(err, "closing gzip writer") + } + + return buf.Bytes(), nil +} + +func (m *MockGitHubServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Verify authorization if required + if m.config.requiredAuthorizationToken != "" { + authHeader := r.Header.Get("Authorization") + expectedHeader := "token " + m.config.requiredAuthorizationToken + if authHeader != expectedHeader { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, "Unauthorized: expected token %q, got %q", + m.config.requiredAuthorizationToken, strings.TrimPrefix(authHeader, "token ")) + return + } + } + + // Handle GitHub releases/tags API endpoint + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/repos/") { + if strings.Contains(r.URL.Path, "/releases/tags/") { + m.handleReleaseTags(w, r) + return + } else if strings.Contains(r.URL.Path, "/releases/download/") { + m.handleReleaseDownload(w, r) + return + } + } + + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Not implemented") +} + +func (m *MockGitHubServer) handleReleaseTags(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 6 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Invalid path format") + return + } + + // Extract repo and tag from path + // Format: /repos/{owner}/{repo}/releases/tags/{tag} + repo := fmt.Sprintf("%s/%s", parts[2], parts[3]) + tag := parts[len(parts)-1] + + repoReleases, ok := m.releases[repo] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Repository not found") + return + } + + release, ok := repoReleases[tag] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Release not found") + return + } + + // Ensure asset URLs point to our mock server + for i := range release.Assets { + release.Assets[i].URL = fmt.Sprintf("%s/repos/%s/releases/download/%s/%s", + m.URL(), repo, release.TagName, release.Assets[i].Name) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(release); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (m *MockGitHubServer) handleReleaseDownload(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 7 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Invalid download path format") + return + } + + // Extract asset name from path + // Format: /repos/{owner}/{repo}/releases/download/{tag}/{asset} + assetName := parts[len(parts)-1] + + content, ok := m.assets[assetName] + if !ok { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Asset not found") + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(content); err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) + return + } +} + +// URL returns the base URL of the mock server +func (m *MockGitHubServer) URL() string { + return m.Server.URL +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 6b92aefe6..7ab7b231d 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -27,6 +27,7 @@ import ( "github.com/alecthomas/assert/v2" "github.com/cashapp/hermit/envars" "github.com/cashapp/hermit/errors" + "github.com/cashapp/hermit/github" "github.com/creack/pty" ) @@ -507,6 +508,108 @@ EOF assert test -x ../hermit-bundle-test/bin/testbin1 `, }, + {name: "GitHubTokenAuthWithGHCli", + preparations: prep{ + fixture("github_token_auth"), + activateWithMocks("."), + mockGitHub( + github.WithRequiredToken("mock-gh-token"), + github.WithMockRelease(github.MockRelease{ + Repo: "cashapp/hermit-packages-private", + TagName: "v1.0.0", + Name: "cashapp-private-pkg-1.0.0.tar.gz", + Files: []string{"testdata/github_token_auth/packages/cashapp-private-pkg.sh"}, + }), + ), + }, + script: ` + printf "gh-cli-auth = true\n" > "$HERMIT_USER_CONFIG" + + # Install a package that requires GitHub token auth + hermit install cashapp-private-pkg + + # Verify gh CLI was called to get token for private package + assert test -f gh-calls + assert grep -q "gh auth token" gh-calls + + # Verify package was installed and works + assert test "$(cashapp-private-pkg.sh)" = "cashapp-private-pkg 1.0.0" + `, + expectations: exp{ + filesExist("bin/cashapp-private-pkg.sh"), + }}, + {name: "GitHubTokenAuthWithEnv", + preparations: prep{ + fixture("github_token_auth"), + activate("."), + mockGitHub( + github.WithRequiredToken("env-token"), + github.WithMockRelease(github.MockRelease{ + Repo: "cashapp/hermit-packages-private", + TagName: "v1.0.0", + Name: "cashapp-private-pkg-1.0.0.tar.gz", + Files: []string{"testdata/github_token_auth/packages/cashapp-private-pkg.sh"}, + }), + ), + }, + script: ` + # Set environment token + export HERMIT_GITHUB_TOKEN="env-token" + + # Install a package that requires GitHub token auth + hermit install cashapp-private-pkg + + # Verify package was installed and works + assert test "$(cashapp-private-pkg.sh)" = "cashapp-private-pkg 1.0.0" + `, + expectations: exp{ + filesExist("bin/cashapp-private-pkg.sh"), + }}, + {name: "GitHubTokenAuthWithGHCliFails", + preparations: prep{ + fixture("github_token_auth"), + activateWithMocks("."), + mockGitHub( + github.WithRequiredToken("mock-gh-token"), + github.WithMockRelease(github.MockRelease{ + Repo: "cashapp/hermit-packages-private", + TagName: "v1.0.0", + Name: "cashapp-private-pkg-1.0.0.tar.gz", + Files: []string{"testdata/github_token_auth/packages/cashapp-private-pkg.sh"}, + }), + ), + }, + script: ` + # Make sure the gh mock is executable + chmod +x mocks/gh + + # Configure to use gh CLI auth + printf "gh-cli-auth = true\n" > "$HERMIT_USER_CONFIG" + + # Set the environment variable to make gh auth fail + export GH_AUTH_FAIL=1 + + # Install should fail because gh cli auth fails and no env var is set + hermit install cashapp-private-pkg || true + + # Verify gh CLI was called and failed + assert test -f gh-failures + assert grep -q "gh auth token failed" gh-failures + + # Now unset the failure flag and set environment token as fallback + unset GH_AUTH_FAIL + export HERMIT_GITHUB_TOKEN="mock-gh-token" + + # Install should succeed with env var + hermit install cashapp-private-pkg + + # Verify package was installed and works + assert test "$(cashapp-private-pkg.sh)" = "cashapp-private-pkg 1.0.0" + `, + expectations: exp{ + filesExist("bin/cashapp-private-pkg.sh"), + outputContains("gh auth failed: no oauth token found for github.com"), + }}, } checkForShells(t) @@ -639,11 +742,15 @@ func buildAndInjectHermit(t *testing.T, environ []string) (outenviron []string) err := os.Mkdir(hermitExeDir, 0700) assert.NoError(t, err) t.Logf("Compiling Hermit to %s", hermitExe) - output, err := exec.Command("go", "build", "-o", hermitExe, "github.com/cashapp/hermit/cmd/hermit").CombinedOutput() + output, err := exec.Command("go", "build", "-tags", "localgithub", "-o", hermitExe, "github.com/cashapp/hermit/cmd/hermit").CombinedOutput() assert.NoError(t, err, "%s", output) - outenviron = make([]string, len(environ), len(environ)+1) + outenviron = make([]string, len(environ), len(environ)+2) copy(outenviron, environ) outenviron = append(outenviron, "HERMIT_EXE="+hermitExe) + // Ensure HERMIT_GITHUB_BASE_URL is set for localgithub tests + if os.Getenv("HERMIT_GITHUB_BASE_URL") != "" { + outenviron = append(outenviron, "HERMIT_GITHUB_BASE_URL="+os.Getenv("HERMIT_GITHUB_BASE_URL")) + } for i, env := range outenviron { if strings.HasPrefix(env, "PATH=") { outenviron[i] = "PATH=" + hermitExeDir + ":" + env[len("PATH="):] @@ -722,6 +829,21 @@ func activate(relDest string) preparation { } } +// Activate the Hermit environment and add mocks to PATH +func activateWithMocks(relDest string) preparation { + return func(t *testing.T, dir string) string { + // Add mocks directory to PATH if it exists + mocksPath := filepath.Join(dir, relDest, "mocks") + if _, err := os.Stat(mocksPath); err == nil { + return fmt.Sprintf(` +export PATH="%s:$PATH" +. %s/bin/activate-hermit +`, mocksPath, relDest) + } + return fmt.Sprintf(". %s/bin/activate-hermit", relDest) + } +} + // Copy the specified environment fixture into the test root and activate it. func activatedFixtureEnv(env string) preparation { return func(t *testing.T, dir string) string { @@ -773,6 +895,15 @@ func fixtureToDir(relSource string, relDest string) preparation { } } +// mockGitHub creates a preparation that sets up a mock GitHub server with the given options +func mockGitHub(opts ...github.MockServerOption) preparation { + return func(t *testing.T, dir string) string { + mock := github.NewMockGitHubServer(t, opts...) + t.Cleanup(func() { mock.Server.Close() }) + return fmt.Sprintf("export HERMIT_GITHUB_BASE_URL=%s", mock.Server.URL) + } +} + // An expectation that must be met after running a test. type ( expectation func(t *testing.T, dir, stdout string) diff --git a/integration/testdata/github_token_auth/bin/README.hermit.md b/integration/testdata/github_token_auth/bin/README.hermit.md new file mode 100644 index 000000000..e889550ba --- /dev/null +++ b/integration/testdata/github_token_auth/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/integration/testdata/github_token_auth/bin/activate-hermit b/integration/testdata/github_token_auth/bin/activate-hermit new file mode 100755 index 000000000..3b191fb4e --- /dev/null +++ b/integration/testdata/github_token_auth/bin/activate-hermit @@ -0,0 +1,19 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/integration/testdata/github_token_auth/bin/hermit b/integration/testdata/github_token_auth/bin/hermit new file mode 100755 index 000000000..3828ba088 --- /dev/null +++ b/integration/testdata/github_token_auth/bin/hermit @@ -0,0 +1,41 @@ +#!/bin/bash + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/integration/testdata/github_token_auth/bin/hermit.hcl b/integration/testdata/github_token_auth/bin/hermit.hcl new file mode 100644 index 000000000..f037235a1 --- /dev/null +++ b/integration/testdata/github_token_auth/bin/hermit.hcl @@ -0,0 +1,5 @@ +sources = ["env:///packages"] + +github-token-auth { + match = ["cashapp/hermit-packages-private"] +} diff --git a/integration/testdata/github_token_auth/mocks/gh b/integration/testdata/github_token_auth/mocks/gh new file mode 100755 index 000000000..e91fc302a --- /dev/null +++ b/integration/testdata/github_token_auth/mocks/gh @@ -0,0 +1,19 @@ +#!/bin/sh + +if [ "$1" = "auth" ] && [ "$2" = "token" ]; then + echo "gh $*" >> gh-calls + + # Check if we should simulate a failure + if [ -n "$GH_AUTH_FAIL" ]; then + echo "no oauth token found for github.com" >&2 + echo "gh auth token failed (simulated)" >> gh-failures + exit 1 + fi + + echo "mock-gh-token" + exit 0 +fi + +# Log all other invocations as errors +echo "ERROR: gh $* (unexpected command)" >> gh-calls +exit 1 diff --git a/integration/testdata/github_token_auth/packages/cashapp-private-pkg.hcl b/integration/testdata/github_token_auth/packages/cashapp-private-pkg.hcl new file mode 100644 index 000000000..f0179e9a8 --- /dev/null +++ b/integration/testdata/github_token_auth/packages/cashapp-private-pkg.hcl @@ -0,0 +1,4 @@ +description = "Test private package requiring GitHub token" +source = "https://github.com/cashapp/hermit-packages-private/releases/download/v${version}/cashapp-private-pkg-${version}.tar.gz" +binaries = ["cashapp-private-pkg.sh"] +version "1.0.0" {} diff --git a/integration/testdata/github_token_auth/packages/cashapp-private-pkg.sh b/integration/testdata/github_token_auth/packages/cashapp-private-pkg.sh new file mode 100755 index 000000000..3ee6975e0 --- /dev/null +++ b/integration/testdata/github_token_auth/packages/cashapp-private-pkg.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "cashapp-private-pkg 1.0.0" diff --git a/manifest/infer_test.go b/manifest/infer_test.go index a5527a73d..51285c587 100644 --- a/manifest/infer_test.go +++ b/manifest/infer_test.go @@ -31,7 +31,7 @@ func TestInfer(t *testing.T) { p, cache.GetSource, http.DefaultClient, - github.New(nil, ""), + github.New(p, nil, nil), srv.URL+"/releases/download/0.1.1/pkg-0.1.1-linux-amd64.tgz", "", )