From c55b81e10c5125c5662fb2e4c4cee7ae6bb9fd86 Mon Sep 17 00:00:00 2001 From: Clark Tomlinson Date: Fri, 20 Dec 2024 15:25:40 -0500 Subject: [PATCH] Add support for retreiving registry credentials from netrc files Signed-off-by: Clark Tomlinson --- pkg/netrc/netrc.go | 157 ++++++++++++++++++++++++++++++ pkg/netrc/netrc_test.go | 143 +++++++++++++++++++++++++++ pkg/registry/client.go | 37 +++++-- pkg/registry/client_netrc_test.go | 79 +++++++++++++++ 4 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 pkg/netrc/netrc.go create mode 100644 pkg/netrc/netrc_test.go create mode 100644 pkg/registry/client_netrc_test.go diff --git a/pkg/netrc/netrc.go b/pkg/netrc/netrc.go new file mode 100644 index 000000000..fe620aa73 --- /dev/null +++ b/pkg/netrc/netrc.go @@ -0,0 +1,157 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package netrc + +import ( + "net/url" + "os" + "path/filepath" + "strings" +) + +// DefaultPath returns the default path to the .netrc file +func DefaultPath() string { + if os.Getenv("NETRC") != "" { + return os.Getenv("NETRC") + } + return filepath.Join(os.Getenv("HOME"), ".netrc") +} + +// Credentials represents a machine entry in .netrc file +type Credentials struct { + Machine string + Login string + Password string +} + +// GetCredentials returns the credentials for the given URL from .netrc file +func GetCredentials(urlStr string) (*Credentials, error) { + netrcPath := DefaultPath() + if _, err := os.Stat(netrcPath); os.IsNotExist(err) { + return nil, nil + } + + data, err := os.ReadFile(netrcPath) + if err != nil { + return nil, err + } + + u, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + + host := u.Host + if strings.Contains(host, ":") { + host = strings.Split(host, ":")[0] + } + + parser := newParser(string(data)) + machines, err := parser.parse() + if err != nil { + return nil, err + } + + for _, m := range machines { + if m.Machine == host { + return &Credentials{ + Machine: m.Machine, + Login: m.Login, + Password: m.Password, + }, nil + } + } + + return nil, nil +} + +type machine struct { + Machine string + Login string + Password string +} + +type parser struct { + input string + pos int +} + +func newParser(input string) *parser { + return &parser{input: input} +} + +func (p *parser) parse() ([]machine, error) { + var machines []machine + var current machine + + for p.pos < len(p.input) { + token := p.nextToken() + if token == "" { + break + } + + switch token { + case "machine": + if current.Machine != "" { + machines = append(machines, current) + current = machine{} + } + current.Machine = p.nextToken() + case "login": + current.Login = p.nextToken() + case "password": + current.Password = p.nextToken() + } + } + + if current.Machine != "" { + machines = append(machines, current) + } + + return machines, nil +} + +func (p *parser) nextToken() string { + // Skip whitespace + for p.pos < len(p.input) && (p.input[p.pos] == ' ' || p.input[p.pos] == '\t' || p.input[p.pos] == '\n' || p.input[p.pos] == '\r') { + p.pos++ + } + + if p.pos >= len(p.input) { + return "" + } + + start := p.pos + if p.input[p.pos] == '"' { + p.pos++ + start = p.pos + for p.pos < len(p.input) && p.input[p.pos] != '"' { + p.pos++ + } + if p.pos < len(p.input) { + token := p.input[start:p.pos] + p.pos++ + return token + } + } else { + for p.pos < len(p.input) && p.input[p.pos] != ' ' && p.input[p.pos] != '\t' && p.input[p.pos] != '\n' && p.input[p.pos] != '\r' { + p.pos++ + } + } + + return p.input[start:p.pos] +} diff --git a/pkg/netrc/netrc_test.go b/pkg/netrc/netrc_test.go new file mode 100644 index 000000000..977d3f762 --- /dev/null +++ b/pkg/netrc/netrc_test.go @@ -0,0 +1,143 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package netrc + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetCredentials(t *testing.T) { + // Create a temporary .netrc file + content := `machine example.com + login testuser + password testpass +machine other.com + login otheruser + password otherpass` + + tmpDir := t.TempDir() + netrcPath := filepath.Join(tmpDir, ".netrc") + if err := os.WriteFile(netrcPath, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + // Set NETRC env var to point to our test file + oldNetrc := os.Getenv("NETRC") + defer os.Setenv("NETRC", oldNetrc) + os.Setenv("NETRC", netrcPath) + + tests := []struct { + name string + url string + wantUser string + wantPass string + wantErr bool + }{ + { + name: "basic URL", + url: "https://example.com/repo", + wantUser: "testuser", + wantPass: "testpass", + }, + { + name: "URL with port", + url: "https://example.com:443/repo", + wantUser: "testuser", + wantPass: "testpass", + }, + { + name: "other domain", + url: "https://other.com/repo", + wantUser: "otheruser", + wantPass: "otherpass", + }, + { + name: "no match", + url: "https://nomatch.com/repo", + wantUser: "", + wantPass: "", + }, + { + name: "invalid URL", + url: "://invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetCredentials(tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("GetCredentials() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if tt.wantUser == "" && tt.wantPass == "" { + if got != nil { + t.Errorf("GetCredentials() = %v, want nil", got) + } + return + } + if got == nil { + t.Fatal("GetCredentials() = nil, want credentials") + } + if got.Login != tt.wantUser { + t.Errorf("GetCredentials() username = %v, want %v", got.Login, tt.wantUser) + } + if got.Password != tt.wantPass { + t.Errorf("GetCredentials() password = %v, want %v", got.Password, tt.wantPass) + } + }) + } +} + +func TestParseNetrc(t *testing.T) { + content := `# comment line +machine example.com + login user1 + password pass1 +machine other.com login user2 password pass2 +machine "quoted.com" + login "user 3" + password "pass 3" +` + p := newParser(content) + machines, err := p.parse() + if err != nil { + t.Fatal(err) + } + + if len(machines) != 3 { + t.Errorf("Expected 3 machines, got %d", len(machines)) + } + + expected := []machine{ + {Machine: "example.com", Login: "user1", Password: "pass1"}, + {Machine: "other.com", Login: "user2", Password: "pass2"}, + {Machine: "quoted.com", Login: "user 3", Password: "pass 3"}, + } + + for i, e := range expected { + if machines[i] != e { + t.Errorf("machine[%d] = %v, want %v", i, machines[i], e) + } + } +} diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 6a943d27d..6c441a187 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -41,6 +41,7 @@ import ( "helm.sh/helm/v3/internal/version" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/netrc" ) // See https://github.com/helm/helm/issues/10166 @@ -117,9 +118,20 @@ func NewClient(options ...ClientOption) (*Client, error) { } // if username and password are set, use them for authentication - // by adding the basic auth Authorization header to the resolver - if client.username != "" && client.password != "" { - concat := client.username + ":" + client.password + username := client.username + password := client.password + + // If credentials are not explicitly set, try to get them from .netrc + if username == "" && password == "" { + if creds, err := netrc.GetCredentials(ref.Registry); err == nil && creds != nil { + username = creds.Login + password = creds.Password + } + } + + // Add credentials to resolver if available + if username != "" && password != "" { + concat := username + ":" + password encodedAuth := base64.StdEncoding.EncodeToString([]byte(concat)) opts = append(opts, auth.WithResolverHeaders( http.Header{ @@ -148,10 +160,21 @@ func NewClient(options ...ClientOption) (*Client, error) { }, Cache: cache, Credential: func(_ context.Context, reg string) (registryauth.Credential, error) { - if client.username != "" && client.password != "" { + username := client.username + password := client.password + + // If credentials are not explicitly set, try to get them from .netrc + if username == "" && password == "" { + if creds, err := netrc.GetCredentials(reg); err == nil && creds != nil { + username = creds.Login + password = creds.Password + } + } + + if username != "" && password != "" { return registryauth.Credential{ - Username: client.username, - Password: client.password, + Username: username, + Password: password, }, nil } @@ -176,10 +199,8 @@ func NewClient(options ...ClientOption) (*Client, error) { Username: username, Password: password, }, nil - }, } - } return client, nil } diff --git a/pkg/registry/client_netrc_test.go b/pkg/registry/client_netrc_test.go new file mode 100644 index 000000000..88c94fc29 --- /dev/null +++ b/pkg/registry/client_netrc_test.go @@ -0,0 +1,79 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/netrc" +) + +func TestClientNetrcAuth(t *testing.T) { + // Create a temporary .netrc file + content := `machine example.com + login testuser + password testpass` + + tmpDir := t.TempDir() + netrcPath := filepath.Join(tmpDir, ".netrc") + if err := os.WriteFile(netrcPath, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + // Set NETRC env var to point to our test file + oldNetrc := os.Getenv("NETRC") + defer os.Setenv("NETRC", oldNetrc) + os.Setenv("NETRC", netrcPath) + + // Create a new client + client, err := NewClient() + if err != nil { + t.Fatal(err) + } + + // Test that credentials from .netrc are used + creds, err := netrc.GetCredentials("https://example.com") + if err != nil { + t.Fatal(err) + } + if creds == nil { + t.Fatal("Expected credentials from .netrc, got nil") + } + if creds.Login != "testuser" { + t.Errorf("Expected username 'testuser', got '%s'", creds.Login) + } + if creds.Password != "testpass" { + t.Errorf("Expected password 'testpass', got '%s'", creds.Password) + } + + // Test that explicit credentials override .netrc + client, err = NewClient( + ClientOptBasicAuth("explicituser", "explicitpass"), + ) + if err != nil { + t.Fatal(err) + } + + if client.username != "explicituser" { + t.Errorf("Expected username 'explicituser', got '%s'", client.username) + } + if client.password != "explicitpass" { + t.Errorf("Expected password 'explicitpass', got '%s'", client.password) + } +}