mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
The spinner goroutine was writing carriage-return + status text to os.Stdout every 100ms. In the non-interactive TTY path, the child process also writes directly to os.Stdout, causing both writers to race on the same file descriptor. The \r emitted by the spinner resets the cursor to column 0 mid-line, corrupting and truncating the child process output. Fix by writing all spinner/status output to os.Stderr, which is the standard Unix convention for diagnostic and status messages. This is also consistent with how progress.go and ShowWarning already behave. https://claude.ai/code/session_012jMiRSS4Jx9Bs7a2F4S6KN Co-authored-by: Claude <noreply@anthropic.com>
196 lines
5.2 KiB
Go
196 lines
5.2 KiB
Go
package ui
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"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
|
|
)
|
|
|
|
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))
|
|
}
|
|
|
|
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.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)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|