diff --git a/clients/azuredevops/repo.go b/clients/azuredevops/repo.go new file mode 100644 index 000000000000..c9c7b931e0a7 --- /dev/null +++ b/clients/azuredevops/repo.go @@ -0,0 +1,124 @@ +// Copyright 2024 OpenSSF Scorecard 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 azuredevops + +import ( + "fmt" + "net/url" + "strings" + + "github.com/ossf/scorecard/v5/clients" + sce "github.com/ossf/scorecard/v5/errors" +) + +type Repo struct { + scheme string + host string + organization string + project string + name string + metadata []string +} + +// Parses input string into repoURL struct +/* + Accepted input string formats are as follows: + - "dev.azure.com///_git/" + - "https://dev.azure.com///_git/" +*/ +func (r *Repo) parse(input string) error { + var t string + c := strings.Split(input, "/") + if l := len(c); l >= 3 { + t = input + } else { + return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Azure DevOps repo format is invalid: %s", input)) + } + + u, err := url.Parse(withDefaultScheme(t)) + if err != nil { + return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err)) + } + + const splitLen = 4 + split := strings.SplitN(strings.Trim(u.Path, "/"), "/", splitLen) + if len(split) != splitLen { + return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Azure DevOps repo format is invalid: %s", input)) + } + + r.scheme, r.host, r.organization, r.project, r.name = u.Scheme, u.Host, split[0], split[1], split[3] + return nil +} + +// Allow skipping scheme for ease-of-use, default to https. +func withDefaultScheme(uri string) string { + if strings.Contains(uri, "://") { + return uri + } + return "https://" + uri +} + +// URI implements Repo.URI(). +func (r *Repo) URI() string { + return fmt.Sprintf("%s/%s/%s/%s/%s", r.host, r.organization, r.project, "_git", r.name) +} + +func (r *Repo) Host() string { + return r.host +} + +// String implements Repo.String. +func (r *Repo) String() string { + return fmt.Sprintf("%s-%s_%s_%s", r.host, r.organization, r.project, r.name) +} + +// IsValid checks if the repoURL is valid. +func (r *Repo) IsValid() error { + if strings.TrimSpace(r.organization) == "" || + strings.TrimSpace(r.project) == "" || + strings.TrimSpace(r.name) == "" { + return sce.WithMessage(sce.ErrInvalidURL, "expected full project url: "+r.URI()) + } + + return nil +} + +func (r *Repo) AppendMetadata(metadata ...string) { + r.metadata = append(r.metadata, metadata...) +} + +// Metadata implements Repo.Metadata. +func (r *Repo) Metadata() []string { + return r.metadata +} + +// Path() implements RepoClient.Path. +func (r *Repo) Path() string { + return fmt.Sprintf("%s/%s/%s/%s", r.organization, r.project, "_git", r.name) +} + +// MakeAzureDevOpsRepo takes input of forms in parse and returns and implementation +// of clients.Repo interface. +func MakeAzureDevOpsRepo(input string) (clients.Repo, error) { + var repo Repo + if err := repo.parse(input); err != nil { + return nil, fmt.Errorf("error during parse: %w", err) + } + if err := repo.IsValid(); err != nil { + return nil, fmt.Errorf("error in IsValid: %w", err) + } + + return &repo, nil +} diff --git a/clients/azuredevops/repo_test.go b/clients/azuredevops/repo_test.go new file mode 100644 index 000000000000..18b1de01ff2d --- /dev/null +++ b/clients/azuredevops/repo_test.go @@ -0,0 +1,173 @@ +// Copyright 2024 OpenSSF Scorecard 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 azuredevops + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestRepo_parse(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputURL string + expected Repo + wantErr bool + flagRequired bool + }{ + { + name: "valid azuredevops project with scheme", + expected: Repo{ + host: "dev.azure.com", + organization: "dnceng-public", + project: "public", + name: "public", + }, + inputURL: "https://dev.azure.com/dnceng-public/public/_git/public", + wantErr: false, + }, + { + name: "valid azuredevops project without scheme", + expected: Repo{ + host: "dev.azure.com", + organization: "dnceng-public", + project: "public", + name: "public", + }, + inputURL: "dev.azure.com/dnceng-public/public/_git/public", + wantErr: false, + }, + { + name: "invalid azuredevops project missing repo", + expected: Repo{}, + inputURL: "https://dev.azure.com/dnceng-public/public", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := Repo{} + if err := r.parse(tt.inputURL); (err != nil) != tt.wantErr { + t.Errorf("repoURL.parse() error = %v", err) + } + if tt.wantErr { + return + } + t.Log(r.URI()) + if !tt.wantErr && !cmp.Equal(tt.expected, r, cmpopts.IgnoreUnexported(Repo{})) { + t.Logf("expected: %s GOT: %s", tt.expected.host, r.host) + t.Logf("expected: %s GOT: %s", tt.expected.organization, r.organization) + t.Logf("expected: %s GOT: %s", tt.expected.project, r.project) + t.Logf("expected: %s GOT: %s", tt.expected.name, r.name) + t.Errorf("Got diff: %s", cmp.Diff(tt.expected, r)) + } + if !cmp.Equal(r.Host(), tt.expected.host) { + t.Errorf("%s expected host: %s got host %s", tt.inputURL, tt.expected.host, r.Host()) + } + }) + } +} + +func TestRepo_IsValid(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputURL string + repo Repo + wantErr bool + flagRequired bool + }{ + { + name: "valid azuredevops project", + repo: Repo{ + host: "dev.azure.com", + organization: "dnceng-public", + project: "public", + name: "public", + }, + wantErr: false, + }, + { + name: "invalid azuredevops project", + repo: Repo{ + host: "dev.azure.com", + organization: "dnceng-public", + project: "public", + name: "", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.repo.IsValid(); (err != nil) != tt.wantErr { + t.Errorf("repoURL.IsValid() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + }) + } +} + +func TestRepo_MakeAzureDevOpsRepo(t *testing.T) { + t.Parallel() + tests := []struct { + repouri string + expected bool + flagRequired bool + }{ + { + repouri: "github.com/ossf/scorecard", + expected: false, + }, + { + repouri: "ossf/scorecard", + expected: false, + }, + { + repouri: "https://github.com/ossf/scorecard", + expected: false, + }, + { + repouri: "https://dev.azure.com/dnceng-public/public/_git/public", + expected: true, + }, + { + repouri: "dev.azure.com/dnceng-public/public/_git/public", + expected: true, + }, + } + + for _, tt := range tests { + g, err := MakeAzureDevOpsRepo(tt.repouri) + if (g != nil) != (err == nil) { + t.Errorf("got azuredevopsrepo: %s with err %s", g, err) + } + isAzureDevOps := g != nil && err == nil + if isAzureDevOps != tt.expected { + t.Errorf("got %s isazuredevops: %t expected %t", tt.repouri, isAzureDevOps, tt.expected) + } + } +}