diff --git a/cmd/harbor/root/login.go b/cmd/harbor/root/login.go index 5b9906187..fd8b94abd 100644 --- a/cmd/harbor/root/login.go +++ b/cmd/harbor/root/login.go @@ -16,7 +16,6 @@ package root import ( "context" "fmt" - "os" "strings" "github.com/goharbor/go-client/pkg/harbor" @@ -28,7 +27,6 @@ import ( "github.com/goharbor/harbor-cli/pkg/views/login" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "golang.org/x/term" ) var ( @@ -53,13 +51,11 @@ func LoginCommand() *cobra.Command { } if passwordStdin { - fmt.Print("Password: ") - passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) // #nosec G115 - fd fits in int on all supported platforms + password, err := utils.GetSecretStdin("Password: ") if err != nil { return fmt.Errorf("failed to read password from stdin: %v", err) } - fmt.Println() - Password = string(passwordBytes) + Password = password } loginView := login.LoginView{ diff --git a/cmd/harbor/root/quota/list.go b/cmd/harbor/root/quota/list.go index 45fc2e9b7..3bcf5326f 100644 --- a/cmd/harbor/root/quota/list.go +++ b/cmd/harbor/root/quota/list.go @@ -42,19 +42,19 @@ func ListQuotaCommand() *cobra.Command { return fmt.Errorf("page size should be less than or equal to 100") } - quota, err := api.ListQuota(opts) + quotas, err := api.GetAllQuotas(api.ListQuota, opts) if err != nil { return fmt.Errorf("failed to get quota list: %v", err) } FormatFlag := viper.GetString("output-format") if FormatFlag != "" { - err = utils.PrintFormat(quota, FormatFlag) + err = utils.PrintFormat(quotas, FormatFlag) if err != nil { return fmt.Errorf("failed to get quota list: %v", err) } } else { - list.ListQuotas(quota.Payload) + list.ListQuotas(quotas) } return nil }, diff --git a/cmd/harbor/root/replication/executions/list.go b/cmd/harbor/root/replication/executions/list.go index 067ce3d41..d3c1d36b0 100644 --- a/cmd/harbor/root/replication/executions/list.go +++ b/cmd/harbor/root/replication/executions/list.go @@ -61,13 +61,13 @@ func ListCommand() *cobra.Command { } log.Debug("Fetching executions...") - executions, err := api.ListReplicationExecutions(rpolicyID, opts) + executions, err := api.GetAllReplicationExecutions(rpolicyID, api.ListReplicationExecutions, opts) if err != nil { return fmt.Errorf("failed to get projects list: %v", utils.ParseHarborErrorMsg(err)) } - log.WithField("count", len(executions.Payload)).Debug("Number of executions fetched") - if len(executions.Payload) == 0 { + log.WithField("count", len(executions)).Debug("Number of executions fetched") + if len(executions) == 0 { fmt.Println("No executions found") return nil } @@ -75,13 +75,13 @@ func ListCommand() *cobra.Command { formatFlag := viper.GetString("output-format") if formatFlag != "" { log.WithField("output_format", formatFlag).Debug("Output format selected") - err = utils.PrintFormat(executions.Payload, formatFlag) + err = utils.PrintFormat(executions, formatFlag) if err != nil { return err } } else { log.Debug("Listing projects using default view") - list.ListExecutions(executions.Payload) + list.ListExecutions(executions) } return nil }, diff --git a/cmd/harbor/root/replication/policies/list.go b/cmd/harbor/root/replication/policies/list.go index 33c775932..222c65d64 100644 --- a/cmd/harbor/root/replication/policies/list.go +++ b/cmd/harbor/root/replication/policies/list.go @@ -45,13 +45,13 @@ func ListCommand() *cobra.Command { } log.Debug("Fetching policies...") - allPolicies, err := api.ListReplicationPolicies(opts) + allPolicies, err := api.GetAllReplicationPolicies(api.ListReplicationPolicies, opts) if err != nil { return fmt.Errorf("failed to get projects list: %v", utils.ParseHarborErrorMsg(err)) } - log.WithField("count", len(allPolicies.Payload)).Debug("Number of policies fetched") - if len(allPolicies.Payload) == 0 { + log.WithField("count", len(allPolicies)).Debug("Number of policies fetched") + if len(allPolicies) == 0 { fmt.Println("No policies found") return nil } @@ -59,13 +59,13 @@ func ListCommand() *cobra.Command { formatFlag := viper.GetString("output-format") if formatFlag != "" { log.WithField("output_format", formatFlag).Debug("Output format selected") - err = utils.PrintFormat(allPolicies.Payload, formatFlag) + err = utils.PrintFormat(allPolicies, formatFlag) if err != nil { return err } } else { log.Debug("Listing projects using default view") - list.ListPolicies(allPolicies.Payload) + list.ListPolicies(allPolicies) } return nil }, diff --git a/pkg/api/quota_handler_test.go b/pkg/api/quota_handler_test.go new file mode 100644 index 000000000..c40090215 --- /dev/null +++ b/pkg/api/quota_handler_test.go @@ -0,0 +1,54 @@ +// Copyright Project Harbor 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 api + +import ( + "testing" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/quota" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAllQuotasFetchesAllPages(t *testing.T) { + var pages []int64 + var pageSizes []int64 + + quotas, err := GetAllQuotas( + func(opts ListQuotaFlags) (*quota.ListQuotasOK, error) { + pages = append(pages, opts.Page) + pageSizes = append(pageSizes, opts.PageSize) + + if opts.Page == 1 { + return "a.ListQuotasOK{Payload: makeQuotas(100)}, nil + } + return "a.ListQuotasOK{Payload: makeQuotas(2)}, nil + }, + ListQuotaFlags{Page: 8, PageSize: 0}, + ) + + require.NoError(t, err) + assert.Len(t, quotas, 102) + assert.Equal(t, []int64{1, 2}, pages) + assert.Equal(t, []int64{100, 100}, pageSizes) +} + +func makeQuotas(count int) []*models.Quota { + quotas := make([]*models.Quota, count) + for i := range quotas { + quotas[i] = &models.Quota{ID: int64(i + 1)} + } + return quotas +} diff --git a/pkg/api/replication_handler.go b/pkg/api/replication_handler.go index 5245d6c6b..e096461dc 100644 --- a/pkg/api/replication_handler.go +++ b/pkg/api/replication_handler.go @@ -45,6 +45,40 @@ func ListReplicationPolicies(opts ...ListFlags) (*replication.ListReplicationPol return response, nil } +func GetAllReplicationPolicies( + listFunc func(...ListFlags) (*replication.ListReplicationPoliciesOK, error), + opts ListFlags, +) ([]*models.ReplicationPolicy, error) { + var allPolicies []*models.ReplicationPolicy + if opts.PageSize == 0 { + opts.PageSize = 100 + opts.Page = 1 + + for { + policies, err := listFunc(opts) + if err != nil { + return nil, err + } + + allPolicies = append(allPolicies, policies.Payload...) + + if len(policies.Payload) < int(opts.PageSize) { + break + } + + opts.Page++ + } + } else { + policies, err := listFunc(opts) + if err != nil { + return nil, err + } + allPolicies = policies.Payload + } + + return allPolicies, nil +} + func GetReplicationPolicy(policyID int64) (*replication.GetReplicationPolicyOK, error) { ctx, client, err := utils.ContextWithClient() if err != nil { @@ -166,6 +200,41 @@ func ListReplicationExecutions(policyID int64, opts ...ListFlags) (*replication. return response, nil } +func GetAllReplicationExecutions( + policyID int64, + listFunc func(int64, ...ListFlags) (*replication.ListReplicationExecutionsOK, error), + opts ListFlags, +) ([]*models.ReplicationExecution, error) { + var allExecutions []*models.ReplicationExecution + if opts.PageSize == 0 { + opts.PageSize = 100 + opts.Page = 1 + + for { + executions, err := listFunc(policyID, opts) + if err != nil { + return nil, err + } + + allExecutions = append(allExecutions, executions.Payload...) + + if len(executions.Payload) < int(opts.PageSize) { + break + } + + opts.Page++ + } + } else { + executions, err := listFunc(policyID, opts) + if err != nil { + return nil, err + } + allExecutions = executions.Payload + } + + return allExecutions, nil +} + func GetReplicationExecution(executionID int64) (*replication.GetReplicationExecutionOK, error) { ctx, client, err := utils.ContextWithClient() if err != nil { diff --git a/pkg/api/replication_handler_test.go b/pkg/api/replication_handler_test.go new file mode 100644 index 000000000..38f32b194 --- /dev/null +++ b/pkg/api/replication_handler_test.go @@ -0,0 +1,90 @@ +// Copyright Project Harbor 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 api + +import ( + "testing" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/replication" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAllReplicationPoliciesFetchesAllPages(t *testing.T) { + var pages []int64 + var pageSizes []int64 + + policies, err := GetAllReplicationPolicies( + func(opts ...ListFlags) (*replication.ListReplicationPoliciesOK, error) { + require.Len(t, opts, 1) + pages = append(pages, opts[0].Page) + pageSizes = append(pageSizes, opts[0].PageSize) + + if opts[0].Page == 1 { + return &replication.ListReplicationPoliciesOK{Payload: makeReplicationPolicies(100)}, nil + } + return &replication.ListReplicationPoliciesOK{Payload: makeReplicationPolicies(3)}, nil + }, + ListFlags{Page: 5, PageSize: 0}, + ) + + require.NoError(t, err) + assert.Len(t, policies, 103) + assert.Equal(t, []int64{1, 2}, pages) + assert.Equal(t, []int64{100, 100}, pageSizes) +} + +func TestGetAllReplicationExecutionsFetchesAllPages(t *testing.T) { + var pages []int64 + var pageSizes []int64 + const policyID int64 = 7 + + executions, err := GetAllReplicationExecutions( + policyID, + func(gotPolicyID int64, opts ...ListFlags) (*replication.ListReplicationExecutionsOK, error) { + require.Equal(t, policyID, gotPolicyID) + require.Len(t, opts, 1) + pages = append(pages, opts[0].Page) + pageSizes = append(pageSizes, opts[0].PageSize) + + if opts[0].Page == 1 { + return &replication.ListReplicationExecutionsOK{Payload: makeReplicationExecutions(100)}, nil + } + return &replication.ListReplicationExecutionsOK{Payload: makeReplicationExecutions(4)}, nil + }, + ListFlags{Page: 3, PageSize: 0}, + ) + + require.NoError(t, err) + assert.Len(t, executions, 104) + assert.Equal(t, []int64{1, 2}, pages) + assert.Equal(t, []int64{100, 100}, pageSizes) +} + +func makeReplicationPolicies(count int) []*models.ReplicationPolicy { + policies := make([]*models.ReplicationPolicy, count) + for i := range policies { + policies[i] = &models.ReplicationPolicy{ID: int64(i + 1)} + } + return policies +} + +func makeReplicationExecutions(count int) []*models.ReplicationExecution { + executions := make([]*models.ReplicationExecution, count) + for i := range executions { + executions[i] = &models.ReplicationExecution{ID: int64(i + 1)} + } + return executions +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 3ab996771..c84f11496 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -18,11 +18,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "regexp" "strconv" "strings" - "syscall" "github.com/charmbracelet/bubbles/table" "github.com/gocarina/gocsv" @@ -187,13 +187,25 @@ func SavePayloadJSON(filename string, payload any) { // Get Password as Stdin func GetSecretStdin(prompt string) (string, error) { - fmt.Print(prompt) - bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if term.IsTerminal(int(os.Stdin.Fd())) { // #nosec G115 - fd fits in int on all supported platforms + fmt.Print(prompt) + bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) // #nosec G115 - fd fits in int on all supported platforms + if err != nil { + return "", err + } + fmt.Println() // move to the next line after input + return trimSecretLineEnding(bytePassword), nil + } + + bytePassword, err := io.ReadAll(os.Stdin) if err != nil { return "", err } - fmt.Println() // move to the next line after input - return strings.TrimSpace(string(bytePassword)), nil + return trimSecretLineEnding(bytePassword), nil +} + +func trimSecretLineEnding(secret []byte) string { + return strings.TrimRight(string(secret), "\r\n") } func ToKebabCase(s string) string { diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index b255f47d1..a6a87d61b 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -15,11 +15,13 @@ package utils_test import ( "fmt" + "os" "testing" "github.com/goharbor/harbor-cli/pkg/utils" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_Sanitize_ServerAddress(t *testing.T) { @@ -145,3 +147,38 @@ func TestStorageStringToBytes(t *testing.T) { _, err := utils.StorageStringToBytes("1025TiB") assert.Error(t, err, "Expected error for input exceeding 1024TiB but got none") } + +func TestGetSecretStdinReadsPipedInput(t *testing.T) { + secret := getSecretFromPipe(t, "Abcd1234\n") + + assert.Equal(t, "Abcd1234", secret) +} + +func TestGetSecretStdinPreservesSecretWhitespace(t *testing.T) { + secret := getSecretFromPipe(t, " Abcd1234 \r\n") + + assert.Equal(t, " Abcd1234 ", secret) +} + +func getSecretFromPipe(t *testing.T, input string) string { + t.Helper() + + oldStdin := os.Stdin + reader, writer, err := os.Pipe() + require.NoError(t, err) + + _, err = writer.WriteString(input) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = oldStdin + _ = reader.Close() + }) + + secret, err := utils.GetSecretStdin("Password: ") + require.NoError(t, err) + + return secret +}