mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* fix: Suppress non-PMG error from Critical UI Error * fix: Code review fixes --------- Co-authored-by: Sahil Bansal <bansalsahil315@gmail.com>
280 lines
7.5 KiB
Go
280 lines
7.5 KiB
Go
package ui
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/safedep/pmg/analyzer"
|
||
"github.com/safedep/pmg/internal/models"
|
||
"golang.org/x/term"
|
||
)
|
||
|
||
// 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
|
||
)
|
||
|
||
type BlockConfig struct {
|
||
// ShowReference determines whether to show detailed information for suspicious packages.
|
||
// If false, the details are omitted to avoid repeating information already shown to the user.
|
||
ShowReference bool
|
||
|
||
MalwarePackages []*analyzer.PackageVersionAnalysisResult
|
||
}
|
||
|
||
func NewDefaultBlockConfig() *BlockConfig {
|
||
return &BlockConfig{
|
||
ShowReference: true,
|
||
}
|
||
}
|
||
|
||
var verbosityLevel VerbosityLevel = VerbosityLevelNormal
|
||
|
||
func SetVerbosityLevel(level VerbosityLevel) {
|
||
verbosityLevel = level
|
||
}
|
||
|
||
func ClearStatus() {
|
||
StopSpinner()
|
||
fmt.Fprint(os.Stderr, "\r")
|
||
}
|
||
|
||
func Block(config *BlockConfig) error {
|
||
return blockWithExit(config, true)
|
||
}
|
||
|
||
func BlockNoExit(config *BlockConfig) error {
|
||
return blockWithExit(config, false)
|
||
}
|
||
|
||
func blockWithExit(config *BlockConfig, exit bool) error {
|
||
StopSpinner()
|
||
|
||
// We show the block message only in normal mode to avoid repeating information
|
||
// already shown to the user in verbose mode as part of the reporting.
|
||
if verbosityLevel != VerbosityLevelVerbose {
|
||
fmt.Println()
|
||
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
||
|
||
if config.ShowReference {
|
||
printMaliciousPackagesList(config.MalwarePackages)
|
||
fmt.Println()
|
||
}
|
||
}
|
||
|
||
if exit {
|
||
os.Exit(1)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func SetStatus(status string) {
|
||
if verbosityLevel == VerbosityLevelSilent {
|
||
return
|
||
}
|
||
|
||
StopSpinner()
|
||
StartSpinnerWithColor(status, Colors.Dim)
|
||
}
|
||
|
||
// GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages.
|
||
// It reads from os.Stdin. Use GetConfirmationOnMalwareWithReader for custom input sources.
|
||
func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
||
return GetConfirmationOnMalwareWithReader(malwarePackages, os.Stdin)
|
||
}
|
||
|
||
// GetConfirmationOnMalwareWithReader prompts the user to confirm installation of suspicious packages.
|
||
// It reads from the provided reader, allowing for PTY input routing during proxy mode.
|
||
func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersionAnalysisResult, reader io.Reader) (bool, error) {
|
||
StopSpinner()
|
||
|
||
fmt.Println()
|
||
fmt.Printf("%s %s\n", Colors.Yellow("!"), Colors.Yellow(fmt.Sprintf("Suspicious package(s) detected: %d", len(malwarePackages))))
|
||
|
||
printMaliciousPackagesList(malwarePackages)
|
||
|
||
fmt.Println()
|
||
fmt.Print(Colors.Normal("Do you want to continue with the installation? (y/N) "))
|
||
|
||
// Use Scanner on the provided reader to support PTY input routing
|
||
scanner := bufio.NewScanner(reader)
|
||
if scanner.Scan() {
|
||
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||
if response == "y" || response == "yes" || (len(response) > 0 && response[0] == 'y') {
|
||
return true, nil
|
||
}
|
||
}
|
||
|
||
// Check for scanner errors, but don't treat them as fatal
|
||
if err := scanner.Err(); err != nil {
|
||
// On EOF or interrupted read, just return false (deny)
|
||
return false, nil
|
||
}
|
||
|
||
return false, nil
|
||
}
|
||
|
||
func ShowWarning(message string) {
|
||
// Print colored warning to stderr immediately - it won't be cleared by other output
|
||
fmt.Fprintf(os.Stderr, "PMG: %s\n", Colors.Red(message))
|
||
}
|
||
|
||
// Infof prints an informational message, suppressed in silent mode.
|
||
func Infof(msg string, args ...interface{}) {
|
||
if verbosityLevel == VerbosityLevelSilent {
|
||
return
|
||
}
|
||
fmt.Println(fmt.Sprintf(msg, args...))
|
||
}
|
||
|
||
// Successf prints a green success message, suppressed in silent mode.
|
||
func Successf(msg string, args ...interface{}) {
|
||
if verbosityLevel == VerbosityLevelSilent {
|
||
return
|
||
}
|
||
fmt.Printf("%s %s\n", Colors.Green("✓"), fmt.Sprintf(msg, args...))
|
||
}
|
||
|
||
// PromptInput prints a label and reads a line of visible input from stdin.
|
||
func PromptInput(label string) (string, error) {
|
||
fmt.Printf("%s %s", Colors.Cyan("›"), Colors.Bold(label))
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
if scanner.Scan() {
|
||
return strings.TrimSpace(scanner.Text()), nil
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
return "", fmt.Errorf("no input received")
|
||
}
|
||
|
||
// PromptSecret prints a label and reads input from stdin with echo disabled.
|
||
// Returns an error if stdin is not a terminal (e.g. piped input).
|
||
func PromptSecret(label string) (string, error) {
|
||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||
return "", fmt.Errorf("interactive terminal required for secure input")
|
||
}
|
||
|
||
fmt.Printf("%s %s", Colors.Cyan("▪"), Colors.Bold(label))
|
||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||
fmt.Println() // newline after hidden input
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.TrimSpace(string(raw)), nil
|
||
}
|
||
|
||
func Fatalf(msg string, args ...interface{}) {
|
||
ClearStatus()
|
||
|
||
fmt.Fprintln(os.Stderr, Colors.Red(fmt.Sprintf(msg, args...)))
|
||
os.Exit(1)
|
||
}
|
||
|
||
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
|
||
for _, mp := range malwarePackages {
|
||
fmt.Println()
|
||
fmt.Printf(" %s %s\n", Colors.Red("-"),
|
||
Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
|
||
mp.PackageVersion.GetVersion())))
|
||
|
||
if verbosityLevel == VerbosityLevelVerbose {
|
||
fmt.Printf(" %s\n", Colors.Dim(termWidthFormatText(mp.Summary, 76)))
|
||
}
|
||
|
||
if mp.ReferenceURL != "" {
|
||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
|
||
}
|
||
}
|
||
}
|
||
|
||
func printCooldownPackagesList(packages []models.CooldownBlock) {
|
||
for _, pkg := range packages {
|
||
fmt.Println()
|
||
fmt.Printf(" %s %s\n",
|
||
Colors.Yellow("⊘"),
|
||
Colors.Yellow(fmt.Sprintf("%s@%s", pkg.Name, pkg.Version)))
|
||
|
||
dateStr := ""
|
||
if !pkg.PublishDate.IsZero() {
|
||
dateStr = fmt.Sprintf(" (%s)", pkg.PublishDate.Format("2006-01-02"))
|
||
}
|
||
|
||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
|
||
"Published %s ago%s — available in %s",
|
||
pluralizeDays(pkg.DaysAgo), dateStr, pluralizeDays(pkg.DaysLeft),
|
||
)))
|
||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
|
||
"Tip: wait %s for cooldown to expire",
|
||
pluralizeDays(pkg.DaysLeft),
|
||
)))
|
||
}
|
||
}
|
||
|
||
func pluralizeDays(n int) string {
|
||
if n == 1 {
|
||
return "1 day"
|
||
}
|
||
return fmt.Sprintf("%d days", n)
|
||
}
|
||
|
||
func pluralizePackages(n int) string {
|
||
if n == 1 {
|
||
return "1 package"
|
||
}
|
||
return fmt.Sprintf("%d packages", n)
|
||
}
|
||
|
||
// 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")
|
||
}
|