feat(proxy): report blocked packages on stop, exit 1 if any were blocked

pmg proxy stop now waits for the proxy process to exit, reads the final
blocked count written to the state file on shutdown, and exits non-zero
when one or more packages were blocked during the session.

This gives CI a clear failure signal from the proxy itself, separate
from the package manager's own exit code.
This commit is contained in:
Sahilb315
2026-06-23 17:07:07 +05:30
parent e19aa274f1
commit ea2c7e8242
5 changed files with 63 additions and 19 deletions
+11 -13
View File
@@ -75,19 +75,13 @@ jobs:
echo "SUCCESS: lodash installed through proxy"
cd .. && rm -rf benign-test
- name: Malicious package is blocked
- name: Malicious package is blocked (npm exits non-zero)
run: |
mkdir malicious-test && cd malicious-test
npm init -y
if npm --no-cache --prefer-online install safedep-test-pkg@0.1.3; then
echo "ERROR: safedep-test-pkg was not blocked!"
exit 1
fi
if [ -d "node_modules/safedep-test-pkg" ]; then
echo "ERROR: safedep-test-pkg found in node_modules!"
exit 1
fi
echo "SUCCESS: safedep-test-pkg blocked by persistent proxy"
npm --no-cache --prefer-online install safedep-test-pkg@0.1.3 && exit 1 || true
test ! -d node_modules/safedep-test-pkg
echo "SUCCESS: npm correctly failed to install safedep-test-pkg"
cd .. && rm -rf malicious-test
- name: pip installs through proxy
@@ -97,6 +91,10 @@ jobs:
python -c "import requests; print('pip ok:', requests.__version__)"
deactivate && rm -rf venv
- name: Stop proxy
if: always()
run: pmg proxy stop || true
- name: Stop proxy (exits 1 when packages were blocked)
run: |
if pmg proxy stop; then
echo "ERROR: pmg proxy stop should have exited non-zero (packages were blocked)"
exit 1
fi
echo "SUCCESS: pmg proxy stop correctly reported blocked packages"
+7 -1
View File
@@ -106,7 +106,13 @@ func runStart(_ *cobra.Command, _ []string) error {
<-sigCh
close(confirmationChan)
_ = proxystate.Remove(statePath)
// Write final blocked count before exiting so `pmg proxy stop` can read it.
// stop is responsible for removing the state file.
state.BlockedCount = stats.GetStats().BlockedCount
if werr := proxystate.Write(statePath, state); werr != nil {
log.Warnf("failed to write final proxy state: %v", werr)
}
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+38 -2
View File
@@ -4,12 +4,20 @@ import (
"fmt"
"os"
"syscall"
"time"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/errcodes"
"github.com/safedep/pmg/internal/proxystate"
"github.com/spf13/cobra"
)
const (
stopPollInterval = 200 * time.Millisecond
stopPollTimeout = 10 * time.Second
)
func newStopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop",
@@ -41,8 +49,36 @@ func runStop(_ *cobra.Command, _ []string) error {
return fmt.Errorf("send SIGTERM to proxy (pid %d): %w", state.PID, err)
}
if _, err := fmt.Fprintf(os.Stdout, "Sent SIGTERM to PMG proxy (pid %d, addr %s)\n", state.PID, state.Addr); err != nil {
return fmt.Errorf("write stop message: %w", err)
// Wait for the process to exit so we can read the final blocked count it writes.
deadline := time.Now().Add(stopPollTimeout)
for time.Now().Before(deadline) {
if !state.IsRunning() {
break
}
time.Sleep(stopPollInterval)
}
// Read the final state the proxy wrote on shutdown (has BlockedCount).
final, rerr := proxystate.Read(statePath)
_ = proxystate.Remove(statePath)
if rerr != nil {
// Proxy exited but didn't write final state (e.g. crash). Treat as clean.
if _, werr := fmt.Fprintf(os.Stdout, "PMG proxy (pid %d) stopped\n", state.PID); werr != nil {
return werr
}
return nil
}
if _, werr := fmt.Fprintf(os.Stdout, "PMG proxy stopped — analyzed packages, %d blocked\n", final.BlockedCount); werr != nil {
return werr
}
if final.BlockedCount > 0 {
return usefulerror.NewUsefulError().
WithCode(errcodes.ProxyPackagesBlocked).
WithMsg(fmt.Sprintf("%d package(s) were blocked by the proxy", final.BlockedCount)).
WithHelp("Review the proxy logs for details on blocked packages")
}
return nil
+3
View File
@@ -24,6 +24,9 @@ const (
CertTrustStore = "CertTrustStore"
UnsupportedPlatform = "UnsupportedPlatform"
// Proxy error codes.
ProxyPackagesBlocked = "ProxyPackagesBlocked"
// Unknown mirrors the default code that dry/usefulerror returns for errors
// created without an explicit code, so unset and explicitly-unknown errors
// classify identically (e.g. the bug-report hint in ui.ErrorExit).
+4 -3
View File
@@ -11,9 +11,10 @@ import (
const stateFileName = "proxy-state.json"
type State struct {
PID int `json:"pid"`
Addr string `json:"addr"`
CACertPath string `json:"ca_cert_path"`
PID int `json:"pid"`
Addr string `json:"addr"`
CACertPath string `json:"ca_cert_path"`
BlockedCount int `json:"blocked_count"`
}
func StatePath(configDir string) string {