Plugins: Refactor plugin download/installation (#43046)
* installer -> repo * add semver format checking * add plugin callbacks in test * remove newline * post install only scans new directories * remove unused stuff * everything in own package * add missing cli params * make grafana version part of the API * resolve conflicts * tidy up logger * fix cli and tidy log statements * rename log package * update struct name * fix linter issue * fs -> filestore * reorder imports * alias import * fix test * fix test * inline var * revert jsonc file * make repo dep of manager * actually inject the thing * accept all args for compatability checks * accept compat from store * pass os + arch vals * don't inject fs * tidy up * tidy up * merge with main and tidy fs storage * fix test * fix packages * fix comment + field name * update fs naming * fixed wire * remove unused func * fix mocks * fix storage test * renaming * fix log line * fix test * re-order field * tidying * add test for update with same version * fix wire for CLI * remove use of ioutil * don't pass field * small tidy * ignore code scanning warn * fix testdata link * update lgtm code
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/logger"
|
||||
)
|
||||
|
||||
var reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
|
||||
|
||||
var (
|
||||
ErrUninstallOutsideOfPluginDir = errors.New("cannot uninstall a plugin outside of the plugins directory")
|
||||
ErrUninstallInvalidPluginDir = errors.New("cannot recognize as plugin folder")
|
||||
)
|
||||
|
||||
type FS struct {
|
||||
store map[string]string
|
||||
mu sync.RWMutex
|
||||
pluginsDir string
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func FileSystem(logger logger.Logger, pluginsDir string) *FS {
|
||||
return &FS{
|
||||
store: make(map[string]string),
|
||||
pluginsDir: pluginsDir,
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FS) Add(ctx context.Context, pluginID string, pluginArchive *zip.ReadCloser) (
|
||||
*ExtractedPluginArchive, error) {
|
||||
pluginDir, err := fs.extractFiles(ctx, pluginArchive, pluginID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "failed to extract plugin archive", err)
|
||||
}
|
||||
|
||||
res, err := toPluginDTO(pluginID, pluginDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "failed to convert to plugin DTO", err)
|
||||
}
|
||||
|
||||
fs.log.Successf("Downloaded and extracted %s v%s zip successfully to %s", res.ID, res.Info.Version, pluginDir)
|
||||
|
||||
var deps []*Dependency
|
||||
for _, plugin := range res.Dependencies.Plugins {
|
||||
deps = append(deps, &Dependency{
|
||||
ID: plugin.ID,
|
||||
Version: plugin.Version,
|
||||
})
|
||||
}
|
||||
|
||||
fs.mu.Lock()
|
||||
fs.store[pluginID] = pluginDir
|
||||
fs.mu.Unlock()
|
||||
|
||||
return &ExtractedPluginArchive{
|
||||
ID: res.ID,
|
||||
Version: res.Info.Version,
|
||||
Dependencies: deps,
|
||||
Path: pluginDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *FS) Remove(_ context.Context, pluginID string) error {
|
||||
fs.mu.RLock()
|
||||
pluginDir, exists := fs.store[pluginID]
|
||||
fs.mu.RUnlock()
|
||||
if !exists {
|
||||
return fmt.Errorf("%s does not exist", pluginID)
|
||||
}
|
||||
|
||||
// extra security check to ensure we only remove plugins that are located in the configured plugins directory
|
||||
path, err := filepath.Rel(fs.pluginsDir, pluginDir)
|
||||
if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
|
||||
return ErrUninstallOutsideOfPluginDir
|
||||
}
|
||||
|
||||
if _, err = os.Stat(filepath.Join(pluginDir, "plugin.json")); os.IsNotExist(err) {
|
||||
if _, err = os.Stat(filepath.Join(pluginDir, "dist/plugin.json")); os.IsNotExist(err) {
|
||||
return ErrUninstallInvalidPluginDir
|
||||
}
|
||||
}
|
||||
|
||||
fs.log.Infof("Uninstalling plugin %v", pluginDir)
|
||||
return os.RemoveAll(pluginDir)
|
||||
}
|
||||
|
||||
func (fs *FS) extractFiles(_ context.Context, pluginArchive *zip.ReadCloser, pluginID string) (string, error) {
|
||||
installDir := filepath.Join(fs.pluginsDir, pluginID)
|
||||
if _, err := os.Stat(installDir); !os.IsNotExist(err) {
|
||||
fs.log.Debugf("Removing existing installation of plugin %s", installDir)
|
||||
err = os.RemoveAll(installDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := pluginArchive.Close(); err != nil {
|
||||
fs.log.Warn("failed to close zip file", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, zf := range pluginArchive.File {
|
||||
// We can ignore gosec G305 here since we check for the ZipSlip vulnerability below
|
||||
// nolint:gosec
|
||||
fullPath := filepath.Join(fs.pluginsDir, zf.Name)
|
||||
|
||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
||||
if filepath.IsAbs(zf.Name) ||
|
||||
!strings.HasPrefix(fullPath, filepath.Clean(fs.pluginsDir)+string(os.PathSeparator)) ||
|
||||
strings.HasPrefix(zf.Name, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf(
|
||||
"archive member %q tries to write outside of plugin directory: %q, this can be a security risk",
|
||||
zf.Name, fs.pluginsDir)
|
||||
}
|
||||
|
||||
dstPath := filepath.Clean(filepath.Join(fs.pluginsDir, removeGitBuildFromName(zf.Name, pluginID))) // lgtm[go/zipslip]
|
||||
|
||||
if zf.FileInfo().IsDir() {
|
||||
// We can ignore gosec G304 here since it makes sense to give all users read access
|
||||
// nolint:gosec
|
||||
if err := os.MkdirAll(dstPath, 0755); err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return "", ErrPermissionDenied{Path: dstPath}
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Create needed directories to extract file
|
||||
// We can ignore gosec G304 here since it makes sense to give all users read access
|
||||
// nolint:gosec
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("%v: %w", "failed to create directory to extract plugin files", err)
|
||||
}
|
||||
|
||||
if isSymlink(zf) {
|
||||
if err := extractSymlink(installDir, zf, dstPath); err != nil {
|
||||
fs.log.Warn("failed to extract symlink", "err", err)
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := extractFile(zf, dstPath); err != nil {
|
||||
return "", fmt.Errorf("%v: %w", "failed to extract file", err)
|
||||
}
|
||||
}
|
||||
|
||||
return installDir, nil
|
||||
}
|
||||
|
||||
func isSymlink(file *zip.File) bool {
|
||||
return file.Mode()&os.ModeSymlink == os.ModeSymlink
|
||||
}
|
||||
|
||||
func extractSymlink(basePath string, file *zip.File, filePath string) error {
|
||||
// symlink target is the contents of the file
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to extract file", err)
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err = io.Copy(buf, src); err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to copy symlink contents", err)
|
||||
}
|
||||
|
||||
symlinkPath := strings.TrimSpace(buf.String())
|
||||
if !isSymlinkRelativeTo(basePath, symlinkPath, filePath) {
|
||||
return fmt.Errorf("symlink %q pointing outside plugin directory is not allowed", filePath)
|
||||
}
|
||||
|
||||
if err = os.Symlink(symlinkPath, filePath); err != nil {
|
||||
return fmt.Errorf("failed to make symbolic link for %v: %w", filePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSymlinkRelativeTo checks whether symlinkDestPath is relative to basePath.
|
||||
// symlinkOrigPath is the path to file holding the symbolic link.
|
||||
func isSymlinkRelativeTo(basePath string, symlinkDestPath string, symlinkOrigPath string) bool {
|
||||
if filepath.IsAbs(symlinkDestPath) {
|
||||
return false
|
||||
}
|
||||
fileDir := filepath.Dir(symlinkOrigPath)
|
||||
cleanPath := filepath.Clean(filepath.Join(fileDir, "/", symlinkDestPath))
|
||||
p, err := filepath.Rel(basePath, cleanPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(filepath.Clean(p), "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func extractFile(file *zip.File, filePath string) (err error) {
|
||||
fileMode := file.Mode()
|
||||
// This is entry point for backend plugins so we want to make them executable
|
||||
if strings.HasSuffix(filePath, "_linux_amd64") || strings.HasSuffix(filePath, "_darwin_amd64") {
|
||||
fileMode = os.FileMode(0755)
|
||||
}
|
||||
|
||||
// We can ignore the gosec G304 warning on this one, since the variable part of the file path stems
|
||||
// from command line flag "pluginsDir", and the only possible damage would be writing to the wrong directory.
|
||||
// If the user shouldn't be writing to this directory, they shouldn't have the permission in the file system.
|
||||
// nolint:gosec
|
||||
dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return fmt.Errorf("could not create %q, permission denied, make sure you have write access to plugin dir", filePath)
|
||||
}
|
||||
|
||||
unwrappedError := errors.Unwrap(err)
|
||||
if unwrappedError != nil && strings.EqualFold(unwrappedError.Error(), "text file busy") {
|
||||
return fmt.Errorf("file %q is in use - please stop Grafana, install the plugin and restart Grafana", filePath)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%v: %w", "failed to open file", err)
|
||||
}
|
||||
defer func() {
|
||||
err = dst.Close()
|
||||
}()
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to extract file", err)
|
||||
}
|
||||
defer func() {
|
||||
err = src.Close()
|
||||
}()
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
return err
|
||||
}
|
||||
|
||||
func removeGitBuildFromName(filename, pluginID string) string {
|
||||
return reGitBuild.ReplaceAllString(filename, pluginID+"/")
|
||||
}
|
||||
|
||||
func toPluginDTO(pluginID, pluginDir string) (InstalledPlugin, error) {
|
||||
distPluginDataPath := filepath.Join(pluginDir, "dist", "plugin.json")
|
||||
|
||||
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
|
||||
// nolint:gosec
|
||||
data, err := os.ReadFile(distPluginDataPath)
|
||||
if err != nil {
|
||||
pluginDataPath := filepath.Join(pluginDir, "plugin.json")
|
||||
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
|
||||
// nolint:gosec
|
||||
data, err = os.ReadFile(pluginDataPath)
|
||||
if err != nil {
|
||||
return InstalledPlugin{}, fmt.Errorf("could not find dist/plugin.json or plugin.json for %s in %s", pluginID, pluginDir)
|
||||
}
|
||||
}
|
||||
|
||||
res := InstalledPlugin{}
|
||||
if err = json.Unmarshal(data, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.ID == "" {
|
||||
return InstalledPlugin{}, fmt.Errorf("could not find valid plugin %s in %s", pluginID, pluginDir)
|
||||
}
|
||||
|
||||
if res.Info.Version == "" {
|
||||
res.Info.Version = "0.0.0"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdd(t *testing.T) {
|
||||
testDir := "./testdata/tmpInstallPluginDir"
|
||||
err := os.MkdirAll(testDir, os.ModePerm)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
err = os.RemoveAll(testDir)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
pluginID := "test-app"
|
||||
|
||||
fs := FileSystem(&fakeLogger{}, testDir)
|
||||
archive, err := fs.Add(context.Background(), pluginID, zipFile(t, "./testdata/plugin-with-symlinks.zip"))
|
||||
require.NotNil(t, archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify extracted contents
|
||||
files, err := os.ReadDir(filepath.Join(testDir, pluginID))
|
||||
require.NoError(t, err)
|
||||
file2, err := files[2].Info()
|
||||
require.NoError(t, err)
|
||||
file4, err := files[4].Info()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, files, 6)
|
||||
require.Equal(t, files[0].Name(), "MANIFEST.txt")
|
||||
require.Equal(t, files[1].Name(), "dashboards")
|
||||
require.Equal(t, files[2].Name(), "extra")
|
||||
require.Equal(t, os.ModeSymlink, file2.Mode()&os.ModeSymlink)
|
||||
require.Equal(t, files[3].Name(), "plugin.json")
|
||||
require.Equal(t, files[4].Name(), "symlink_to_txt")
|
||||
require.Equal(t, os.ModeSymlink, file4.Mode()&os.ModeSymlink)
|
||||
require.Equal(t, files[5].Name(), "text.txt")
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
pluginDir := t.TempDir()
|
||||
pluginJSON := filepath.Join(pluginDir, "plugin.json")
|
||||
_, err := os.Create(pluginJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
pluginID := "test-datasource"
|
||||
i := &FS{
|
||||
pluginsDir: filepath.Dir(pluginDir),
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
|
||||
t.Run("Uninstall will search in nested dir folder for plugin.json", func(t *testing.T) {
|
||||
pluginDistDir := filepath.Join(t.TempDir(), "dist")
|
||||
err = os.Mkdir(pluginDistDir, os.ModePerm)
|
||||
require.NoError(t, err)
|
||||
pluginJSON = filepath.Join(pluginDistDir, "plugin.json")
|
||||
_, err = os.Create(pluginJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
pluginDir = filepath.Dir(pluginDistDir)
|
||||
|
||||
i = &FS{
|
||||
pluginsDir: filepath.Dir(pluginDir),
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("Uninstall will not delete folder if cannot recognize plugin structure", func(t *testing.T) {
|
||||
pluginDir = t.TempDir()
|
||||
i = &FS{
|
||||
pluginsDir: filepath.Dir(pluginDir),
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.EqualError(t, err, "cannot recognize as plugin folder")
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
require.False(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("Uninstall will not delete folder if plugin's directory is not a subdirectory of specified plugins directory", func(t *testing.T) {
|
||||
pluginDir = t.TempDir()
|
||||
i = &FS{
|
||||
pluginsDir: "/some/other/path",
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.EqualError(t, err, "cannot uninstall a plugin outside of the plugins directory")
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
require.False(t, os.IsNotExist(err))
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractFiles(t *testing.T) {
|
||||
pluginsDir := setupFakePluginsDir(t)
|
||||
|
||||
i := &FS{log: &fakeLogger{}, pluginsDir: pluginsDir}
|
||||
|
||||
t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) {
|
||||
skipWindows(t)
|
||||
|
||||
pluginID := "grafana-simple-json-datasource"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
// File in zip has permissions 755
|
||||
fileInfo, err := os.Stat(filepath.Join(pluginsDir, "grafana-simple-json-datasource", "simple-plugin_darwin_amd64"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
|
||||
|
||||
// File in zip has permission 755
|
||||
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_linux_amd64")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
|
||||
|
||||
// File in zip has permission 644
|
||||
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "-rw-r--r--", fileInfo.Mode().String())
|
||||
|
||||
// File in zip has permission 755
|
||||
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/non-plugin-binary")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
|
||||
})
|
||||
|
||||
t.Run("Should extract file with relative symlink", func(t *testing.T) {
|
||||
skipWindows(t)
|
||||
|
||||
pluginID := "plugin-with-symlink"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pluginsDir+"/plugin-with-symlink/text.txt", target)
|
||||
})
|
||||
|
||||
t.Run("Should extract directory with relative symlink", func(t *testing.T) {
|
||||
skipWindows(t)
|
||||
|
||||
pluginID := "plugin-with-symlink-dir"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink-dir.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
|
||||
require.NoError(t, err)
|
||||
|
||||
target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pluginsDir+"/plugin-with-symlink-dir/dir", target)
|
||||
})
|
||||
|
||||
t.Run("Should not extract file with absolute symlink", func(t *testing.T) {
|
||||
skipWindows(t)
|
||||
|
||||
pluginID := "plugin-with-absolute-symlink"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink/test.txt")
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("Should not extract directory with absolute symlink", func(t *testing.T) {
|
||||
skipWindows(t)
|
||||
|
||||
pluginID := "plugin-with-absolute-symlink-dir"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink-dir.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink-dir/target")
|
||||
require.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("Should detect if archive members point outside of the destination directory", func(t *testing.T) {
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-parent-member.zip"), "plugin-with-parent-member")
|
||||
require.Empty(t, path)
|
||||
require.EqualError(t, err, fmt.Sprintf(
|
||||
`archive member "../member.txt" tries to write outside of plugin directory: %q, this can be a security risk`,
|
||||
pluginsDir,
|
||||
))
|
||||
})
|
||||
|
||||
t.Run("Should detect if archive members are absolute", func(t *testing.T) {
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-member.zip"), "plugin-with-absolute-member")
|
||||
require.Empty(t, path)
|
||||
require.EqualError(t, err, fmt.Sprintf(
|
||||
`archive member "/member.txt" tries to write outside of plugin directory: %q, this can be a security risk`,
|
||||
pluginsDir,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
func zipFile(t *testing.T, zipPath string) *zip.ReadCloser {
|
||||
rc, err := zip.OpenReader(zipPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
return rc
|
||||
}
|
||||
|
||||
func TestRemoveGitBuildFromName(t *testing.T) {
|
||||
// The root directory should get renamed to the plugin name
|
||||
paths := map[string]string{
|
||||
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/": "datasource-kairosdb/",
|
||||
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/README.md": "datasource-kairosdb/README.md",
|
||||
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/": "datasource-kairosdb/partials/",
|
||||
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/config.html": "datasource-kairosdb/partials/config.html",
|
||||
}
|
||||
for p, exp := range paths {
|
||||
name := removeGitBuildFromName(p, "datasource-kairosdb")
|
||||
require.Equal(t, exp, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSymlinkRelativeTo(t *testing.T) {
|
||||
tcs := []struct {
|
||||
desc string
|
||||
basePath string
|
||||
symlinkDestPath string
|
||||
symlinkOrigPath string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file within basePath should return true",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "test.txt",
|
||||
symlinkOrigPath: "/dir/sub-dir/test1.txt",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file within basePath should return true",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "test.txt",
|
||||
symlinkOrigPath: "/dir/test1.txt",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file within basePath should return true",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "../etc/test.txt",
|
||||
symlinkOrigPath: "/dir/sub-dir/test1.txt",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to absolute directory outside basePath should return false",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "/etc/test.txt",
|
||||
symlinkOrigPath: "/dir/sub-dir/test1.txt",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file outside basePath should return false",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "../../etc/test.txt",
|
||||
symlinkOrigPath: "/dir/sub-dir/test1.txt",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file outside basePath should return false",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "../../",
|
||||
symlinkOrigPath: "/dir/sub-sir/symlink.txt",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file outside basePath should return false",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "../..",
|
||||
symlinkOrigPath: "/dir/sub-sir/symlink.txt",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file outside basePath should return false",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "../../",
|
||||
symlinkOrigPath: "/dir/sub-sir/",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
desc: "Symbolic link pointing to relative file outside basePath should return false",
|
||||
basePath: "/dir",
|
||||
symlinkDestPath: "../..",
|
||||
symlinkOrigPath: "/dir/sub-sir/",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
actual := isSymlinkRelativeTo(tc.basePath, tc.symlinkDestPath, tc.symlinkOrigPath)
|
||||
require.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupFakePluginsDir(t *testing.T) string {
|
||||
dir := "testdata/fake-plugins-dir"
|
||||
err := os.RemoveAll(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(dir, 0750)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err = os.RemoveAll(dir)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
dir, err = filepath.Abs(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func skipWindows(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping test on Windows")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLogger struct{}
|
||||
|
||||
func (f *fakeLogger) Successf(_ string, _ ...interface{}) {}
|
||||
func (f *fakeLogger) Failuref(_ string, _ ...interface{}) {}
|
||||
func (f *fakeLogger) Info(_ ...interface{}) {}
|
||||
func (f *fakeLogger) Infof(_ string, _ ...interface{}) {}
|
||||
func (f *fakeLogger) Debug(_ ...interface{}) {}
|
||||
func (f *fakeLogger) Debugf(_ string, _ ...interface{}) {}
|
||||
func (f *fakeLogger) Warn(_ ...interface{}) {}
|
||||
func (f *fakeLogger) Warnf(_ string, _ ...interface{}) {}
|
||||
func (f *fakeLogger) Error(_ ...interface{}) {}
|
||||
func (f *fakeLogger) Errorf(_ string, _ ...interface{}) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
)
|
||||
|
||||
type Manager interface {
|
||||
Add(ctx context.Context, pluginID string, rc *zip.ReadCloser) (*ExtractedPluginArchive, error)
|
||||
Remove(ctx context.Context, pluginID string) error
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package storage
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ErrPermissionDenied struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e ErrPermissionDenied) Error() string {
|
||||
return fmt.Sprintf("could not create %q, permission denied, make sure you have write access to plugin dir", e.Path)
|
||||
}
|
||||
|
||||
type ExtractedPluginArchive struct {
|
||||
ID string
|
||||
Version string
|
||||
Dependencies []*Dependency
|
||||
Path string
|
||||
}
|
||||
|
||||
type Dependency struct {
|
||||
ID string
|
||||
Version string
|
||||
}
|
||||
|
||||
type InstalledPlugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Info PluginInfo `json:"info"`
|
||||
Dependencies Dependencies `json:"dependencies"`
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
GrafanaVersion string `json:"grafanaVersion"`
|
||||
Plugins []PluginDependency `json:"plugins"`
|
||||
}
|
||||
|
||||
type PluginDependency struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Version string `json:"version"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user