diff --git a/cmd/root.go b/cmd/root.go index 71856ba..aee1fd6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,8 @@ var defaultDirs = []string{} var triggerInterval int var colored bool var debug bool +var ppid bool +var cmdLength int func init() { rootCmd.PersistentFlags().BoolVarP(&logPS, "procevents", "p", true, "print new processes to stdout") @@ -70,6 +72,8 @@ func init() { rootCmd.PersistentFlags().IntVarP(&triggerInterval, "interval", "i", 100, "scan every 'interval' milliseconds for new processes") rootCmd.PersistentFlags().BoolVarP(&colored, "color", "c", true, "color the printed events") rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "", false, "print detailed error messages") + rootCmd.PersistentFlags().BoolVarP(&ppid, "ppid", "", false, "record process ppids") + rootCmd.PersistentFlags().IntVarP(&cmdLength, "truncate", "t", 2048, "truncate process cmds longer than this") log.SetOutput(os.Stdout) } @@ -91,7 +95,7 @@ func root(cmd *cobra.Command, args []string) { fsw := fswatcher.NewFSWatcher() defer fsw.Close() - pss := psscanner.NewPSScanner() + pss := psscanner.NewPSScanner(ppid, cmdLength) sigCh := make(chan os.Signal) signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) diff --git a/internal/pspy/pspy.go b/internal/pspy/pspy.go index 70cd7c6..233ec58 100644 --- a/internal/pspy/pspy.go +++ b/internal/pspy/pspy.go @@ -38,9 +38,16 @@ type chans struct { func Start(cfg *config.Config, b *Bindings, sigCh chan os.Signal) chan struct{} { b.Logger.Infof("Config: %+v", cfg) + abort := make(chan struct{}, 1) + abort <- struct{}{} - initFSW(b.FSW, cfg.RDirs, cfg.Dirs, b.Logger) - triggerCh, fsEventCh := startFSW(b.FSW, b.Logger, cfg.DrainFor) + if !initFSW(b.FSW, cfg.RDirs, cfg.Dirs, b.Logger, sigCh) { + return abort + } + triggerCh, fsEventCh, ok := startFSW(b.FSW, b.Logger, cfg.DrainFor, sigCh) + if !ok { + return abort + } psEventCh := startPSS(b.PSS, b.Logger, triggerCh) @@ -83,27 +90,29 @@ func printOutput(cfg *config.Config, b *Bindings, chans *chans) chan struct{} { return exit } -func initFSW(fsw FSWatcher, rdirs, dirs []string, logger Logger) { +func initFSW(fsw FSWatcher, rdirs, dirs []string, logger Logger, sigCh <-chan os.Signal) bool { errCh, doneCh := fsw.Init(rdirs, dirs) for { select { + case <-sigCh: + return false case <-doneCh: - return + return true case err := <-errCh: logger.Errorf(true, "initializing fs watcher: %v", err) } } } -func startFSW(fsw FSWatcher, logger Logger, drainFor time.Duration) (triggerCh chan struct{}, fsEventCh chan string) { +func startFSW(fsw FSWatcher, logger Logger, drainFor time.Duration, sigCh <-chan os.Signal) (triggerCh chan struct{}, fsEventCh chan string, ok bool) { triggerCh, fsEventCh, errCh := fsw.Run() go logErrors(errCh, logger) // ignore all file system events created on startup logger.Infof("Draining file system events due to startup...") - drainEventsFor(triggerCh, fsEventCh, drainFor) + ok = drainEventsFor(triggerCh, fsEventCh, drainFor, sigCh) logger.Infof("done") - return triggerCh, fsEventCh + return } func startPSS(pss PSScanner, logger Logger, triggerCh chan struct{}) (psEventCh chan psscanner.PSEvent) { @@ -128,13 +137,15 @@ func logErrors(errCh chan error, logger Logger) { } } -func drainEventsFor(triggerCh chan struct{}, eventCh chan string, d time.Duration) { +func drainEventsFor(triggerCh chan struct{}, eventCh chan string, d time.Duration, sigCh <-chan os.Signal) bool { for { select { + case <-sigCh: + return false case <-triggerCh: case <-eventCh: case <-time.After(d): - return + return true } } } diff --git a/internal/pspy/pspy_test.go b/internal/pspy/pspy_test.go index f66aefe..b6080e5 100644 --- a/internal/pspy/pspy_test.go +++ b/internal/pspy/pspy_test.go @@ -17,24 +17,55 @@ func TestInitFSW(t *testing.T) { fsw := newMockFSWatcher() rdirs := make([]string, 0) dirs := make([]string, 0) + sigCh := make(chan os.Signal) go func() { fsw.initErrCh <- errors.New("error1") fsw.initErrCh <- errors.New("error2") close(fsw.initDoneCh) }() - initFSW(fsw, rdirs, dirs, l) + if !initFSW(fsw, rdirs, dirs, l, sigCh) { + t.Error("unexpected return value") + } expectMessage(t, l.Error, "initializing fs watcher: error1") expectMessage(t, l.Error, "initializing fs watcher: error2") expectClosed(t, fsw.initDoneCh) } +func TestInitFSWInterrupt(t *testing.T) { + l := newMockLogger() + fsw := newMockFSWatcher() + rdirs := make([]string, 0) + dirs := make([]string, 0) + sigCh := make(chan os.Signal, 0) + done := make(chan struct{}) + + go func() { + <-time.After(100 * time.Millisecond) + sigCh <- os.Interrupt + }() + + go func() { + if initFSW(fsw, rdirs, dirs, l, sigCh) { + t.Error("unexpected return value") + } + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(1 * time.Second): + t.Error("timout") + } +} + // very flaky test... refactor code! func TestStartFSW(t *testing.T) { l := newMockLogger() fsw := newMockFSWatcher() drainFor := 100 * time.Millisecond + sigCh := make(chan os.Signal) go func() { fsw.runTriggerCh <- struct{}{} // trigger sent while draining @@ -47,7 +78,10 @@ func TestStartFSW(t *testing.T) { }() // sends no events and triggers from the drain phase - triggerCh, fsEventCh := startFSW(fsw, l, drainFor) + triggerCh, fsEventCh, ok := startFSW(fsw, l, drainFor, sigCh) + if !ok { + t.Error("unexpected return value") + } expectMessage(t, l.Info, "Draining file system events due to startup...") expectMessage(t, l.Error, "ERROR: error sent while draining") expectMessage(t, l.Info, "done") @@ -55,6 +89,32 @@ func TestStartFSW(t *testing.T) { expectMessage(t, fsEventCh, "event sent after draining") } +func TestStartFSWInterrupt(t *testing.T) { + l := newMockLogger() + fsw := newMockFSWatcher() + drainFor := 500 * time.Millisecond + sigCh := make(chan os.Signal) + done := make(chan struct{}) + + go func() { + <-time.After(100 * time.Millisecond) + sigCh <- os.Interrupt + }() + + go func() { + if _, _, ok := startFSW(fsw, l, drainFor, sigCh); ok { + t.Error("unexpected return value") + } + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("timout") + } +} + func TestStartPSS(t *testing.T) { pss := newMockPSScanner() l := newMockLogger() @@ -95,7 +155,7 @@ func TestStart(t *testing.T) { close(fsw.initDoneCh) <-time.After(2 * drainFor) fsw.runTriggerCh <- struct{}{} - pss.runEventCh <- psscanner.PSEvent{UID: 1000, PID: 12345, CMD: "pss event"} + pss.runEventCh <- psscanner.PSEvent{UID: 1000, PID: 12345, PPID: 54321, CMD: "pss event"} pss.runErrCh <- errors.New("pss error") fsw.runEventCh <- "fsw event" fsw.runErrCh <- errors.New("fsw error") @@ -108,7 +168,7 @@ func TestStart(t *testing.T) { <-time.After(2 * drainFor) expectMessage(t, l.Info, "done") expectTrigger(t, pss.runTriggerCh) // pss receives triggers from fsw - expectMessage(t, l.Event, fmt.Sprintf("%d CMD: UID=1000 PID=12345 | pss event", logging.ColorPurple)) + expectMessage(t, l.Event, fmt.Sprintf("%d CMD: UID=1000 PID=12345 PPID=54321 | pss event", logging.ColorPurple)) expectMessage(t, l.Error, "ERROR: pss error") expectMessage(t, l.Event, fmt.Sprintf("%d FS: fsw event", logging.ColorNone)) expectMessage(t, l.Error, "ERROR: fsw error") diff --git a/internal/psscanner/proclist.go b/internal/psscanner/proclist.go index 0de01fa..ea6c316 100644 --- a/internal/psscanner/proclist.go +++ b/internal/psscanner/proclist.go @@ -1,31 +1,19 @@ package psscanner import ( - "errors" "fmt" - "io/ioutil" + "io" "os" "strconv" - "strings" ) -var procDirReader = func() ([]os.FileInfo, error) { - return ioutil.ReadDir("/proc") +type procList map[int]struct{} + +type pidProcessor interface { + processNewPid(pid int) } -var procStatusReader = func(pid int) ([]byte, error) { - statPath := fmt.Sprintf("/proc/%d/status", pid) - return ioutil.ReadFile(statPath) -} - -var cmdLineReader = func(pid int) ([]byte, error) { - cmdPath := fmt.Sprintf("/proc/%d/cmdline", pid) - return ioutil.ReadFile(cmdPath) -} - -type procList map[int]string - -func (pl procList) refresh(eventCh chan PSEvent) error { +func (pl procList) refresh(p pidProcessor) error { pids, err := getPIDs() if err != nil { return err @@ -35,7 +23,8 @@ func (pl procList) refresh(eventCh chan PSEvent) error { pid := pids[i] _, ok := pl[pid] if !ok { - pl.addPid(pid, eventCh) + p.processNewPid(pid) + pl[pid] = struct{}{} } } @@ -43,15 +32,21 @@ func (pl procList) refresh(eventCh chan PSEvent) error { } func getPIDs() ([]int, error) { - proc, err := procDirReader() + f, err := dirOpen("/proc") if err != nil { return nil, fmt.Errorf("opening proc dir: %v", err) } + defer f.Close() + + names, err := f.Readdirnames(-1) + if err != nil { + return nil, fmt.Errorf("reading proc dir: %v", err) + } pids := make([]int, 0) - for _, f := range proc { - pid, err := file2Pid(f) - if err != nil { + for _, f := range names { + pid, err := strconv.Atoi(f) + if err != nil || pid <= 0 { continue } pids = append(pids, pid) @@ -59,67 +54,11 @@ func getPIDs() ([]int, error) { return pids, nil } -var errNotAPid = errors.New("not a pid") - -func file2Pid(f os.FileInfo) (int, error) { - if !f.IsDir() { - return -1, errNotAPid - } - - pid, err := strconv.Atoi(f.Name()) - if err != nil || pid <= 0 { - return -1, errNotAPid - } - - return pid, nil +type readDirNamesCloser interface { + Readdirnames(n int) (names []string, err error) + io.Closer } -func (pl procList) addPid(pid int, eventCh chan PSEvent) { - cmd, err := getCmd(pid) - if err != nil { - cmd = "???" // process probably terminated - } - uid, err := getUID(pid) - if err != nil { - uid = -1 - } - eventCh <- PSEvent{UID: uid, PID: pid, CMD: cmd} - pl[pid] = cmd -} - -func getCmd(pid int) (string, error) { - cmd, err := cmdLineReader(pid) - if err != nil { - return "", err - } - for i := 0; i < len(cmd); i++ { - if cmd[i] == 0 { - cmd[i] = 32 - } - } - return string(cmd), nil -} - -func getUID(pid int) (int, error) { - stat, err := procStatusReader(pid) - if err != nil { - return -1, err - } - - lines := strings.Split(string(stat), "\n") - if len(lines) < 9 { - return -1, fmt.Errorf("no uid information") - } - - uidL := strings.Split(lines[8], "\t") - if len(uidL) < 2 { - return -1, fmt.Errorf("uid line read incomplete") - } - - uid, err := strconv.Atoi(uidL[1]) - if err != nil { - return -1, fmt.Errorf("converting %s to int: %v", uidL[1], err) - } - - return uid, nil +var dirOpen func(string) (readDirNamesCloser, error) = func(s string) (readDirNamesCloser, error) { + return os.Open(s) } diff --git a/internal/psscanner/proclist_test.go b/internal/psscanner/proclist_test.go index 6761244..4ec0881 100644 --- a/internal/psscanner/proclist_test.go +++ b/internal/psscanner/proclist_test.go @@ -1,257 +1,233 @@ package psscanner import ( - "encoding/hex" "errors" "fmt" - "os" "reflect" + "strings" "testing" - "time" ) -// GetCmd - -func TestGetCmd(t *testing.T) { - tests := []struct { - pid int - cmdLine []byte - cmdErr error - cmd string - err string - }{ - {pid: 1, cmdLine: []byte("abc"), cmdErr: nil, cmd: "abc", err: ""}, - {pid: 1, cmdLine: []byte(""), cmdErr: nil, cmd: "", err: ""}, // can work with empty result - {pid: 1, cmdLine: []byte("abc\x00123"), cmdErr: nil, cmd: "abc 123", err: ""}, // turns null bytes into spaces - {pid: 1, cmdLine: []byte("abc"), cmdErr: errors.New("file-system-error"), cmd: "", err: "file-system-error"}, // returns error from file reader - } - - for _, tt := range tests { - restore := mockCmdLineReader(tt.cmdLine, tt.cmdErr) - cmd, err := getCmd(tt.pid) - if cmd != tt.cmd { - t.Errorf("Wrong cmd line returned: got %s but want %s", cmd, tt.cmd) - } - if (err != nil || tt.err != "") && fmt.Sprintf("%v", err) != tt.err { - t.Errorf("Wrong error returned: got %v but want %s", err, tt.err) - } - restore() - } -} - -func mockCmdLineReader(cmdLine []byte, err error) (restore func()) { - oldFunc := cmdLineReader - cmdLineReader = func(pid int) ([]byte, error) { - return cmdLine, err - } - return func() { - cmdLineReader = oldFunc - } -} - // GetPIDs func TestGetPIDs(t *testing.T) { tests := []struct { - proc []os.FileInfo - procErr error - pids []int - err string + name string + proc []string + procErrOpen error + procErrRead error + pids []int + err string }{ - {proc: []os.FileInfo{newMockDir("42"), newMockDir("somedir")}, procErr: nil, pids: []int{42}, err: ""}, // reads numbers and ignores everything else - {proc: []os.FileInfo{newMockDir("42"), newMockFile("13")}, procErr: nil, pids: []int{42}, err: ""}, // reads directories and ignores files - {proc: []os.FileInfo{newMockDir("0"), newMockDir("-1")}, procErr: nil, pids: []int{}, err: ""}, // ignores 0 and negative numbers - {proc: []os.FileInfo{}, procErr: nil, pids: []int{}, err: ""}, // can handle empty procfs - {proc: []os.FileInfo{}, procErr: errors.New("file-system-error"), pids: nil, err: "opening proc dir: file-system-error"}, // returns errors + { + name: "numbers-only", + proc: []string{"42", "somedir"}, + procErrOpen: nil, + procErrRead: nil, + pids: []int{42}, + err: "", + }, + { + name: "multiple-entries", + proc: []string{"42", "13"}, + procErrOpen: nil, + procErrRead: nil, + pids: []int{42, 13}, + err: "", + }, + { + name: "ignores-lte-0", + proc: []string{"0", "-1"}, + procErrOpen: nil, + procErrRead: nil, + pids: []int{}, + err: "", + }, + { + name: "empty-procfs", + proc: []string{}, + procErrOpen: nil, + procErrRead: nil, + pids: []int{}, + err: "", + }, + { + name: "handle-open-error", + proc: []string{}, + procErrOpen: errors.New("file-system-error"), + procErrRead: nil, + pids: nil, + err: "opening proc dir: file-system-error", + }, + { + name: "handle-read-error", + proc: []string{}, + procErrOpen: nil, + procErrRead: errors.New("file-system-error"), + pids: nil, + err: "reading proc dir: file-system-error", + }, } for _, tt := range tests { - restore := mockProcDirReader(tt.proc, tt.procErr) - pids, err := getPIDs() - if !reflect.DeepEqual(pids, tt.pids) { - t.Errorf("Wrong pids returned: got %v but want %v", pids, tt.pids) - } - if (err != nil || tt.err != "") && fmt.Sprintf("%v", err) != tt.err { - t.Errorf("Wrong error returned: got %v but want %s", err, tt.err) - } - restore() + t.Run(tt.name, func(t *testing.T) { + defer mockDir("/proc", tt.proc, tt.procErrRead, tt.procErrOpen, t)() + pids, err := getPIDs() + if !reflect.DeepEqual(pids, tt.pids) { + t.Errorf("Wrong pids returned: got %v but want %v", pids, tt.pids) + } + if (err != nil || tt.err != "") && fmt.Sprintf("%v", err) != tt.err { + t.Errorf("Wrong error returned: got %v but want %s", err, tt.err) + } + }) } } -func mockProcDirReader(proc []os.FileInfo, err error) (restore func()) { - oldFunc := procDirReader - procDirReader = func() ([]os.FileInfo, error) { - return proc, err - } - return func() { - procDirReader = oldFunc - } +type MockDir struct { + names []string + err error } -func newMockDir(name string) *mockFileInfo { - return &mockFileInfo{ - name: name, - isDir: true, - } -} - -func newMockFile(name string) *mockFileInfo { - return &mockFileInfo{ - name: name, - isDir: false, - } -} - -type mockFileInfo struct { - name string - isDir bool -} - -func (f *mockFileInfo) Name() string { - return f.name -} -func (f *mockFileInfo) Size() int64 { - return 0 -} -func (f *mockFileInfo) Mode() os.FileMode { - return 0 -} -func (f *mockFileInfo) ModTime() time.Time { - return time.Now() -} -func (f *mockFileInfo) IsDir() bool { - return f.isDir -} -func (f *mockFileInfo) Sys() interface{} { +func (f *MockDir) Close() error { return nil } -// GetUID - -func TestGetUID(t *testing.T) { - completeStatus, _ := hex.DecodeString("4e616d653a0963726f6e0a556d61736b3a09303032320a53746174653a09532028736c656570696e67290a546769643a09370a4e6769643a09300a5069643a09370a505069643a09350a5472616365725069643a09300a5569643a09300930093009300a4769643a09300930093009300a464453697a653a0936340a47726f7570733a0930200a4e53746769643a09370a4e537069643a09370a4e53706769643a09310a4e537369643a09310a566d5065616b3a092020203238303132206b420a566d53697a653a092020203237393932206b420a566d4c636b3a092020202020202030206b420a566d50696e3a092020202020202030206b420a566d48574d3a092020202032333532206b420a566d5253533a092020202032333532206b420a527373416e6f6e3a092020202020323430206b420a52737346696c653a092020202032313132206b420a52737353686d656d3a092020202020202030206b420a566d446174613a092020202020333430206b420a566d53746b3a092020202020313332206b420a566d4578653a092020202020203434206b420a566d4c69623a092020202032383536206b420a566d5054453a092020202020203736206b420a566d504d443a092020202020203132206b420a566d537761703a092020202020202030206b420a48756765746c6250616765733a092020202020202030206b420a546872656164733a09310a536967513a09302f34373834320a536967506e643a09303030303030303030303030303030300a536864506e643a09303030303030303030303030303030300a536967426c6b3a09303030303030303030303030303030300a53696749676e3a09303030303030303030303030303030360a5369674367743a09303030303030303138303031303030310a436170496e683a09303030303030303061383034323566620a43617050726d3a09303030303030303061383034323566620a4361704566663a09303030303030303061383034323566620a436170426e643a09303030303030303061383034323566620a436170416d623a09303030303030303030303030303030300a536563636f6d703a09320a437075735f616c6c6f7765643a09330a437075735f616c6c6f7765645f6c6973743a09302d310a4d656d735f616c6c6f7765643a09310a4d656d735f616c6c6f7765645f6c6973743a09300a766f6c756e746172795f637478745f73776974636865733a0932350a6e6f6e766f6c756e746172795f637478745f73776974636865733a09310a") - uidLineBroken, _ := hex.DecodeString("4e616d653a0963726f6e0a556d61736b3a09303032320a53746174653a09532028736c656570696e67290a546769643a09370a4e6769643a09300a5069643a09370a505069643a09350a5472616365725069643a09300a5569643a") - notEnoughLines, _ := hex.DecodeString("4e616d653a0963726f6e0a556d61736b3a09303032320a537461") - tests := []struct { - pid int - stat []byte - statErr error - uid int - err string - }{ - {pid: 7, stat: completeStatus, statErr: nil, uid: 0, err: ""}, // can read normal stat - {pid: 7, stat: uidLineBroken, statErr: nil, uid: -1, err: "uid line read incomplete"}, // errors on incomplete Uid line - {pid: 7, stat: notEnoughLines, statErr: nil, uid: -1, err: "no uid information"}, // errors for insufficient lines - {pid: 7, stat: []byte(""), statErr: nil, uid: -1, err: "no uid information"}, // errors for insufficient lines - {pid: 7, stat: []byte(""), statErr: errors.New("file-system-error"), uid: -1, err: "file-system-error"}, // returns file system errors - } - - for _, tt := range tests { - restore := mockProcStatusReader(tt.stat, tt.statErr) - uid, err := getUID(tt.pid) - if uid != tt.uid { - fmt.Printf("STAT: %s", tt.stat) - t.Errorf("Wrong uid returned: got %d but want %d", uid, tt.uid) - } - if (err != nil || tt.err != "") && fmt.Sprintf("%v", err) != tt.err { - t.Errorf("Wrong error returned: got %v but want %s", err, tt.err) - } - restore() +func min(a, b int) int { + if a > b { + return b } + return a } -func mockProcStatusReader(stat []byte, err error) (restore func()) { - oldFunc := procStatusReader - procStatusReader = func(pid int) ([]byte, error) { - return stat, err +func (f *MockDir) Readdirnames(n int) (names []string, err error) { + if n < 0 { + return f.names, f.err + } + return f.names[:min(n, len(f.names))], f.err +} + +// Hook/chain a mocked file into the "open" variable +func mockDir(name string, names []string, errRead error, errOpen error, t *testing.T) func() { + oldopen := dirOpen + dirOpen = func(n string) (readDirNamesCloser, error) { + if name == n { + if testing.Verbose() { + t.Logf("opening mocked dir: %s", n) + } + return &MockDir{ + names: names, + err: errRead, + }, errOpen + } + return oldopen(n) } return func() { - procStatusReader = oldFunc + dirOpen = oldopen } } -// refresh +type mockPidProcessor struct { + t *testing.T + pids []int +} + +func (m *mockPidProcessor) processNewPid(pid int) { + if testing.Verbose() { + m.t.Logf("proc %d processed", pid) + } + m.pids = append(m.pids, pid) +} + +var unit = struct{}{} func TestRefresh(t *testing.T) { tests := []struct { - eventCh chan PSEvent - pl procList - newPids []int - pidsAfter []int - events []string + name string + pl procList + newPids []int + plAfter procList + pidsProcessed []int }{ - {eventCh: make(chan PSEvent), pl: procList{}, newPids: []int{1, 2, 3}, pidsAfter: []int{3, 2, 1}, events: []string{ - "UID=??? PID=3 | the-command", - "UID=??? PID=2 | the-command", - "UID=??? PID=1 | the-command", - }}, - {eventCh: make(chan PSEvent), pl: procList{1: "pid-found-before"}, newPids: []int{1, 2, 3}, pidsAfter: []int{1, 3, 2}, events: []string{ - "UID=??? PID=3 | the-command", - "UID=??? PID=2 | the-command", - }}, // no events emitted for PIDs already known + { + name: "nominal", + pl: procList{}, + newPids: []int{1, 2, 3}, + plAfter: procList{1: unit, 2: unit, 3: unit}, + pidsProcessed: []int{3, 2, 1}, + }, + { + name: "merge", + pl: procList{1: unit}, + newPids: []int{1, 2, 3}, + plAfter: procList{1: unit, 2: unit, 3: unit}, + pidsProcessed: []int{3, 2}, + }, + { + name: "nothing-new", + pl: procList{1: unit, 2: unit, 3: unit}, + newPids: []int{1, 2, 3}, + plAfter: procList{1: unit, 2: unit, 3: unit}, + pidsProcessed: []int{}, + }, } for _, tt := range tests { - restoreGetPIDs := mockPidList(tt.newPids) - restoreCmdLineReader := mockCmdLineReader([]byte("the-command"), nil) - restoreProcStatusReader := mockProcStatusReader([]byte(""), nil) // don't mock read value since it's not worth it + t.Run(tt.name, func(t *testing.T) { + defer mockPidList(tt.newPids, t)() - events := make([]string, 0) - done := make(chan struct{}) - go func() { - for e := range tt.eventCh { - events = append(events, e.String()) + m := &mockPidProcessor{t, []int{}} + tt.pl.refresh(m) + + if !reflect.DeepEqual(m.pids, tt.pidsProcessed) { + t.Errorf("Unexpected pids got processed: got %v but want %v", m.pids, tt.pidsProcessed) } - done <- struct{}{} - }() - tt.pl.refresh(tt.eventCh) - close(tt.eventCh) - <-done - - restoreProcStatusReader() - restoreCmdLineReader() - restoreGetPIDs() - - pidsAfter := getPids(&tt.pl) - - for _, pid := range tt.pidsAfter { - if !contains(pidsAfter, pid) { - t.Errorf("PID %d should be in list %v but was not!", pid, pidsAfter) + if !reflect.DeepEqual(tt.pl, tt.plAfter) { + t.Errorf("Unexpected pids stored in procList: got %v but want %v", tt.pl, tt.plAfter) } - } - for _, pid := range pidsAfter { - if !contains(tt.pidsAfter, pid) { - t.Errorf("PID %d should be in list %v but was not!", pid, pidsAfter) - } - } - if !reflect.DeepEqual(events, tt.events) { - t.Errorf("Wrong events returned: got %v but want %v", events, tt.events) - } + }) } } -func contains(list []int, v int) bool { - for _, i := range list { - if i == v { - return true - } +// separate test for failing, only one case where getPids fails +func TestRefreshFail(t *testing.T) { + e := errors.New("file-system-error") + for _, tt := range []struct { + name string + errRead error + errOpen error + }{ + { + name: "open-dir-fail", + errRead: nil, + errOpen: e, + }, + { + name: "read-dir-fail", + errRead: e, + errOpen: nil, + }, + } { + t.Run(tt.name, func(t *testing.T) { + defer mockDir("/proc", []string{}, tt.errRead, tt.errOpen, t)() + m := &mockPidProcessor{t, []int{}} + pl := procList{1: unit} + err := pl.refresh(m) + if err == nil { + t.Errorf("Expected an error") + } else { + if strings.Index(err.Error(), e.Error()) == -1 { + t.Errorf("Unexpected error: %v", err) + } + } + + }) } - return false } -func mockPidList(pids []int) func() { - dirs := make([]os.FileInfo, 0) +func mockPidList(pids []int, t *testing.T) func() { + dirs := make([]string, 0) for _, pid := range pids { - dirs = append(dirs, newMockDir(fmt.Sprintf("%d", pid))) + dirs = append(dirs, fmt.Sprintf("%d", pid)) } - restore := mockProcDirReader(dirs, nil) - return restore -} - -func getPids(pl *procList) []int { - pids := make([]int, 0) - for pid := range *pl { - pids = append(pids, pid) - } - return pids + return mockDir("/proc", dirs, nil, nil, t) } diff --git a/internal/psscanner/psscanner.go b/internal/psscanner/psscanner.go index 4fb9dda..15dc0cb 100644 --- a/internal/psscanner/psscanner.go +++ b/internal/psscanner/psscanner.go @@ -1,16 +1,26 @@ package psscanner import ( + "errors" "fmt" + "io" + "os" + "regexp" "strconv" + "syscall" ) -type PSScanner struct{} +type PSScanner struct { + enablePpid bool + eventCh chan<- PSEvent + maxCmdLength int +} type PSEvent struct { - UID int - PID int - CMD string + UID int + PID int + PPID int + CMD string } func (evt PSEvent) String() string { @@ -19,23 +29,100 @@ func (evt PSEvent) String() string { uid = "???" } - return fmt.Sprintf("UID=%-4s PID=%-6d | %s", uid, evt.PID, evt.CMD) + if evt.PPID == -1 { + return fmt.Sprintf("UID=%-5s PID=%-6d | %s", uid, evt.PID, evt.CMD) + } + + return fmt.Sprintf( + "UID=%-5s PID=%-6d PPID=%-6d | %s", uid, evt.PID, evt.PPID, evt.CMD) } -func NewPSScanner() *PSScanner { - return &PSScanner{} +var ( + // identify ppid in stat file + ppidRegex, _ = regexp.Compile("\\d+ \\(.*\\) [[:alpha:]] (\\d+)") + // hook for testing, directly use Lstat syscall as os.Lstat hides data in Sys member + lstat = syscall.Lstat + // hook for testing + open = func(s string) (io.ReadCloser, error) { + return os.Open(s) + } +) + +func NewPSScanner(ppid bool, cmdLength int) *PSScanner { + return &PSScanner{ + enablePpid: ppid, + eventCh: nil, + maxCmdLength: cmdLength, + } } func (p *PSScanner) Run(triggerCh chan struct{}) (chan PSEvent, chan error) { eventCh := make(chan PSEvent, 100) + p.eventCh = eventCh errCh := make(chan error) pl := make(procList) go func() { for { <-triggerCh - pl.refresh(eventCh) + pl.refresh(p) } }() return eventCh, errCh } + +func (p *PSScanner) processNewPid(pid int) { + statInfo := syscall.Stat_t{} + errStat := lstat(fmt.Sprintf("/proc/%d", pid), &statInfo) + cmdLine, errCmdLine := readFile(fmt.Sprintf("/proc/%d/cmdline", pid), p.maxCmdLength) + ppid, _ := p.getPpid(pid) + + cmd := "???" // process probably terminated + if errCmdLine == nil { + for i := 0; i < len(cmdLine); i++ { + if cmdLine[i] == 0 { + cmdLine[i] = 32 + } + } + cmd = string(cmdLine) + } + + uid := -1 + if errStat == nil { + uid = int(statInfo.Uid) + } + + p.eventCh <- PSEvent{UID: uid, PID: pid, PPID: ppid, CMD: cmd} +} + +func (p *PSScanner) getPpid(pid int) (int, error) { + if !p.enablePpid { + return -1, nil + } + + stat, err := readFile(fmt.Sprintf("/proc/%d/stat", pid), 512) + if err != nil { + return -1, err + } + + if m := ppidRegex.FindStringSubmatch(string(stat)); m != nil { + return strconv.Atoi(m[1]) + } + return -1, errors.New("corrupt stat file") +} + +// no nonsense file reading +func readFile(filename string, maxlen int) ([]byte, error) { + file, err := open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + buffer := make([]byte, maxlen) + n, err := file.Read(buffer) + if err != io.EOF && err != nil { + return nil, err + } + return buffer[:n], nil +} diff --git a/internal/psscanner/psscanner_test.go b/internal/psscanner/psscanner_test.go index 773cff8..005b5ca 100644 --- a/internal/psscanner/psscanner_test.go +++ b/internal/psscanner/psscanner_test.go @@ -1,63 +1,671 @@ package psscanner import ( + //"encoding/hex" + "errors" + "fmt" + "io" + "reflect" + "syscall" "testing" "time" ) const timeout = 100 * time.Millisecond -// refresh - func TestRun(t *testing.T) { tests := []struct { + name string pids []int events []string }{ - {pids: []int{1, 2, 3}, events: []string{ - "UID=??? PID=3 | the-command", - "UID=??? PID=2 | the-command", - "UID=??? PID=1 | the-command", - }}, + { + name: "nominal", + pids: []int{1, 2, 3}, + events: []string{ + "UID=??? PID=3 | the-command", + "UID=??? PID=2 | the-command", + "UID=??? PID=1 | the-command", + }, + }, } for _, tt := range tests { - restoreGetPIDs := mockPidList(tt.pids) - restoreCmdLineReader := mockCmdLineReader([]byte("the-command"), nil) - restoreProcStatusReader := mockProcStatusReader([]byte(""), nil) // don't mock read value since it's not worth it - - pss := NewPSScanner() - triggerCh := make(chan struct{}) - eventCh, errCh := pss.Run(triggerCh) - - // does nothing without triggering - select { - case e := <-eventCh: - t.Errorf("Received event before trigger: %s", e) - case err := <-errCh: - t.Errorf("Received error before trigger: %v", err) - case <-time.After(timeout): - // ok - } - - triggerCh <- struct{}{} - - // received event after the trigger - for i := 0; i < 3; i++ { - select { - case <-time.After(timeout): - t.Errorf("did not receive event in time") - case e := <-eventCh: - if e.String() != tt.events[i] { - t.Errorf("Wrong event received: got '%s' but wanted '%s'", e, tt.events[i]) - } - case err := <-errCh: - t.Errorf("Received unexpected error: %v", err) + t.Run(tt.name, func(t *testing.T) { + defer mockPidList(tt.pids, t)() + for _, pid := range tt.pids { + defer mockPidCmdLine(pid, []byte("the-command"), nil, nil, t)() + defer mockPidUid(pid, 0, errors.New("file not found"), t)() } - } - restoreProcStatusReader() - restoreCmdLineReader() - restoreGetPIDs() + pss := NewPSScanner(false, 2048) + triggerCh := make(chan struct{}) + eventCh, errCh := pss.Run(triggerCh) + + // does nothing without triggering + select { + case e := <-eventCh: + t.Errorf("Received event before trigger: %s", e) + case err := <-errCh: + t.Errorf("Received error before trigger: %v", err) + case <-time.After(timeout): + // ok + } + + triggerCh <- struct{}{} + + // received event after the trigger + for i := 0; i < 3; i++ { + select { + case <-time.After(timeout): + t.Errorf("did not receive event in time") + case e := <-eventCh: + if e.String() != tt.events[i] { + t.Errorf("Wrong event received: got '%s' but wanted '%s'", e, tt.events[i]) + } + case err := <-errCh: + t.Errorf("Received unexpected error: %v", err) + } + } + }) + } +} + +var ( + completeStat = []byte("1314 (some proc with) odd chars)) in name) R 5560 1314 5560 34821 1314 4194304 82 0 0 0 0 0 0 0 20 0 1 0 15047943 7790592 196 18446744073709551615 94260770430976 94260770462160 140725974097504 0 0 0 0 0 0 0 0 0 17 1 0 0 0 0 0 94260772559472 94260772561088 94260783992832 140725974106274 140725974106294 140725974106294 140725974110191 0\n") + partialStat = []byte("1314 (ps) ") + invalidPpid = []byte("1314 (ps) R XYZ 1314 5560 34821 1314 4194304 82 0 0 0 0 0 0 0 20 0 1 0 15047943 7790592 196 18446744073709551615 94260770430976 94260770462160 140725974097504 0 0 0 0 0 0 0 0 0 17 1 0 0 0 0 0 94260772559472 94260772561088 94260783992832 140725974106274 140725974106294 140725974106294 140725974110191 0\n") +) + +func TestProcessNewPid(t *testing.T) { + tests := []struct { + name string + enablePpid bool + truncate int + pid int + cmdLine []byte + cmdLineErrRead error + cmdLineErrOpen error + stat []byte + statErrRead error + statErrOpen error + lstatUid uint32 + lstatErr error + expected PSEvent + }{ + { + name: "nominal-no-ppid", + enablePpid: false, + truncate: 100, + pid: 1, + cmdLine: []byte("abc\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 1, + PPID: -1, + CMD: "abc 123", + }, + }, + { + name: "nominal-ppid", + enablePpid: true, + truncate: 100, + pid: 1, + cmdLine: []byte("abc\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 999, + lstatErr: nil, + expected: PSEvent{ + UID: 999, + PID: 1, + PPID: 5560, + CMD: "abc 123", + }, + }, + { + name: "empty-cmd-ok", + enablePpid: true, + truncate: 100, + pid: 1, + cmdLine: []byte{}, + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 1, + PPID: 5560, + CMD: "", + }, + }, + { + name: "cmd-truncate", + enablePpid: false, + truncate: 10, + pid: 1, + cmdLine: []byte("abc\x00123\x00alpha"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 1, + PPID: -1, + CMD: "abc 123 al", + }, + }, + { + name: "cmd-io-error", + enablePpid: true, + truncate: 100, + pid: 2, + cmdLine: nil, + cmdLineErrRead: errors.New("file-system-error"), + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 2, + PPID: 5560, + CMD: "???", + }, + }, + { + name: "cmd-io-error2", + enablePpid: true, + truncate: 100, + pid: 2, + cmdLine: nil, + cmdLineErrRead: nil, + cmdLineErrOpen: errors.New("file-system-error"), + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 2, + PPID: 5560, + CMD: "???", + }, + }, + { + name: "stat-io-error", + enablePpid: true, + truncate: 100, + pid: 2, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: nil, + statErrRead: errors.New("file-system-error"), + statErrOpen: nil, + lstatUid: 321, + lstatErr: nil, + expected: PSEvent{ + UID: 321, + PID: 2, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "stat-io-error2", + enablePpid: true, + truncate: 100, + pid: 2, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: nil, + statErrRead: nil, + statErrOpen: errors.New("file-system-error"), + lstatUid: 4454, + lstatErr: nil, + expected: PSEvent{ + UID: 4454, + PID: 2, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "lstat-fail", + enablePpid: false, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: errors.New("file not found"), + expected: PSEvent{ + UID: -1, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "lstat-with-ppid", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: completeStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: errors.New("file not found"), + expected: PSEvent{ + UID: -1, + PID: 3, + PPID: 5560, + CMD: "some cmd 123", + }, + }, + { + name: "stat-too-short", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: partialStat, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 66, + lstatErr: nil, + expected: PSEvent{ + UID: 66, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "stat-bad-ppid", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: invalidPpid, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 66, + lstatErr: nil, + expected: PSEvent{ + UID: 66, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "stat-empty", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: []byte{}, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 88, + lstatErr: nil, + expected: PSEvent{ + UID: 88, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + /*{ + name: "uid-line-too-short", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: uidLineBroken, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: -1, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "uid-parse-error", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: uidNaN, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: -1, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "ppid-line-too-short", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: ppidLineShort, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: -1, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "ppid-parse-error", + enablePpid: true, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: ppidNaN, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: -1, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "no-ppid-line-too-short", + enablePpid: false, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: ppidLineShort, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + }, + { + name: "no-ppid-parse-error", + enablePpid: false, + truncate: 100, + pid: 3, + cmdLine: []byte("some\x00cmd\x00123"), + cmdLineErrRead: nil, + cmdLineErrOpen: nil, + stat: ppidNaN, + statErrRead: nil, + statErrOpen: nil, + lstatUid: 0, + lstatErr: nil, + expected: PSEvent{ + UID: 0, + PID: 3, + PPID: -1, + CMD: "some cmd 123", + }, + },*/ + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer mockPidCmdLine(tt.pid, tt.cmdLine, tt.cmdLineErrRead, tt.cmdLineErrOpen, t)() + defer mockPidStat(tt.pid, tt.stat, tt.statErrRead, tt.statErrOpen, t)() + defer mockPidUid(tt.pid, tt.lstatUid, tt.lstatErr, t)() + + results := make(chan PSEvent, 1) + + scanner := &PSScanner{ + enablePpid: tt.enablePpid, + eventCh: results, + maxCmdLength: tt.truncate, + } + + go func() { + scanner.processNewPid(tt.pid) + }() + + select { + case <-time.After(timeout): + t.Error("Timeout waiting for event") + case event := <-results: + close(results) + if testing.Verbose() { + t.Logf("received event: %#v", event) + } + if !reflect.DeepEqual(event, tt.expected) { + t.Errorf("Event received but format is has unexpected values: got %#v but want %#v", event, tt.expected) + } + } + }) + } +} + +func mockPidStat(pid int, stat []byte, errRead error, errOpen error, t *testing.T) func() { + return mockFile(fmt.Sprintf("/proc/%d/stat", pid), stat, errRead, errOpen, t) +} + +func mockPidCmdLine(pid int, cmdline []byte, errRead error, errOpen error, t *testing.T) func() { + return mockFile(fmt.Sprintf("/proc/%d/cmdline", pid), cmdline, errRead, errOpen, t) +} + +type MockFile struct { + content []byte + err error +} + +func (f *MockFile) Close() error { + return nil +} + +func (f *MockFile) Read(p []byte) (int, error) { + return copy(p, f.content), f.err +} + +// Hook/chain a mocked file into the "open" variable +func mockFile(name string, content []byte, errRead error, errOpen error, t *testing.T) func() { + oldopen := open + open = func(n string) (io.ReadCloser, error) { + if name == n { + if testing.Verbose() { + t.Logf("opening mocked file: %s", n) + } + return &MockFile{ + content: content, + err: errRead, + }, errOpen + } + return oldopen(n) + } + return func() { + open = oldopen + } +} + +func mockPidUid(pid int, uid uint32, err error, t *testing.T) func() { + return mockLStat(fmt.Sprintf("/proc/%d", pid), uid, err, t) +} + +func mockLStat(name string, uid uint32, err error, t *testing.T) func() { + oldlstat := lstat + lstat = func(path string, stat *syscall.Stat_t) error { + if path == name { + if testing.Verbose() { + t.Logf("mocking lstat for %s", name) + } + stat.Uid = uid + return err + } + return oldlstat(path, stat) + } + return func() { + lstat = oldlstat + } +} + +func TestNewPSScanner(t *testing.T) { + for _, tt := range []struct { + name string + ppid bool + cmdlen int + }{ + { + name: "without-ppid", + ppid: false, + cmdlen: 30, + }, + { + name: "with-ppid", + ppid: true, + cmdlen: 5000, + }, + } { + t.Run(tt.name, func(t *testing.T) { + expected := &PSScanner{ + enablePpid: tt.ppid, + eventCh: nil, + maxCmdLength: tt.cmdlen, + } + new := NewPSScanner(tt.ppid, tt.cmdlen) + + if !reflect.DeepEqual(new, expected) { + t.Errorf("Unexpected scanner initialisation state: got %#v but want %#v", new, expected) + } + + }) + } +} + +func TestPSEvent(t *testing.T) { + tests := []struct { + name string + uid int + pid int + ppid int + cmd string + expected string + }{ + { + name: "nominal-with-ppid", + uid: 999, + pid: 123, + ppid: 321, + cmd: "some cmd", + expected: "UID=999 PID=123 PPID=321 | some cmd", + }, + { + name: "nominal-without-ppid", + uid: 999, + pid: 123, + ppid: -1, + cmd: "some cmd", + expected: "UID=999 PID=123 | some cmd", + }, + { + name: "nocmd-without-ppid", + uid: 999, + pid: 123, + ppid: -1, + cmd: "", + expected: "UID=999 PID=123 | ", + }, + { + name: "nocmd-with-ppid", + uid: 999, + pid: 123, + ppid: 321, + cmd: "", + expected: "UID=999 PID=123 PPID=321 | ", + }, + { + name: "nouid", + uid: -1, + pid: 123, + ppid: 321, + cmd: "some cmd", + expected: "UID=??? PID=123 PPID=321 | some cmd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ps := PSEvent{ + UID: tt.uid, + PID: tt.pid, + PPID: tt.ppid, + CMD: tt.cmd, + } + if ps.String() != tt.expected { + t.Errorf("Expecting \"%s\", got \"%s\"", tt.expected, ps.String()) + } + }) } }