diff --git a/cmd/notation/internal/plugin/plugin.go b/cmd/notation/internal/plugin/plugin.go new file mode 100644 index 000000000..e06f57c18 --- /dev/null +++ b/cmd/notation/internal/plugin/plugin.go @@ -0,0 +1,85 @@ +// Copyright The Notary Project 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 plugin + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/notaryproject/notation/internal/httputil" +) + +// MaxPluginSourceBytes specifies the limit on how many bytes are allowed in the +// server's response to the download from URL request. +// +// The plugin source size must be strictly less than this value. +var MaxPluginSourceBytes int64 = 256 * 1024 * 1024 // 256 MiB + +// PluginSourceType is an enum for plugin source +type PluginSourceType int + +const ( + // PluginSourceTypeFile means plugin source is file + PluginSourceTypeFile PluginSourceType = 1 + iota + + // PluginSourceTypeURL means plugin source is URL + PluginSourceTypeURL +) + +const ( + // MediaTypeZip means plugin file is zip + MediaTypeZip = "application/zip" + + // MediaTypeGzip means plugin file is gzip + MediaTypeGzip = "application/x-gzip" +) + +// DownloadPluginFromURLTimeout is the timeout when downloading plugin from a +// URL +const DownloadPluginFromURLTimeout = 10 * time.Minute + +// DownloadPluginFromURL downloads plugin file from url to a tmp directory +func DownloadPluginFromURL(ctx context.Context, pluginURL string, tmpFile io.Writer) error { + // Get the data + client := httputil.NewAuthClient(ctx, &http.Client{Timeout: DownloadPluginFromURLTimeout}) + req, err := http.NewRequest(http.MethodGet, pluginURL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + // Check server response + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s %q: https response bad status: %s", resp.Request.Method, resp.Request.URL, resp.Status) + } + // Write the body to file + lr := &io.LimitedReader{ + R: resp.Body, + N: MaxPluginSourceBytes, + } + _, err = io.Copy(tmpFile, lr) + if err != nil { + return err + } + if lr.N == 0 { + return fmt.Errorf("%s %q: https response reaches the %d MiB size limit", resp.Request.Method, resp.Request.URL, MaxPluginSourceBytes) + } + return nil +} diff --git a/cmd/notation/plugin/cmd.go b/cmd/notation/plugin/cmd.go index 39e01bcbf..98c494b8f 100644 --- a/cmd/notation/plugin/cmd.go +++ b/cmd/notation/plugin/cmd.go @@ -23,6 +23,7 @@ func Cmd() *cobra.Command { command.AddCommand( listCommand(), + installCommand(nil), uninstallCommand(nil), ) diff --git a/cmd/notation/plugin/install.go b/cmd/notation/plugin/install.go new file mode 100644 index 000000000..2dfed9093 --- /dev/null +++ b/cmd/notation/plugin/install.go @@ -0,0 +1,315 @@ +// Copyright The Notary Project 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 plugin + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/notaryproject/notation-go/dir" + "github.com/notaryproject/notation-go/log" + "github.com/notaryproject/notation-go/plugin" + notationplugin "github.com/notaryproject/notation/cmd/notation/internal/plugin" + "github.com/notaryproject/notation/internal/cmd" + "github.com/notaryproject/notation/internal/osutil" + "github.com/spf13/cobra" +) + +const ( + notationPluginTmpDir = "notation-plugin" + notationPluginDownloadTmpFile = "notation-plugin-download" +) + +type pluginInstallOpts struct { + cmd.LoggingFlagOpts + pluginSourceType notationplugin.PluginSourceType + pluginSource string + inputChecksum string + isFile bool + isURL bool + force bool +} + +func installCommand(opts *pluginInstallOpts) *cobra.Command { + if opts == nil { + opts = &pluginInstallOpts{} + } + command := &cobra.Command{ + Use: "install [flags] <--file|--url> ", + Aliases: []string{"add"}, + Short: "Install plugin", + Long: `Install a plugin + +Example - Install plugin from file system: + notation plugin install --file wabbit-plugin-v1.0.zip + +Example - Install plugin from file system with user input SHA256 checksum: + notation plugin install --file wabbit-plugin-v1.0.zip --sha256sum 113062a462674a0e35cb5cad75a0bb2ea16e9537025531c0fd705018fcdbc17e + +Example - Install plugin from file system regardless if it's already installed: + notation plugin install --file wabbit-plugin-v1.0.zip --force + +Example - Install plugin from file system with .tar.gz: + notation plugin install --file wabbit-plugin-v1.0.tar.gz + +Example - Install plugin from URL, SHA256 checksum is required: + notation plugin install --url https://wabbit-networks.com/intaller/linux/amd64/wabbit-plugin-v1.0.tar.gz --sha256sum f8a75d9234db90069d9eb5660e5374820edf36d710bd063f4ef81e7063d3810b +`, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + switch { + case opts.isFile: + return errors.New("missing plugin file path") + case opts.isURL: + return errors.New("missing plugin URL") + } + return errors.New("missing plugin source") + } + if len(args) > 1 { + return fmt.Errorf("can only insall one plugin at a time, but got %v", args) + } + opts.pluginSource = args[0] + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + switch { + case opts.isFile: + opts.pluginSourceType = notationplugin.PluginSourceTypeFile + case opts.isURL: + opts.pluginSourceType = notationplugin.PluginSourceTypeURL + } + return install(cmd, opts) + }, + } + opts.LoggingFlagOpts.ApplyFlags(command.Flags()) + command.Flags().BoolVar(&opts.isFile, "file", false, "install plugin from a file in file system") + command.Flags().BoolVar(&opts.isURL, "url", false, "install plugin from an HTTPS URL. The timeout of the download HTTPS request is set to 10 minutes") + command.Flags().StringVar(&opts.inputChecksum, "sha256sum", "", "must match SHA256 of the plugin source, required when \"--url\" flag is set") + command.Flags().BoolVar(&opts.force, "force", false, "force the installation of the plugin") + command.MarkFlagsMutuallyExclusive("file", "url") + command.MarkFlagsOneRequired("file", "url") + return command +} + +func install(command *cobra.Command, opts *pluginInstallOpts) error { + // set log level + ctx := opts.LoggingFlagOpts.InitializeLogger(command.Context()) + // core process + switch opts.pluginSourceType { + case notationplugin.PluginSourceTypeFile: + if err := installPlugin(ctx, opts.pluginSource, opts.inputChecksum, opts.force); err != nil { + return fmt.Errorf("plugin installation failed: %w", err) + } + return nil + case notationplugin.PluginSourceTypeURL: + if opts.inputChecksum == "" { + return errors.New("installing from URL requires non-empty SHA256 checksum of the plugin source") + } + pluginURL, err := url.Parse(opts.pluginSource) + if err != nil { + return fmt.Errorf("failed to parse plugin download URL %s with error: %w", pluginURL, err) + } + if pluginURL.Scheme != "https" { + return fmt.Errorf("failed to download plugin from URL: only the HTTPS scheme is supported, but got %s", pluginURL.Scheme) + } + tmpFile, err := os.CreateTemp("", notationPluginDownloadTmpFile) + if err != nil { + return fmt.Errorf("failed to create notationPluginDownloadTmpFile: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + fmt.Printf("Downloading plugin from %s\n", opts.pluginSource) + err = notationplugin.DownloadPluginFromURL(ctx, opts.pluginSource, tmpFile) + if err != nil { + return fmt.Errorf("failed to download plugin from URL %s with error: %w", opts.pluginSource, err) + } + fmt.Println("Download completed") + if err := installPlugin(ctx, tmpFile.Name(), opts.inputChecksum, opts.force); err != nil { + return fmt.Errorf("plugin installation failed: %w", err) + } + return nil + default: + return errors.New("plugin installation failed: unknown plugin source type") + } +} + +// installPlugin installs the plugin given plugin source path +func installPlugin(ctx context.Context, inputPath string, inputChecksum string, force bool) error { + // sanity check + inputFileStat, err := os.Stat(inputPath) + if err != nil { + return err + } + if !inputFileStat.Mode().IsRegular() { + return fmt.Errorf("%s is not a valid file", inputPath) + } + // checksum check + if inputChecksum != "" { + if err := osutil.ValidateSHA256Sum(inputPath, inputChecksum); err != nil { + return err + } + } + // install the plugin based on file type + fileType, err := osutil.DetectFileType(inputPath) + if err != nil { + return err + } + switch fileType { + case notationplugin.MediaTypeZip: + rc, err := zip.OpenReader(inputPath) + if err != nil { + return err + } + defer rc.Close() + return installPluginFromFS(ctx, rc, force) + case notationplugin.MediaTypeGzip: + // when file is gzip, required to be tar + return installPluginFromTarGz(ctx, inputPath, force) + default: + // input file is not in zip or gzip, try install directly + installOpts := plugin.CLIInstallOptions{ + PluginPath: inputPath, + Overwrite: force, + } + return installPluginWithOptions(ctx, installOpts) + } +} + +// installPluginFromFS extracts, validates and installs the plugin files +// from a fs.FS +// +// Note: zip.ReadCloser implments fs.FS +func installPluginFromFS(ctx context.Context, pluginFs fs.FS, force bool) error { + // set up logger + logger := log.GetLogger(ctx) + root := "." + // extracting all regular files from root into tmpDir + tmpDir, err := os.MkdirTemp("", notationPluginTmpDir) + if err != nil { + return fmt.Errorf("failed to create notationPluginTmpDir: %w", err) + } + defer os.RemoveAll(tmpDir) + if err := fs.WalkDir(pluginFs, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + fName := d.Name() + if d.IsDir() && fName != root { // skip any dir in the fs except root + return fs.SkipDir + } + info, err := d.Info() + if err != nil { + return err + } + // only accept regular files. + // it is required by github-advanced-security to check for `..` in fName + if !info.Mode().IsRegular() || strings.Contains(fName, "..") { + return nil + } + logger.Debugf("Extracting file %s...", fName) + rc, err := pluginFs.Open(path) + if err != nil { + return err + } + defer rc.Close() + tmpFilePath := filepath.Join(tmpDir, fName) + return osutil.CopyFromReaderToDir(rc, tmpFilePath, info.Mode()) + }); err != nil { + return err + } + // install core process + installOpts := plugin.CLIInstallOptions{ + PluginPath: tmpDir, + Overwrite: force, + } + return installPluginWithOptions(ctx, installOpts) +} + +// installPluginFromTarGz extracts and untar a plugin tar.gz file, validates and +// installs the plugin +func installPluginFromTarGz(ctx context.Context, tarGzPath string, force bool) error { + logger := log.GetLogger(ctx) + rc, err := os.Open(tarGzPath) + if err != nil { + return err + } + defer rc.Close() + decompressedStream, err := gzip.NewReader(rc) + if err != nil { + return err + } + defer decompressedStream.Close() + tarReader := tar.NewReader(decompressedStream) + // extracting all regular files into tmpDir + tmpDir, err := os.MkdirTemp("", notationPluginTmpDir) + if err != nil { + return fmt.Errorf("failed to create notationPluginTmpDir: %w", err) + } + defer os.RemoveAll(tmpDir) + for { + header, err := tarReader.Next() + if err != nil { + if err == io.EOF { + break + } + return err + } + // only accept regular files. + // it is required by github-advanced-security to check for `..` in fName + if !header.FileInfo().Mode().IsRegular() || strings.Contains(header.Name, "..") { + continue + } + fName := filepath.Base(header.Name) + logger.Debugf("Extracting file %s...", fName) + tmpFilePath := filepath.Join(tmpDir, fName) + if err := osutil.CopyFromReaderToDir(tarReader, tmpFilePath, header.FileInfo().Mode()); err != nil { + return err + } + } + // install core process + installOpts := plugin.CLIInstallOptions{ + PluginPath: tmpDir, + Overwrite: force, + } + return installPluginWithOptions(ctx, installOpts) +} + +// installPluginWithOptions installs plugin with CLIInstallOptions +func installPluginWithOptions(ctx context.Context, opts plugin.CLIInstallOptions) error { + mgr := plugin.NewCLIManager(dir.PluginFS()) + existingPluginMetadata, newPluginMetadata, err := mgr.Install(ctx, opts) + if err != nil { + var errPluginDowngrade plugin.PluginDowngradeError + if errors.As(err, &errPluginDowngrade) { + return fmt.Errorf("%w.\nIt is not recommended to install an older version. To force the installation, use the \"--force\" option", errPluginDowngrade) + } + return err + } + if existingPluginMetadata != nil { + fmt.Printf("Succussefully installed plugin %s, updated the version from %s to %s\n", newPluginMetadata.Name, existingPluginMetadata.Version, newPluginMetadata.Version) + } else { + fmt.Printf("Succussefully installed plugin %s, version %s\n", newPluginMetadata.Name, newPluginMetadata.Version) + } + return nil +} diff --git a/cmd/notation/registry.go b/cmd/notation/registry.go index 7ef069f21..fe82b6c6e 100644 --- a/cmd/notation/registry.go +++ b/cmd/notation/registry.go @@ -18,17 +18,14 @@ import ( "errors" "fmt" "net" - "net/http" "github.com/notaryproject/notation-go/log" notationregistry "github.com/notaryproject/notation-go/registry" "github.com/notaryproject/notation/cmd/notation/internal/experimental" notationauth "github.com/notaryproject/notation/internal/auth" - "github.com/notaryproject/notation/internal/trace" - "github.com/notaryproject/notation/internal/version" + "github.com/notaryproject/notation/internal/httputil" "github.com/notaryproject/notation/pkg/configutil" credentials "github.com/oras-project/oras-credentials-go" - "github.com/sirupsen/logrus" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" @@ -122,19 +119,6 @@ func getRegistryLoginClient(ctx context.Context, opts *SecureFlagOpts, serverAdd return reg, nil } -func setHttpDebugLog(ctx context.Context, authClient *auth.Client) { - if logrusLog, ok := log.GetLogger(ctx).(*logrus.Logger); ok && logrusLog.Level != logrus.DebugLevel { - return - } - if authClient.Client == nil { - authClient.Client = http.DefaultClient - } - if authClient.Client.Transport == nil { - authClient.Client.Transport = http.DefaultTransport - } - authClient.Client.Transport = trace.NewTransport(authClient.Client.Transport) -} - // getAuthClient returns an *auth.Client and a bool indicating if the registry // is insecure. // @@ -157,12 +141,7 @@ func getAuthClient(ctx context.Context, opts *SecureFlagOpts, ref registry.Refer } // build authClient - authClient := &auth.Client{ - Cache: auth.NewCache(), - ClientID: "notation", - } - authClient.SetUserAgent("notation/" + version.GetVersion()) - setHttpDebugLog(ctx, authClient) + authClient := httputil.NewAuthClient(ctx, nil) if !withCredential { return authClient, insecureRegistry, nil } diff --git a/go.mod b/go.mod index e07ef8126..9759d13ff 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.21 require ( github.com/notaryproject/notation-core-go v1.0.1 - github.com/notaryproject/notation-go v1.0.2-0.20231123031546-5de0d58b21c1 + github.com/notaryproject/notation-go v1.0.2-0.20231218132318-85a5bb9826c6 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0-rc5 github.com/oras-project/oras-credentials-go v0.3.1 diff --git a/go.sum b/go.sum index acfed5966..72cf4c8e0 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/notaryproject/notation-core-go v1.0.1 h1:01doxjDERbd0vocLQrlJdusKrRLNNn50OJzp0c5I4Cw= github.com/notaryproject/notation-core-go v1.0.1/go.mod h1:rayl8WlKgS4YxOZgDO0iGGB4Ef515ZFZUFaZDmsPXgE= -github.com/notaryproject/notation-go v1.0.2-0.20231123031546-5de0d58b21c1 h1:TuSZ+3Eu3A/XKucl7J95sDT8XoG6t2dEcIipt6ydAls= -github.com/notaryproject/notation-go v1.0.2-0.20231123031546-5de0d58b21c1/go.mod h1:tSCFsAdKAtB7AfKS/BaUf8AXzASA+9TEokMDEDutqPM= +github.com/notaryproject/notation-go v1.0.2-0.20231218132318-85a5bb9826c6 h1:9YgUKLuNU8eNlv2H696aBQzW8CtSjevRgbMGld59wrY= +github.com/notaryproject/notation-go v1.0.2-0.20231218132318-85a5bb9826c6/go.mod h1:nqDueF9YCCX0u41Eec7aGJEXgGdM0a3KD79wqhCnxq0= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= diff --git a/internal/httputil/client.go b/internal/httputil/client.go new file mode 100644 index 000000000..ee0324ed5 --- /dev/null +++ b/internal/httputil/client.go @@ -0,0 +1,35 @@ +// Copyright The Notary Project 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 httputil + +import ( + "context" + "net/http" + + "github.com/notaryproject/notation/internal/trace" + "github.com/notaryproject/notation/internal/version" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// NewAuthClient returns an *auth.Client +func NewAuthClient(ctx context.Context, httpClient *http.Client) *auth.Client { + client := &auth.Client{ + Client: httpClient, + Cache: auth.NewCache(), + ClientID: "notation", + } + client.SetUserAgent("notation/" + version.GetVersion()) + trace.SetHTTPDebugLog(ctx, client) + return client +} diff --git a/internal/osutil/file.go b/internal/osutil/file.go index 66f1be0fe..d2de17ce5 100644 --- a/internal/osutil/file.go +++ b/internal/osutil/file.go @@ -14,13 +14,21 @@ package osutil import ( + "crypto/sha256" + "encoding/hex" "fmt" "io" "io/fs" + "net/http" "os" "path/filepath" + "strings" ) +// MaxFileBytes is the maximum file bytes. +// When used, the value should strictly less than this number. +var MaxFileBytes int64 = 256 * 1024 * 1024 // 256 MiB + // WriteFile writes to a path with all parent directories created. func WriteFile(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { @@ -94,3 +102,62 @@ func IsRegularFile(path string) (bool, error) { return fileStat.Mode().IsRegular(), nil } + +// CopyFromReaderToDir copies file from src to dst where dst is the destination +// file path. The file size must be less than 256 MiB. +func CopyFromReaderToDir(src io.Reader, dst string, perm fs.FileMode) error { + dstFile, err := os.Create(dst) + if err != nil { + return err + } + lr := &io.LimitedReader{ + R: src, + N: MaxFileBytes, + } + if _, err := io.Copy(dstFile, lr); err != nil || lr.N == 0 { + _ = dstFile.Close() + if err != nil { + return err + } + return fmt.Errorf("file reaches the %d MiB size limit", MaxFileBytes) + } + if err := dstFile.Chmod(perm); err != nil { + _ = dstFile.Close() + return err + } + return dstFile.Close() +} + +// DetectFileType returns a file's content type given path +func DetectFileType(path string) (string, error) { + rc, err := os.Open(path) + if err != nil { + return "", err + } + defer rc.Close() + lr := io.LimitReader(rc, 512) + header, err := io.ReadAll(lr) + if err != nil { + return "", err + } + return http.DetectContentType(header), nil +} + +// ValidateSHA256Sum returns nil if SHA256 of file at path equals to checksum. +func ValidateSHA256Sum(path string, checksum string) error { + rc, err := os.Open(path) + if err != nil { + return err + } + defer rc.Close() + sha256Hash := sha256.New() + if _, err := io.Copy(sha256Hash, rc); err != nil { + return err + } + sha256sum := sha256Hash.Sum(nil) + enc := hex.EncodeToString(sha256sum[:]) + if !strings.EqualFold(enc, checksum) { + return fmt.Errorf("plugin checksum does not match user input. Expecting %s", checksum) + } + return nil +} diff --git a/internal/osutil/file_test.go b/internal/osutil/file_test.go index 6bd70e541..fdf9dd331 100644 --- a/internal/osutil/file_test.go +++ b/internal/osutil/file_test.go @@ -15,7 +15,6 @@ package osutil import ( "bytes" - "io/ioutil" "os" "path/filepath" "runtime" @@ -23,11 +22,11 @@ import ( ) func validFileContent(t *testing.T, filename string, content []byte) { - b, err := ioutil.ReadFile(filename) + b, err := os.ReadFile(filename) if err != nil { t.Fatal(err) } - if bytes.Compare(content, b) != 0 { + if !bytes.Equal(content, b) { t.Fatal("file content is not correct") } } @@ -260,3 +259,13 @@ func TestCopyToDir(t *testing.T) { validFileContent(t, filepath.Join(destDir, "file.txt"), data) }) } + +func TestValidateChecksum(t *testing.T) { + expectedErrorMsg := "plugin checksum does not match user input. Expecting abcd123" + if err := ValidateSHA256Sum("./testdata/test", "abcd123"); err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expected err %s, got %v", expectedErrorMsg, err) + } + if err := ValidateSHA256Sum("./testdata/test", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); err != nil { + t.Fatalf("expected nil err, got %v", err) + } +} diff --git a/internal/osutil/testdata/test b/internal/osutil/testdata/test new file mode 100644 index 000000000..e69de29bb diff --git a/internal/trace/transport.go b/internal/trace/transport.go index e75d334d0..86700c692 100644 --- a/internal/trace/transport.go +++ b/internal/trace/transport.go @@ -30,10 +30,13 @@ limitations under the License. package trace import ( + "context" "net/http" "strings" "github.com/notaryproject/notation-go/log" + "github.com/sirupsen/logrus" + "oras.land/oras-go/v2/registry/remote/auth" ) // Transport is an http.RoundTripper that keeps track of the in-flight @@ -82,3 +85,17 @@ func logHeader(header http.Header, e log.Logger) { e.Debugf(" Empty header") } } + +// SetHTTPDebugLog sets up http debug log with logrus.Logger +func SetHTTPDebugLog(ctx context.Context, authClient *auth.Client) { + if logrusLog, ok := log.GetLogger(ctx).(*logrus.Logger); ok && logrusLog.Level != logrus.DebugLevel { + return + } + if authClient.Client == nil { + authClient.Client = http.DefaultClient + } + if authClient.Client.Transport == nil { + authClient.Client.Transport = http.DefaultTransport + } + authClient.Client.Transport = NewTransport(authClient.Client.Transport) +} diff --git a/specs/commandline/plugin.md b/specs/commandline/plugin.md index 14d5399ae..8ab20e54f 100644 --- a/specs/commandline/plugin.md +++ b/specs/commandline/plugin.md @@ -95,7 +95,7 @@ Successfully installed plugin , version If the entered plugin checksum digest doesn't match the published checksum, Notation will return an error message and will not start installation. ```console -Error: failed to install the plugin: plugin checksum does not match user input. Expecting +Error: plugin installation failed: plugin checksum does not match user input. Expecting ``` If the plugin version is higher than the existing plugin, Notation will start installation and overwrite the existing plugin. @@ -107,13 +107,13 @@ Successfully installed plugin , updated the version from to If the plugin version is equal to the existing plugin, Notation will not start installation and return the following message. Users can use a flag `--force` to skip plugin version check and force the installation. ```console -Error: failed to install the plugin: with version already exists. +Error: plugin installation failed: plugin with version already exists. ``` If the plugin version is lower than the existing plugin, Notation will return an error message and will not start installation. Users can use a flag `--force` to skip plugin version check and force the installation. ```console -Error: failed to install the plugin: . The installing plugin version is lower than the existing plugin version . +Error: failed to install plugin: . The installing plugin version is lower than the existing plugin version . It is not recommended to install an older version. To force the installation, use the "--force" option. ``` ### Install a plugin from URL diff --git a/test/e2e/run.sh b/test/e2e/run.sh index 6365793ea..a3ce61864 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -72,8 +72,8 @@ fi go install -mod=mod github.com/onsi/ginkgo/v2/ginkgo@v2.9.5 # build e2e plugin and tar.gz -PLUGIN_NAME=e2e-plugin -( cd $CWD/plugin && go build -o ./bin/$PLUGIN_NAME . && echo "e2e plugin built." && tar --transform="flags=r;s|$PLUGIN_NAME|notation-$PLUGIN_NAME|" -czvf ./bin/$PLUGIN_NAME.tar.gz -C ./bin/ $PLUGIN_NAME) +PLUGIN_NAME=notation-e2e-plugin +( cd $CWD/plugin && go build -o ./bin/$PLUGIN_NAME . && echo "e2e plugin built." && tar -czvf ./bin/$PLUGIN_NAME.tar.gz -C ./bin/ $PLUGIN_NAME) # setup registry case $REGISTRY_NAME in diff --git a/test/e2e/suite/plugin/install.go b/test/e2e/suite/plugin/install.go new file mode 100644 index 000000000..bd85d0389 --- /dev/null +++ b/test/e2e/suite/plugin/install.go @@ -0,0 +1,124 @@ +// Copyright The Notary Project 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 plugin + +import ( + . "github.com/notaryproject/notation/test/e2e/internal/notation" + "github.com/notaryproject/notation/test/e2e/internal/utils" + . "github.com/onsi/ginkgo/v2" +) + +const ( + PluginURL = "https://github.com/notaryproject/notation-action/raw/e2e-test-plugin/tests/plugin_binaries/notation-e2e-test-plugin_0.1.0_linux_amd64.tar.gz" + PluginChecksum = "be8d035024d3a96afb4118af32f2e201f126c7254b02f7bcffb3e3149d744fd2" +) + +var _ = Describe("notation plugin install", func() { + It("with missing file or url flag", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "."). + MatchErrContent("Error: at least one of the flags in the group [file url] is required\n") + }) + }) + + It("with both file and url flags are set", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "--file", "--url", "."). + MatchErrContent("Error: if any flags in the group [file url] are set none of the others can be; [file url] were all set\n") + }) + }) + + It("with missing plugin source", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install"). + MatchErrContent("Error: missing plugin source\n") + }) + }) + + It("with missing plugin file path", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "--file"). + MatchErrContent("Error: missing plugin file path\n") + }) + }) + + It("with missing plugin URL", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "--url"). + MatchErrContent("Error: missing plugin URL\n") + }) + }) + + It("with valid plugin file path", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.Exec("plugin", "install", "--file", NotationE2EPluginTarGzPath, "-v"). + MatchContent("Succussefully installed plugin e2e-plugin, version 1.0.0\n") + }) + }) + + It("with plugin executable file path", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.Exec("plugin", "install", "--file", NotationE2EPluginPath). + MatchContent("Succussefully installed plugin e2e-plugin, version 1.0.0\n") + }) + }) + + It("with plugin already installed", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.Exec("plugin", "install", "--file", NotationE2EPluginTarGzPath). + MatchContent("Succussefully installed plugin e2e-plugin, version 1.0.0\n") + + notation.ExpectFailure().Exec("plugin", "install", "--file", NotationE2EPluginTarGzPath). + MatchErrContent("Error: plugin installation failed: plugin e2e-plugin with version 1.0.0 already exists\n") + }) + }) + + It("with plugin already installed but force install", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.Exec("plugin", "install", "--file", NotationE2EPluginTarGzPath, "-v"). + MatchContent("Succussefully installed plugin e2e-plugin, version 1.0.0\n") + + notation.Exec("plugin", "install", "--file", NotationE2EPluginTarGzPath, "--force"). + MatchContent("Succussefully installed plugin e2e-plugin, updated the version from 1.0.0 to 1.0.0\n") + }) + }) + + It("with valid plugin URL", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.Exec("plugin", "install", "--url", PluginURL, "--sha256sum", PluginChecksum). + MatchKeyWords("Succussefully installed plugin e2e-test-plugin, version 0.1.0\n") + }) + }) + + It("with valid plugin URL but missing checksum", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "--url", PluginURL). + MatchErrContent("Error: installing from URL requires non-empty SHA256 checksum of the plugin source\n") + }) + }) + + It("with invalid plugin URL scheme", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "--url", "http://invalid", "--sha256sum", "abcd"). + MatchErrContent("Error: failed to download plugin from URL: only the HTTPS scheme is supported, but got http\n") + }) + }) + + It("with invalid plugin URL", func() { + Host(nil, func(notation *utils.ExecOpts, _ *Artifact, vhost *utils.VirtualHost) { + notation.ExpectFailure().Exec("plugin", "install", "--url", "https://invalid", "--sha256sum", "abcd"). + MatchErrKeyWords("failed to download plugin from URL https://invalid") + }) + }) +})