mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Refactor PMG to Maintain Separation of Concerns and Clean Architecture (#19)
* feat: Add separate package manager and resolver * fix: Npm dependency resolver * feat: Add analyzer for malysis query * feat: Add package manager guard as the orchestrator * feat: Add PMG to orchestrate installation * Add concurrent scan execution * Introduce package manager interaction abstraction * feat: Add UI port for guard * Remove refactored source files * Update README * fix: CI script for multi-arch build * ci: goreleaser CI fix * fix: npm command parser to extract package names * feat: Introduce global config primitive * fix: Close results channel for clean goroutine exit * ci: Add container image releaser * test: Improve test for npm resolver * refactor: Analyzer to generalise * Improve UI with additional info * fix: Goreleaser config * fix: npm resolver bug * fix: Fail when command exec workflow fails * fix: Bug with transitive dependency resolution * fix: Synchronize common data update in dependency resolver * chore: Improve log handling * docs: Update README * fix: UI text wrapping * fix: UI handling bugs * feat: Use concurrent dependency resolver
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package ui
|
||||
|
||||
import "github.com/fatih/color"
|
||||
|
||||
type ColorFn func(format string, a ...interface{}) string
|
||||
|
||||
type TerminalColors struct {
|
||||
Normal ColorFn
|
||||
Red ColorFn
|
||||
Yellow ColorFn
|
||||
Cyan ColorFn
|
||||
Green ColorFn
|
||||
}
|
||||
|
||||
var Colors = TerminalColors{
|
||||
Normal: color.New().SprintfFunc(),
|
||||
Red: color.New(color.FgRed, color.Bold).SprintfFunc(),
|
||||
Yellow: color.New(color.FgYellow).SprintfFunc(),
|
||||
Cyan: color.New(color.FgCyan).SprintfFunc(),
|
||||
Green: color.New(color.FgGreen).SprintfFunc(),
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var spinnerChan chan bool
|
||||
|
||||
func StartSpinner(msg string) {
|
||||
StartSpinnerWithColor(msg, Colors.Normal)
|
||||
}
|
||||
|
||||
func StartSpinnerWithColor(msg string, c ColorFn) {
|
||||
if c == nil {
|
||||
c = Colors.Normal
|
||||
}
|
||||
|
||||
style := `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
|
||||
frames := []rune(style)
|
||||
length := len(frames)
|
||||
|
||||
spinnerChan = make(chan bool)
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
go func() {
|
||||
pos := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-spinnerChan:
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
fmt.Printf("\r%s ... %s", c(msg), string(frames[pos%length]))
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func StopSpinner() {
|
||||
// Gracefully handle the case where the spinner is already stopped
|
||||
// and the channel is closed, yet client code calls StopSpinner() again.
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
|
||||
close(spinnerChan)
|
||||
|
||||
fmt.Printf("\r")
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
)
|
||||
|
||||
// The UI is internal to PMG and opinionated for the CLI.
|
||||
// It is not intended to be used outside of PMG.
|
||||
|
||||
type VerbosityLevel int
|
||||
|
||||
const (
|
||||
// PMG is hidden from the user except for errors
|
||||
// and when malicious packages are detected
|
||||
VerbosityLevelSilent VerbosityLevel = iota
|
||||
|
||||
// Show minimal status updates
|
||||
VerbosityLevelNormal
|
||||
|
||||
// Show verbose status updates and information including
|
||||
// information about malicious packages
|
||||
VerbosityLevelVerbose
|
||||
)
|
||||
|
||||
var verbosityLevel VerbosityLevel = VerbosityLevelNormal
|
||||
|
||||
func SetVerbosityLevel(level VerbosityLevel) {
|
||||
verbosityLevel = level
|
||||
}
|
||||
|
||||
func ClearStatus() {
|
||||
StopSpinner()
|
||||
fmt.Print("\r")
|
||||
}
|
||||
|
||||
func Block(malwarePackages ...*analyzer.PackageVersionAnalysisResult) error {
|
||||
StopSpinner()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Red("❌ Malicious package blocked!"))
|
||||
|
||||
printMaliciousPackagesList(malwarePackages)
|
||||
|
||||
fmt.Println()
|
||||
os.Exit(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetStatus(status string) {
|
||||
if verbosityLevel == VerbosityLevelSilent {
|
||||
return
|
||||
}
|
||||
|
||||
StopSpinner()
|
||||
StartSpinnerWithColor(fmt.Sprintf("ℹ️ %s", status), Colors.Green)
|
||||
}
|
||||
|
||||
func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
||||
StopSpinner()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Red(fmt.Sprintf("🚨 Suspicious package(s) detected: %d", len(malwarePackages))))
|
||||
|
||||
printMaliciousPackagesList(malwarePackages)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) "))
|
||||
|
||||
var response string
|
||||
|
||||
// We don't care about the error here because we will return false
|
||||
// if the user doesn't provide a valid response
|
||||
_, _ = fmt.Scanln(&response)
|
||||
|
||||
if len(response) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
response = strings.ToLower(response)
|
||||
if response == "y" || response == "yes" || response[0] == 'y' {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func Fatalf(msg string, args ...interface{}) {
|
||||
ClearStatus()
|
||||
|
||||
fmt.Println(Colors.Red(fmt.Sprintf(msg, args...)))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
|
||||
for _, mp := range malwarePackages {
|
||||
fmt.Println()
|
||||
fmt.Println("⚠️ ", Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
|
||||
mp.PackageVersion.GetVersion())))
|
||||
|
||||
if verbosityLevel == VerbosityLevelVerbose {
|
||||
fmt.Println(Colors.Yellow(termWidthFormatText(mp.Summary, 80)))
|
||||
|
||||
if mp.ReferenceURL != "" {
|
||||
fmt.Println()
|
||||
fmt.Println(Colors.Yellow(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format the string to be maximum maxWidth. Use newlines to wrap the text.
|
||||
func termWidthFormatText(text string, maxWidth int) string {
|
||||
// Replace all newlines with spaces so that we can split the text into words
|
||||
// This is to ensure that we don't split the text at the newlines
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
|
||||
words := strings.Split(text, " ")
|
||||
lines := []string{}
|
||||
currentLine := ""
|
||||
|
||||
for i, word := range words {
|
||||
// Skip empty words that might result from multiple spaces
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
// First word doesn't need a leading space
|
||||
currentLine = word
|
||||
} else if len(currentLine)+len(word)+1 > maxWidth {
|
||||
// +1 for the space we would add
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = word
|
||||
} else {
|
||||
currentLine += " " + word
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget to add the last line
|
||||
if currentLine != "" {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTermWidthFormatText is exported for testing
|
||||
func TestTermWidthFormatTextFunc(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
maxWidth int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
text: "",
|
||||
maxWidth: 10,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "single word less than max width",
|
||||
text: "hello",
|
||||
maxWidth: 10,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "single word longer than max width",
|
||||
text: "supercalifragilisticexpialidocious",
|
||||
maxWidth: 10,
|
||||
expected: "supercalifragilisticexpialidocious",
|
||||
},
|
||||
{
|
||||
name: "multiple words on single line",
|
||||
text: "hello world",
|
||||
maxWidth: 20,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "multiple words wrapped to multiple lines",
|
||||
text: "The quick brown fox jumps over the lazy dog",
|
||||
maxWidth: 20,
|
||||
expected: "The quick brown fox\njumps over the lazy\ndog",
|
||||
},
|
||||
{
|
||||
name: "text with existing newlines",
|
||||
text: "hello\nworld",
|
||||
maxWidth: 20,
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "text with multiple spaces",
|
||||
text: "hello world test",
|
||||
maxWidth: 20,
|
||||
expected: "hello world test",
|
||||
},
|
||||
{
|
||||
name: "very small max width",
|
||||
text: "hello world",
|
||||
maxWidth: 3,
|
||||
expected: "hello\nworld",
|
||||
},
|
||||
{
|
||||
name: "large max width",
|
||||
text: "The quick brown fox jumps over the lazy dog",
|
||||
maxWidth: 100,
|
||||
expected: "The quick brown fox jumps over the lazy dog",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := termWidthFormatText(tt.text, tt.maxWidth)
|
||||
if result != tt.expected {
|
||||
t.Errorf("termWidthFormatText() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user