diff --git a/internal/file/file.go b/internal/file/file.go index 2fe42f05..920fa640 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -13,9 +13,100 @@ package file -import "regexp" +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +// ErrNotRegularFile is returned when the file is not an regular file. +var ErrNotRegularFile = errors.New("not regular file") + +// ErrNotDirectory is returned when the path is not a directory. +var ErrNotDirectory = errors.New("not directory") // IsValidFileName checks if a file name is cross-platform compatible func IsValidFileName(fileName string) bool { return regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`).MatchString(fileName) } + +// CopyToDir copies the src file to dst dir. All parent directories are created +// with permissions 0755. +// +// Source file's read and execute permissions are preserved for everyone. +// Write permission is preserved for owner. Group and others cannot write. +// Existing file will be overwritten. +func CopyToDir(src, dst string) error { + sourceFileInfo, err := os.Stat(src) + if err != nil { + return err + } + if !sourceFileInfo.Mode().IsRegular() { + return ErrNotRegularFile + } + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + dstFile := filepath.Join(dst, filepath.Base(src)) + destination, err := os.Create(dstFile) + if err != nil { + return err + } + defer destination.Close() + err = destination.Chmod(sourceFileInfo.Mode() & os.FileMode(0755)) + if err != nil { + return err + } + _, err = io.Copy(destination, source) + return err +} + +// CopyDirToDir copies contents in src dir to dst dir. Only regular files are +// copied. Existing files will be overwritten. +func CopyDirToDir(src, dst string) error { + fi, err := os.Stat(src) + if err != nil { + return err + } + if !fi.Mode().IsDir() { + return ErrNotDirectory + } + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + // skip sub-directories + if d.IsDir() && d.Name() != filepath.Base(path) { + return fs.SkipDir + } + info, err := d.Info() + if err != nil { + return err + } + // only copy regular files + if info.Mode().IsRegular() { + return CopyToDir(path, dst) + } + return nil + }) +} + +// TrimFileExtension returns the file name without extension. +// +// For example, +// +// when input is xyz.exe, output is xyz +// +// when input is xyz.tar.gz, output is xyz.tar +func TrimFileExtension(fileName string) string { + return strings.TrimSuffix(fileName, filepath.Ext(fileName)) +} diff --git a/internal/file/file_test.go b/internal/file/file_test.go new file mode 100644 index 00000000..a108d0da --- /dev/null +++ b/internal/file/file_test.go @@ -0,0 +1,179 @@ +// 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 file + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCopyToDir(t *testing.T) { + t.Run("copy file", func(t *testing.T) { + tempDir := t.TempDir() + data := []byte("data") + filename := filepath.Join(tempDir, "a", "file.txt") + if err := writeFile(filename, data); err != nil { + t.Fatal(err) + } + + destDir := filepath.Join(tempDir, "b") + if err := CopyToDir(filename, destDir); err != nil { + t.Fatal(err) + } + }) + + t.Run("source directory permission error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + tempDir := t.TempDir() + destDir := t.TempDir() + data := []byte("data") + filename := filepath.Join(tempDir, "a", "file.txt") + if err := writeFile(filename, data); err != nil { + t.Fatal(err) + } + + if err := os.Chmod(tempDir, 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(tempDir, 0700) + + if err := CopyToDir(filename, destDir); err == nil { + t.Fatal("should have error") + } + }) + + t.Run("not a regular file", func(t *testing.T) { + tempDir := t.TempDir() + destDir := t.TempDir() + if err := CopyToDir(tempDir, destDir); err == nil { + t.Fatal("should have error") + } + }) + + t.Run("source file permission error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + tempDir := t.TempDir() + destDir := t.TempDir() + data := []byte("data") + // prepare file + filename := filepath.Join(tempDir, "a", "file.txt") + if err := writeFile(filename, data); err != nil { + t.Fatal(err) + } + // forbid reading + if err := os.Chmod(filename, 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(filename, 0600) + if err := CopyToDir(filename, destDir); err == nil { + t.Fatal("should have error") + } + }) + + t.Run("dest directory permission error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + tempDir := t.TempDir() + destTempDir := t.TempDir() + data := []byte("data") + // prepare file + filename := filepath.Join(tempDir, "a", "file.txt") + if err := writeFile(filename, data); err != nil { + t.Fatal(err) + } + // forbid dest directory operation + if err := os.Chmod(destTempDir, 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(destTempDir, 0700) + if err := CopyToDir(filename, filepath.Join(destTempDir, "a")); err == nil { + t.Fatal("should have error") + } + }) + + t.Run("dest directory permission error 2", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + + tempDir := t.TempDir() + destTempDir := t.TempDir() + data := []byte("data") + // prepare file + filename := filepath.Join(tempDir, "a", "file.txt") + if err := writeFile(filename, data); err != nil { + t.Fatal(err) + } + // forbid writing to destTempDir + if err := os.Chmod(destTempDir, 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(destTempDir, 0700) + if err := CopyToDir(filename, destTempDir); err == nil { + t.Fatal("should have error") + } + }) + + t.Run("copy file and check content", func(t *testing.T) { + tempDir := t.TempDir() + data := []byte("data") + filename := filepath.Join(tempDir, "a", "file.txt") + if err := writeFile(filename, data); err != nil { + t.Fatal(err) + } + + destDir := filepath.Join(tempDir, "b") + if err := CopyToDir(filename, destDir); err != nil { + t.Fatal(err) + } + validFileContent(t, filepath.Join(destDir, "file.txt"), data) + }) +} + +func TestFileNameWithoutExtension(t *testing.T) { + input := "testfile.tar.gz" + expectedOutput := "testfile.tar" + actualOutput := TrimFileExtension(input) + if actualOutput != expectedOutput { + t.Errorf("expected '%s', but got '%s'", expectedOutput, actualOutput) + } +} + +func validFileContent(t *testing.T, filename string, content []byte) { + b, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(content, b) { + t.Fatal("file content is not correct") + } +} + +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} diff --git a/internal/semver/semver.go b/internal/semver/semver.go new file mode 100644 index 00000000..e2eb3406 --- /dev/null +++ b/internal/semver/semver.go @@ -0,0 +1,49 @@ +// 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 semver provides functions related to semanic version. +// This package is based on "golang.org/x/mod/semver" +package semver + +import ( + "fmt" + "regexp" + + "golang.org/x/mod/semver" +) + +// semVerRegEx is taken from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +var semVerRegEx = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + +// IsValid returns true if version is a valid semantic version +func IsValid(version string) bool { + return semVerRegEx.MatchString(version) +} + +// ComparePluginVersion validates and compares two plugin semantic versions. +// +// The result will be 0 if v == w, -1 if v < w, or +1 if v > w. +func ComparePluginVersion(v, w string) (int, error) { + // sanity check + if !IsValid(v) { + return 0, fmt.Errorf("%s is not a valid semantic version", v) + } + if !IsValid(w) { + return 0, fmt.Errorf("%s is not a valid semantic version", w) + } + + // golang.org/x/mod/semver requires semantic version strings must begin + // with a leading "v". Adding prefix "v" to the inputs. + // Reference: https://pkg.go.dev/golang.org/x/mod/semver#pkg-overview + return semver.Compare("v"+v, "v"+w), nil +} diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go new file mode 100644 index 00000000..9ccd5b6d --- /dev/null +++ b/internal/semver/semver_test.go @@ -0,0 +1,40 @@ +// 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 semver + +import "testing" + +func TestComparePluginVersion(t *testing.T) { + t.Run("compare with lower version", func(t *testing.T) { + comp, err := ComparePluginVersion("1.0.0", "1.0.1") + if err != nil || comp >= 0 { + t.Fatal("expected nil err and negative comp") + } + }) + + t.Run("compare with equal version", func(t *testing.T) { + comp, err := ComparePluginVersion("1.0.1", "1.0.1") + if err != nil || comp != 0 { + t.Fatal("expected nil err and comp equal to 0") + } + }) + + t.Run("failed due to invalid semantic version", func(t *testing.T) { + expectedErrMsg := "v1.0.0 is not a valid semantic version" + _, err := ComparePluginVersion("v1.0.0", "1.0.1") + if err == nil || err.Error() != expectedErrMsg { + t.Fatalf("expected err %s, but got %s", expectedErrMsg, err) + } + }) +} diff --git a/plugin/errors.go b/plugin/errors.go new file mode 100644 index 00000000..2a87f140 --- /dev/null +++ b/plugin/errors.go @@ -0,0 +1,49 @@ +// 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 "errors" + +// ErrNotCompliant is returned by plugin methods when the response is not +// compliant. +var ErrNotCompliant = errors.New("plugin not compliant") + +// ErrNotRegularFile is returned when the plugin file is not an regular file. +var ErrNotRegularFile = errors.New("not regular file") + +// PluginDowngradeError is returned when installing a plugin with version +// lower than the exisiting plugin version. +type PluginDowngradeError struct { + Msg string +} + +func (e PluginDowngradeError) Error() string { + if e.Msg != "" { + return e.Msg + } + return "installing plugin with version lower than the existing plugin version" +} + +// InstallEqualVersionError is returned when installing a plugin with version +// equal to the exisiting plugin version. +type InstallEqualVersionError struct { + Msg string +} + +func (e InstallEqualVersionError) Error() string { + if e.Msg != "" { + return e.Msg + } + return "installing plugin with version equal to the existing plugin version" +} diff --git a/plugin/manager.go b/plugin/manager.go index 6b5d98b8..65aea0bd 100644 --- a/plugin/manager.go +++ b/plugin/manager.go @@ -16,20 +16,18 @@ package plugin import ( "context" "errors" + "fmt" "io/fs" "os" "path" + "path/filepath" "github.com/notaryproject/notation-go/dir" + "github.com/notaryproject/notation-go/internal/file" + "github.com/notaryproject/notation-go/internal/semver" + "github.com/notaryproject/notation-go/plugin/proto" ) -// ErrNotCompliant is returned by plugin methods when the response is not -// compliant. -var ErrNotCompliant = errors.New("plugin not compliant") - -// ErrNotRegularFile is returned when the plugin file is not an regular file. -var ErrNotRegularFile = errors.New("not regular file") - // Manager manages plugins installed on the system. type Manager interface { Get(ctx context.Context, name string) (Plugin, error) @@ -84,7 +82,126 @@ func (m *CLIManager) List(ctx context.Context) ([]string, error) { return plugins, nil } -// Uninstall uninstalls a plugin on the system by its name +// CLIInstallOptions provides user customized options for plugin installation +type CLIInstallOptions struct { + // PluginPath can be path of: + // + // 1. A directory which contains plugin related files. Sub-directories are + // ignored. It MUST contain one and only one valid plugin executable file + // following spec: https://github.com/notaryproject/specifications/blob/v1.0.0/specs/plugin-extensibility.md#installation + // It may contain extra lib files and LICENSE files. + // On success, these files will be installed as well. + // + // 2. A single plugin executable file following the spec. + PluginPath string + + // Overwrite is a boolean flag. When set, always install the new plugin. + Overwrite bool +} + +// Install installs a plugin to the system. It returns existing +// plugin metadata, new plugin metadata, and error. It returns nil error +// if and only if the installation succeeded. +// +// If plugin does not exist, directly install the new plugin. +// +// If plugin already exists: +// +// If overwrite is not set, then the new plugin +// version MUST be higher than the existing plugin version. +// +// If overwrite is set, version check is skipped. If existing +// plugin is malfunctioning, it will be overwritten. +func (m *CLIManager) Install(ctx context.Context, installOpts CLIInstallOptions) (*proto.GetMetadataResponse, *proto.GetMetadataResponse, error) { + // initialization + overwrite := installOpts.Overwrite + if installOpts.PluginPath == "" { + return nil, nil, errors.New("plugin source path cannot be empty") + } + var installFromNonDir bool + pluginExecutableFile, pluginName, err := parsePluginFromDir(installOpts.PluginPath) + if err != nil { + if !errors.Is(err, file.ErrNotDirectory) { + return nil, nil, fmt.Errorf("failed to read plugin from directory %s: %w", installOpts.PluginPath, err) + } + // input is not a dir, check if it's a single plugin executable file + installFromNonDir = true + pluginExecutableFile = installOpts.PluginPath + pluginName, err = ParsePluginName(filepath.Base(pluginExecutableFile)) + if err != nil { + return nil, nil, fmt.Errorf("failed to read plugin name from file path %s: %w", pluginExecutableFile, err) + } + isExec, err := isExecutableFile(pluginExecutableFile) + if err != nil { + return nil, nil, fmt.Errorf("failed to check if file %s is executable: %w", pluginExecutableFile, err) + } + if !isExec { + return nil, nil, fmt.Errorf("file %s is not executable", pluginExecutableFile) + } + } + // validate and get new plugin metadata + if err := validatePluginFileExtensionAgainstOS(filepath.Base(pluginExecutableFile), pluginName); err != nil { + return nil, nil, err + } + newPlugin, err := NewCLIPlugin(ctx, pluginName, pluginExecutableFile) + if err != nil { + return nil, nil, fmt.Errorf("failed to create new CLI plugin: %w", err) + } + newPluginMetadata, err := newPlugin.GetMetadata(ctx, &proto.GetMetadataRequest{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to get metadata of new plugin: %w", err) + } + // check plugin existence and get existing plugin metadata + var existingPluginMetadata *proto.GetMetadataResponse + existingPlugin, err := m.Get(ctx, pluginName) + if err != nil { + // fail only if overwrite is not set + if !errors.Is(err, os.ErrNotExist) && !overwrite { + return nil, nil, fmt.Errorf("failed to check plugin existence: %w", err) + } + } else { // plugin already exists + existingPluginMetadata, err = existingPlugin.GetMetadata(ctx, &proto.GetMetadataRequest{}) + if err != nil && !overwrite { // fail only if overwrite is not set + return nil, nil, fmt.Errorf("failed to get metadata of existing plugin: %w", err) + } + // existing plugin is valid, and overwrite is not set, check version + if !overwrite { + comp, err := semver.ComparePluginVersion(newPluginMetadata.Version, existingPluginMetadata.Version) + if err != nil { + return nil, nil, fmt.Errorf("failed to compare plugin versions: %w", err) + } + switch { + case comp < 0: + return nil, nil, PluginDowngradeError{Msg: fmt.Sprintf("failed to install plugin %s. The installing plugin version %s is lower than the existing plugin version %s", pluginName, newPluginMetadata.Version, existingPluginMetadata.Version)} + case comp == 0: + return nil, nil, InstallEqualVersionError{Msg: fmt.Sprintf("plugin %s with version %s already exists", pluginName, existingPluginMetadata.Version)} + } + } + } + // clean up before installation, this guarantees idempotent for install + if err := m.Uninstall(ctx, pluginName); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, nil, fmt.Errorf("failed to clean up plugin %s before installation: %w", pluginName, err) + } + } + // core process + pluginDirPath, err := m.pluginFS.SysPath(pluginName) + if err != nil { + return nil, nil, fmt.Errorf("failed to get the system path of plugin %s: %w", pluginName, err) + } + if installFromNonDir { + if err := file.CopyToDir(pluginExecutableFile, pluginDirPath); err != nil { + return nil, nil, fmt.Errorf("failed to copy plugin executable file from %s to %s: %w", pluginExecutableFile, pluginDirPath, err) + } + } else { + if err := file.CopyDirToDir(installOpts.PluginPath, pluginDirPath); err != nil { + return nil, nil, fmt.Errorf("failed to copy plugin files from %s to %s: %w", installOpts.PluginPath, pluginDirPath, err) + } + } + return existingPluginMetadata, newPluginMetadata, nil +} + +// Uninstall uninstalls a plugin on the system by its name. // If the plugin dir does not exist, os.ErrNotExist is returned. func (m *CLIManager) Uninstall(ctx context.Context, name string) error { pluginDirPath, err := m.pluginFS.SysPath(name) @@ -96,3 +213,63 @@ func (m *CLIManager) Uninstall(ctx context.Context, name string) error { } return os.RemoveAll(pluginDirPath) } + +// parsePluginFromDir checks if a dir is a valid plugin dir which contains +// one and only one plugin executable file. The dir may contain extra lib files +// and LICENSE files. Sub-directories are ignored. +// +// On success, the plugin executable file path, plugin name and +// nil error are returned. +func parsePluginFromDir(path string) (string, string, error) { + // sanity check + fi, err := os.Stat(path) + if err != nil { + return "", "", err + } + if !fi.Mode().IsDir() { + return "", "", file.ErrNotDirectory + } + // walk the path + var pluginExecutableFile, pluginName string + var foundPluginExecutableFile bool + if err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + // skip sub-directories + if d.IsDir() && d.Name() != filepath.Base(path) { + return fs.SkipDir + } + info, err := d.Info() + if err != nil { + return err + } + // only take regular files + if info.Mode().IsRegular() { + if pluginName, err = ParsePluginName(d.Name()); err != nil { + // file name does not follow the notation-{plugin-name} format, + // continue + return nil + } + isExec, err := isExecutableFile(p) + if err != nil { + return err + } + if !isExec { + return nil + } + if foundPluginExecutableFile { + return errors.New("found more than one plugin executable files") + } + foundPluginExecutableFile = true + pluginExecutableFile = p + } + return nil + }); err != nil { + return "", "", err + } + if !foundPluginExecutableFile { + return "", "", errors.New("no plugin executable file was found") + } + return pluginExecutableFile, pluginName, nil +} diff --git a/plugin/manager_test.go b/plugin/manager_test.go index b3b03488..8aa92f88 100644 --- a/plugin/manager_test.go +++ b/plugin/manager_test.go @@ -18,7 +18,9 @@ import ( "encoding/json" "io/fs" "os" + "path/filepath" "reflect" + "runtime" "testing" "testing/fstest" @@ -36,8 +38,45 @@ func (t testCommander) Output(ctx context.Context, path string, command proto.Co return t.stdout, t.stderr, t.err } +type testInstallCommander struct { + existedPluginFilePath string + existedPluginStdout []byte + existedPluginStderr []byte + existedPluginErr error + newPluginFilePath string + newPluginStdout []byte + newPluginStderr []byte + newPluginErr error + err error +} + +func (t testInstallCommander) Output(ctx context.Context, path string, command proto.Command, req []byte) ([]byte, []byte, error) { + if path == t.existedPluginFilePath { + return t.existedPluginStdout, t.existedPluginStderr, t.existedPluginErr + } + if path == t.newPluginFilePath { + return t.newPluginStdout, t.newPluginStderr, t.newPluginErr + } + return nil, nil, t.err +} + var validMetadata = proto.GetMetadataResponse{ - Name: "foo", Description: "friendly", Version: "1", URL: "example.com", + Name: "foo", Description: "friendly", Version: "1.0.0", URL: "example.com", + SupportedContractVersions: []string{"1.0"}, Capabilities: []proto.Capability{proto.CapabilitySignatureGenerator}, +} + +var validMetadataHigherVersion = proto.GetMetadataResponse{ + Name: "foo", Description: "friendly", Version: "1.1.0", URL: "example.com", + SupportedContractVersions: []string{"1.0"}, Capabilities: []proto.Capability{proto.CapabilitySignatureGenerator}, +} + +var validMetadataLowerVersion = proto.GetMetadataResponse{ + Name: "foo", Description: "friendly", Version: "0.1.0", URL: "example.com", + SupportedContractVersions: []string{"1.0"}, Capabilities: []proto.Capability{proto.CapabilitySignatureGenerator}, +} + +var validMetadataBar = proto.GetMetadataResponse{ + Name: "bar", Description: "friendly", Version: "1.0.0", URL: "example.com", SupportedContractVersions: []string{"1.0"}, Capabilities: []proto.Capability{proto.CapabilitySignatureGenerator}, } @@ -52,6 +91,9 @@ var invalidContractVersionMetadata = proto.GetMetadataResponse{ } func TestManager_Get(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } executor = testCommander{stdout: metadataJSON(validMetadata)} mgr := NewCLIManager(mockfs.NewSysFSWithRootMock(fstest.MapFS{}, "./testdata/plugins")) _, err := mgr.Get(context.Background(), "foo") @@ -88,7 +130,369 @@ func TestManager_List(t *testing.T) { }) } +func TestManager_Install(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } + existedPluginFilePath := "testdata/plugins/foo/notation-foo" + newPluginFilePath := "testdata/foo/notation-foo" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + mgr := NewCLIManager(mockfs.NewSysFSWithRootMock(fstest.MapFS{}, "testdata/plugins")) + + t.Run("success install with higher version", func(t *testing.T) { + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadataHigherVersion), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + existingPluginMetadata, newPluginMetadata, err := mgr.Install(context.Background(), installOpts) + if err != nil { + t.Fatalf("expecting error to be nil, but got %v", err) + } + if existingPluginMetadata.Version != validMetadata.Version { + t.Fatalf("existing plugin version mismatch, existing plugin version: %s, but got: %s", validMetadata.Version, existingPluginMetadata.Version) + } + if newPluginMetadata.Version != validMetadataHigherVersion.Version { + t.Fatalf("new plugin version mismatch, new plugin version: %s, but got: %s", validMetadataHigherVersion.Version, newPluginMetadata.Version) + } + }) + + t.Run("success install with lower version and overwrite", func(t *testing.T) { + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadataLowerVersion), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + Overwrite: true, + } + if _, _, err := mgr.Install(context.Background(), installOpts); err != nil { + t.Fatalf("expecting error to be nil, but got %v", err) + } + }) + + t.Run("success install without existing plugin", func(t *testing.T) { + newPluginFilePath := "testdata/bar/notation-bar" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + newPluginFilePath: newPluginFilePath, + newPluginStdout: metadataJSON(validMetadataBar), + } + defer mgr.Uninstall(context.Background(), "bar") + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + existingPluginMetadata, newPluginMetadata, err := mgr.Install(context.Background(), installOpts) + if err != nil { + t.Fatalf("expecting error to be nil, but got %v", err) + } + if existingPluginMetadata != nil { + t.Fatalf("expecting existingPluginMetadata to be nil, but got %v", existingPluginMetadata) + } + if newPluginMetadata.Version != validMetadataBar.Version { + t.Fatalf("new plugin version mismatch, new plugin version: %s, but got: %s", validMetadataBar.Version, newPluginMetadata.Version) + } + }) + + t.Run("fail to install due to equal version", func(t *testing.T) { + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadata), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "plugin foo with version 1.0.0 already exists" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to lower version", func(t *testing.T) { + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadataLowerVersion), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "failed to install plugin foo. The installing plugin version 0.1.0 is lower than the existing plugin version 1.0.0" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to wrong plugin executable file name format", func(t *testing.T) { + newPluginFilePath := "testdata/bar/bar" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + newPluginFilePath: newPluginFilePath, + newPluginStdout: metadataJSON(validMetadataBar), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "failed to read plugin name from file path testdata/bar/bar: invalid plugin executable file name. Plugin file name requires format notation-{plugin-name}, but got bar" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to wrong plugin file permission", func(t *testing.T) { + newPluginFilePath := "testdata/bar/notation-bar" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0600); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + newPluginFilePath: newPluginFilePath, + newPluginStdout: metadataJSON(validMetadataBar), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "file testdata/bar/notation-bar is not executable" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to invalid new plugin file extension", func(t *testing.T) { + newPluginFilePath := "testdata/bar/notation-bar.exe" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + newPluginFilePath: newPluginFilePath, + newPluginStdout: metadataJSON(validMetadataBar), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "invalid plugin file extension. Expecting file notation-bar, but got notation-bar.exe" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to new plugin executable file does not exist", func(t *testing.T) { + newPluginFilePath := "testdata/bar/notation-bar" + executor = testInstallCommander{ + newPluginFilePath: newPluginFilePath, + newPluginStdout: metadataJSON(validMetadataBar), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "failed to read plugin from directory testdata/bar/notation-bar: stat testdata/bar/notation-bar: no such file or directory" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to invalid new plugin metadata", func(t *testing.T) { + newPluginFilePath := "testdata/bar/notation-bar" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + newPluginFilePath: newPluginFilePath, + newPluginStdout: metadataJSON(invalidMetadataName), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "failed to get metadata of new plugin: executable name must be \"notation-foobar\" instead of \"notation-bar\"" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install due to invalid existing plugin metadata", func(t *testing.T) { + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadataBar), + newPluginStdout: metadataJSON(validMetadata), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + } + expectedErrorMsg := "failed to get metadata of existing plugin: executable name must be \"notation-bar\" instead of \"notation-foo\"" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("success to install with overwrite and invalid existing plugin metadata", func(t *testing.T) { + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadataBar), + newPluginStdout: metadataJSON(validMetadata), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginFilePath, + Overwrite: true, + } + _, _, err := mgr.Install(context.Background(), installOpts) + if err != nil { + t.Fatalf("expecting error to be nil, but got %v", err) + } + }) + + t.Run("success to install from plugin dir", func(t *testing.T) { + existedPluginFilePath := "testdata/plugins/foo/notation-foo" + newPluginFilePath := "testdata/foo/notation-foo" + newPluginLibPath := "testdata/foo/libfoo" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + if err := createFileAndChmod(newPluginLibPath, 0600); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadataHigherVersion), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginDir, + } + existingPluginMetadata, newPluginMetadata, err := mgr.Install(context.Background(), installOpts) + if err != nil { + t.Fatalf("expecting nil error, but got %v", err) + } + if existingPluginMetadata.Version != "1.0.0" { + t.Fatalf("expecting existing plugin metadata to be 1.0.0, but got %s", existingPluginMetadata.Version) + } + if newPluginMetadata.Version != "1.1.0" { + t.Fatalf("expecting new plugin metadata to be 1.1.0, but got %s", newPluginMetadata.Version) + } + }) + + t.Run("fail to install from plugin dir due to no plugin executable file", func(t *testing.T) { + existedPluginFilePath := "testdata/plugins/foo/notation-foo" + newPluginFilePath := "testdata/foo/foo" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadataHigherVersion), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginDir, + } + expectedErrorMsg := "failed to read plugin from directory testdata/foo: no plugin executable file was found" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) + + t.Run("fail to install from plugin dir due to more than one plugin executable files", func(t *testing.T) { + existedPluginFilePath := "testdata/plugins/foo/notation-foo" + newPluginFilePath := "testdata/foo/notation-foo1" + newPluginFilePath2 := "testdata/foo/notation-foo2" + newPluginDir := filepath.Dir(newPluginFilePath) + if err := os.MkdirAll(newPluginDir, 0777); err != nil { + t.Fatalf("failed to create %s: %v", newPluginDir, err) + } + defer os.RemoveAll(newPluginDir) + if err := createFileAndChmod(newPluginFilePath, 0700); err != nil { + t.Fatal(err) + } + if err := createFileAndChmod(newPluginFilePath2, 0700); err != nil { + t.Fatal(err) + } + executor = testInstallCommander{ + existedPluginFilePath: existedPluginFilePath, + newPluginFilePath: newPluginFilePath, + existedPluginStdout: metadataJSON(validMetadata), + newPluginStdout: metadataJSON(validMetadataHigherVersion), + } + installOpts := CLIInstallOptions{ + PluginPath: newPluginDir, + } + expectedErrorMsg := "failed to read plugin from directory testdata/foo: found more than one plugin executable files" + _, _, err := mgr.Install(context.Background(), installOpts) + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + }) +} + func TestManager_Uninstall(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping test on Windows") + } executor = testCommander{stdout: metadataJSON(validMetadata)} mgr := NewCLIManager(mockfs.NewSysFSWithRootMock(fstest.MapFS{}, "./testdata/plugins")) if err := os.MkdirAll("./testdata/plugins/toUninstall", 0777); err != nil { @@ -120,3 +524,14 @@ func metadataJSON(m proto.GetMetadataResponse) []byte { } return d } + +func createFileAndChmod(path string, mode fs.FileMode) error { + f, err := os.Create(path) + if err != nil { + return err + } + if err := f.Chmod(mode); err != nil { + return err + } + return f.Close() +} diff --git a/plugin/manager_unix.go b/plugin/manager_unix.go index 389180fd..6d173383 100644 --- a/plugin/manager_unix.go +++ b/plugin/manager_unix.go @@ -16,8 +16,25 @@ package plugin -import "github.com/notaryproject/notation-go/plugin/proto" +import ( + "os" + + "github.com/notaryproject/notation-go/plugin/proto" +) func binName(name string) string { return proto.Prefix + name } + +// isExecutableFile checks if a file at filePath is user executable +func isExecutableFile(filePath string) (bool, error) { + fi, err := os.Stat(filePath) + if err != nil { + return false, err + } + mode := fi.Mode() + if !mode.IsRegular() { + return false, ErrNotRegularFile + } + return mode.Perm()&0100 != 0, nil +} diff --git a/plugin/manager_windows.go b/plugin/manager_windows.go index 3b5bf0be..c8504a0e 100644 --- a/plugin/manager_windows.go +++ b/plugin/manager_windows.go @@ -13,8 +13,26 @@ package plugin -import "github.com/notaryproject/notation-go/plugin/proto" +import ( + "os" + "path/filepath" + "strings" + + "github.com/notaryproject/notation-go/plugin/proto" +) func binName(name string) string { return proto.Prefix + name + ".exe" } + +// isExecutableFile checks if a file at filePath is executable +func isExecutableFile(filePath string) (bool, error) { + fi, err := os.Stat(filePath) + if err != nil { + return false, err + } + if !fi.Mode().IsRegular() { + return false, ErrNotRegularFile + } + return strings.EqualFold(filepath.Ext(filepath.Base(filePath)), ".exe"), nil +} diff --git a/plugin/plugin.go b/plugin/plugin.go index ab720e35..0afb9ea6 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -25,7 +25,9 @@ import ( "os" "os/exec" "path/filepath" + "strings" + "github.com/notaryproject/notation-go/internal/file" "github.com/notaryproject/notation-go/internal/slices" "github.com/notaryproject/notation-go/log" "github.com/notaryproject/notation-go/plugin/proto" @@ -74,7 +76,7 @@ type CLIPlugin struct { path string } -// NewCLIPlugin validate the metadata of the plugin and return a *CLIPlugin. +// NewCLIPlugin returns a *CLIPlugin. func NewCLIPlugin(ctx context.Context, name, path string) (*CLIPlugin, error) { // validate file existence fi, err := os.Stat(path) @@ -223,6 +225,17 @@ func (c execCommander) Output(ctx context.Context, name string, command proto.Co return stdout.Bytes(), nil, nil } +// ParsePluginName checks if fileName is a valid plugin file name +// and gets plugin name from it based on spec: https://github.com/notaryproject/specifications/blob/main/specs/plugin-extensibility.md#installation +func ParsePluginName(fileName string) (string, error) { + fname := file.TrimFileExtension(fileName) + pluginName, found := strings.CutPrefix(fname, proto.Prefix) + if !found { + return "", fmt.Errorf("invalid plugin executable file name. Plugin file name requires format notation-{plugin-name}, but got %s", fname) + } + return pluginName, nil +} + // validate checks if the metadata is correctly populated. func validate(metadata *proto.GetMetadataResponse) error { if metadata.Name == "" { @@ -251,3 +264,16 @@ func validate(metadata *proto.GetMetadataResponse) error { } return nil } + +// validatePluginFileExtensionAgainstOS validates if plugin executable file +// extension aligns with the runtime OS. +// +// On windows, `.exe` extension is required. +// On other OS, MUST not have the `.exe` extension. +func validatePluginFileExtensionAgainstOS(fileName, pluginName string) error { + expectedPluginFile := binName(pluginName) + if filepath.Ext(fileName) != filepath.Ext(expectedPluginFile) { + return fmt.Errorf("invalid plugin file extension. Expecting file %s, but got %s", expectedPluginFile, fileName) + } + return nil +} diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index 45a43fc4..bf592663 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "reflect" + "runtime" "strconv" "strings" "testing" @@ -270,3 +271,59 @@ func TestNewCLIPlugin_ValidError(t *testing.T) { } }) } + +func TestExtractPluginNameFromExecutableFileName(t *testing.T) { + pluginName, err := ParsePluginName("notation-my-plugin") + if err != nil { + t.Fatalf("expected nil err, got %v", err) + } + if pluginName != "my-plugin" { + t.Fatalf("expected plugin name my-plugin, but got %s", pluginName) + } + + pluginName, err = ParsePluginName("notation-my-plugin.exe") + if err != nil { + t.Fatalf("expected nil err, got %v", err) + } + if pluginName != "my-plugin" { + t.Fatalf("expected plugin name my-plugin, but got %s", pluginName) + } + + _, err = ParsePluginName("myPlugin") + expectedErrorMsg := "invalid plugin executable file name. Plugin file name requires format notation-{plugin-name}, but got myPlugin" + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expected %s, got %v", expectedErrorMsg, err) + } + + _, err = ParsePluginName("my-plugin") + expectedErrorMsg = "invalid plugin executable file name. Plugin file name requires format notation-{plugin-name}, but got my-plugin" + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expected %s, got %v", expectedErrorMsg, err) + } +} + +func TestValidatePluginFileExtensionAgainstOS(t *testing.T) { + if runtime.GOOS == "windows" { + err := validatePluginFileExtensionAgainstOS("notation-foo.exe", "foo") + if err != nil { + t.Fatalf("expecting nil error, but got %s", err) + } + + err = validatePluginFileExtensionAgainstOS("notation-foo", "foo") + expectedErrorMsg := "invalid plugin file extension. Expecting file notation-foo.exe, but got notation-foo" + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } + return + } + err := validatePluginFileExtensionAgainstOS("notation-foo", "foo") + if err != nil { + t.Fatalf("expecting nil error, but got %s", err) + } + + err = validatePluginFileExtensionAgainstOS("notation-foo.exe", "foo") + expectedErrorMsg := "invalid plugin file extension. Expecting file notation-foo, but got notation-foo.exe" + if err == nil || err.Error() != expectedErrorMsg { + t.Fatalf("expecting error %s, but got %v", expectedErrorMsg, err) + } +} diff --git a/plugin/testdata/plugins/foo/libfoo b/plugin/testdata/plugins/foo/libfoo new file mode 100644 index 00000000..e69de29b diff --git a/verifier/helpers.go b/verifier/helpers.go index dfabadb0..d432f0c1 100644 --- a/verifier/helpers.go +++ b/verifier/helpers.go @@ -18,12 +18,12 @@ import ( "crypto/x509" "errors" "fmt" - "regexp" "strings" "github.com/notaryproject/notation-core-go/signature" "github.com/notaryproject/notation-go" set "github.com/notaryproject/notation-go/internal/container" + notationsemver "github.com/notaryproject/notation-go/internal/semver" "github.com/notaryproject/notation-go/internal/slices" "github.com/notaryproject/notation-go/verifier/trustpolicy" "github.com/notaryproject/notation-go/verifier/truststore" @@ -47,9 +47,6 @@ var VerificationPluginHeaders = []string{ var errExtendedAttributeNotExist = errors.New("extended attribute not exist") -// semVerRegEx is takenfrom https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -var semVerRegEx = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) - func loadX509TrustStores(ctx context.Context, scheme signature.SigningScheme, policy *trustpolicy.TrustPolicy, x509TrustStore truststore.X509TrustStore) ([]*x509.Certificate, error) { var typeToLoad truststore.Type switch scheme { @@ -152,12 +149,8 @@ func getVerificationPluginMinVersion(signerInfo *signature.SignerInfo) (string, if strings.TrimSpace(version) == "" { return "", fmt.Errorf("%v from extended attribute is an empty string", HeaderVerificationPluginMinVersion) } - if !isVersionSemverValid(version) { + if !notationsemver.IsValid(version) { return "", fmt.Errorf("%v from extended attribute is not a valid SemVer", HeaderVerificationPluginMinVersion) } return version, nil } - -func isVersionSemverValid(version string) bool { - return semVerRegEx.MatchString(version) -} diff --git a/verifier/verifier.go b/verifier/verifier.go index bf76494e..c340f8e9 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -32,6 +32,7 @@ import ( "github.com/notaryproject/notation-go/dir" "github.com/notaryproject/notation-go/internal/envelope" "github.com/notaryproject/notation-go/internal/pkix" + notationsemver "github.com/notaryproject/notation-go/internal/semver" "github.com/notaryproject/notation-go/internal/slices" trustpolicyInternal "github.com/notaryproject/notation-go/internal/trustpolicy" "github.com/notaryproject/notation-go/log" @@ -231,7 +232,7 @@ func (v *verifier) processSignature(ctx context.Context, sigBlob []byte, envelop pluginVersion := metadata.Version //checking if the plugin version is in valid semver format - if !isVersionSemverValid(pluginVersion) { + if !notationsemver.IsValid(pluginVersion) { return notation.ErrorVerificationInconclusive{Msg: fmt.Sprintf("plugin %s has pluginVersion %s which is not in valid semver format", verificationPluginName, pluginVersion)} }