Skip to content

Commit

Permalink
feat: add install method to plugin CLIManager (#364)
Browse files Browse the repository at this point in the history
Signed-off-by: Patrick Zheng <[email protected]>
  • Loading branch information
Two-Hearts authored Dec 18, 2023
1 parent 966c6b7 commit 85a5bb9
Show file tree
Hide file tree
Showing 14 changed files with 1,135 additions and 23 deletions.
93 changes: 92 additions & 1 deletion internal/file/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
179 changes: 179 additions & 0 deletions internal/file/file_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
49 changes: 49 additions & 0 deletions internal/semver/semver.go
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions internal/semver/semver_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading

0 comments on commit 85a5bb9

Please sign in to comment.