Repository: pranshuparmar/witr Branch: main Commit: e81f0fbf75a0 Files: 925 Total size: 11.7 MB Directory structure: gitextract_4kvh9lo7/ ├── .agent/ │ └── workflows/ │ └── validate-pr-check.md ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── e2e-tests.yml │ ├── pr-check.yml │ ├── release.yml │ └── update-docs.yml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── cmd/ │ └── witr/ │ ├── main.go │ └── unsupported.go ├── docs/ │ └── cli/ │ ├── witr.1 │ └── witr.md ├── flake.nix ├── go.mod ├── go.sum ├── install.ps1 ├── install.sh ├── internal/ │ ├── app/ │ │ └── app.go │ ├── launchd/ │ │ └── plist.go │ ├── output/ │ │ ├── children.go │ │ ├── colors.go │ │ ├── docker.go │ │ ├── docker_test.go │ │ ├── envonly.go │ │ ├── json.go │ │ ├── printer.go │ │ ├── safe_writer.go │ │ ├── sanitize.go │ │ ├── sanitize_test.go │ │ ├── short.go │ │ ├── standard.go │ │ └── tree.go │ ├── pipeline/ │ │ └── analyze.go │ ├── proc/ │ │ ├── ancestry.go │ │ ├── boot_darwin.go │ │ ├── boot_freebsd.go │ │ ├── boot_linux.go │ │ ├── boot_windows.go │ │ ├── capabilities_linux.go │ │ ├── children_unix.go │ │ ├── children_windows.go │ │ ├── cmdline_darwin.go │ │ ├── cmdline_freebsd.go │ │ ├── cmdline_linux.go │ │ ├── cmdline_windows.go │ │ ├── command.go │ │ ├── command_test.go │ │ ├── container.go │ │ ├── container_detect.go │ │ ├── container_test.go │ │ ├── docker_proxy.go │ │ ├── extended_darwin.go │ │ ├── extended_darwin_test.go │ │ ├── extended_freebsd.go │ │ ├── extended_linux.go │ │ ├── extended_windows.go │ │ ├── fd_darwin.go │ │ ├── fd_freebsd.go │ │ ├── fd_linux.go │ │ ├── filecontext_darwin.go │ │ ├── filecontext_freebsd.go │ │ ├── filecontext_linux.go │ │ ├── filecontext_windows.go │ │ ├── git.go │ │ ├── libproc_darwin_cgo.go │ │ ├── libproc_darwin_stub.go │ │ ├── libproc_darwin_test.go │ │ ├── net_darwin.go │ │ ├── net_freebsd.go │ │ ├── net_linux.go │ │ ├── net_linux_test.go │ │ ├── net_windows.go │ │ ├── peb_windows.go │ │ ├── process_darwin.go │ │ ├── process_darwin_test.go │ │ ├── process_freebsd.go │ │ ├── process_linux.go │ │ ├── process_list_darwin.go │ │ ├── process_list_freebsd.go │ │ ├── process_list_linux.go │ │ ├── process_list_windows.go │ │ ├── process_windows.go │ │ ├── psenv_unix.go │ │ ├── resource_darwin.go │ │ ├── resource_freebsd.go │ │ ├── resource_linux.go │ │ ├── resource_windows.go │ │ ├── socketstate_darwin.go │ │ ├── socketstate_freebsd.go │ │ ├── socketstate_linux.go │ │ ├── socketstate_windows.go │ │ ├── sort.go │ │ ├── systemd_linux.go │ │ ├── systemd_stub.go │ │ ├── user_darwin.go │ │ ├── user_freebsd.go │ │ ├── user_linux.go │ │ └── user_windows.go │ ├── source/ │ │ ├── bsdrc_darwin.go │ │ ├── bsdrc_freebsd.go │ │ ├── bsdrc_linux.go │ │ ├── bsdrc_windows.go │ │ ├── container.go │ │ ├── cron.go │ │ ├── detect.go │ │ ├── detect_test.go │ │ ├── init.go │ │ ├── launchd_darwin.go │ │ ├── launchd_freebsd.go │ │ ├── launchd_linux.go │ │ ├── launchd_windows.go │ │ ├── network.go │ │ ├── service_other.go │ │ ├── service_windows.go │ │ ├── shell.go │ │ ├── ssh.go │ │ ├── supervisor.go │ │ ├── systemd_darwin.go │ │ ├── systemd_freebsd.go │ │ ├── systemd_linux.go │ │ └── systemd_windows.go │ ├── target/ │ │ ├── file_darwin.go │ │ ├── file_freebsd.go │ │ ├── file_linux.go │ │ ├── file_windows.go │ │ ├── name_darwin.go │ │ ├── name_freebsd.go │ │ ├── name_linux.go │ │ ├── name_windows.go │ │ ├── port_darwin.go │ │ ├── port_freebsd.go │ │ ├── port_linux.go │ │ ├── port_windows.go │ │ ├── resolve.go │ │ └── resolve_test.go │ ├── tools/ │ │ └── docgen/ │ │ └── main.go │ ├── tui/ │ │ ├── actions.go │ │ ├── actions_windows.go │ │ ├── data.go │ │ ├── helpers.go │ │ ├── model.go │ │ ├── mouse.go │ │ ├── update.go │ │ └── view.go │ └── version/ │ ├── VERSION │ └── version.go ├── pkg/ │ └── model/ │ ├── docker.go │ ├── filecontext.go │ ├── net.go │ ├── process.go │ ├── resource.go │ ├── result.go │ ├── socket.go │ ├── source.go │ └── target.go └── vendor/ ├── github.com/ │ ├── atotto/ │ │ └── clipboard/ │ │ ├── .travis.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── clipboard.go │ │ ├── clipboard_darwin.go │ │ ├── clipboard_plan9.go │ │ ├── clipboard_unix.go │ │ └── clipboard_windows.go │ ├── aymanbagabas/ │ │ └── go-osc52/ │ │ └── v2/ │ │ ├── LICENSE │ │ ├── README.md │ │ └── osc52.go │ ├── charmbracelet/ │ │ ├── bubbles/ │ │ │ ├── LICENSE │ │ │ ├── cursor/ │ │ │ │ └── cursor.go │ │ │ ├── help/ │ │ │ │ └── help.go │ │ │ ├── key/ │ │ │ │ └── key.go │ │ │ ├── runeutil/ │ │ │ │ └── runeutil.go │ │ │ ├── table/ │ │ │ │ └── table.go │ │ │ ├── textinput/ │ │ │ │ └── textinput.go │ │ │ └── viewport/ │ │ │ ├── keymap.go │ │ │ └── viewport.go │ │ ├── bubbletea/ │ │ │ ├── .gitattributes │ │ │ ├── .gitignore │ │ │ ├── .golangci.yml │ │ │ ├── .goreleaser.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── Taskfile.yaml │ │ │ ├── commands.go │ │ │ ├── exec.go │ │ │ ├── focus.go │ │ │ ├── inputreader_other.go │ │ │ ├── inputreader_windows.go │ │ │ ├── key.go │ │ │ ├── key_other.go │ │ │ ├── key_sequences.go │ │ │ ├── key_windows.go │ │ │ ├── logging.go │ │ │ ├── mouse.go │ │ │ ├── nil_renderer.go │ │ │ ├── options.go │ │ │ ├── renderer.go │ │ │ ├── screen.go │ │ │ ├── signals_unix.go │ │ │ ├── signals_windows.go │ │ │ ├── standard_renderer.go │ │ │ ├── tea.go │ │ │ ├── tea_init.go │ │ │ ├── tty.go │ │ │ ├── tty_unix.go │ │ │ └── tty_windows.go │ │ ├── colorprofile/ │ │ │ ├── .golangci.yml │ │ │ ├── .goreleaser.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── doc.go │ │ │ ├── env.go │ │ │ ├── env_other.go │ │ │ ├── env_windows.go │ │ │ ├── profile.go │ │ │ └── writer.go │ │ ├── lipgloss/ │ │ │ ├── .gitignore │ │ │ ├── .golangci.yml │ │ │ ├── .goreleaser.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── Taskfile.yaml │ │ │ ├── align.go │ │ │ ├── ansi_unix.go │ │ │ ├── ansi_windows.go │ │ │ ├── borders.go │ │ │ ├── color.go │ │ │ ├── get.go │ │ │ ├── join.go │ │ │ ├── position.go │ │ │ ├── ranges.go │ │ │ ├── renderer.go │ │ │ ├── runes.go │ │ │ ├── set.go │ │ │ ├── size.go │ │ │ ├── style.go │ │ │ ├── unset.go │ │ │ └── whitespace.go │ │ └── x/ │ │ ├── ansi/ │ │ │ ├── LICENSE │ │ │ ├── ansi.go │ │ │ ├── ascii.go │ │ │ ├── background.go │ │ │ ├── c0.go │ │ │ ├── c1.go │ │ │ ├── charset.go │ │ │ ├── clipboard.go │ │ │ ├── color.go │ │ │ ├── ctrl.go │ │ │ ├── cursor.go │ │ │ ├── cwd.go │ │ │ ├── doc.go │ │ │ ├── finalterm.go │ │ │ ├── focus.go │ │ │ ├── graphics.go │ │ │ ├── hyperlink.go │ │ │ ├── inband.go │ │ │ ├── iterm2.go │ │ │ ├── keypad.go │ │ │ ├── kitty.go │ │ │ ├── method.go │ │ │ ├── mode.go │ │ │ ├── mode_deprecated.go │ │ │ ├── modes.go │ │ │ ├── mouse.go │ │ │ ├── notification.go │ │ │ ├── palette.go │ │ │ ├── parser/ │ │ │ │ ├── const.go │ │ │ │ ├── seq.go │ │ │ │ └── transition_table.go │ │ │ ├── parser.go │ │ │ ├── parser_decode.go │ │ │ ├── parser_handler.go │ │ │ ├── parser_sync.go │ │ │ ├── passthrough.go │ │ │ ├── paste.go │ │ │ ├── progress.go │ │ │ ├── reset.go │ │ │ ├── screen.go │ │ │ ├── sgr.go │ │ │ ├── status.go │ │ │ ├── style.go │ │ │ ├── termcap.go │ │ │ ├── title.go │ │ │ ├── truncate.go │ │ │ ├── urxvt.go │ │ │ ├── util.go │ │ │ ├── width.go │ │ │ ├── winop.go │ │ │ ├── wrap.go │ │ │ └── xterm.go │ │ ├── cellbuf/ │ │ │ ├── LICENSE │ │ │ ├── buffer.go │ │ │ ├── cell.go │ │ │ ├── errors.go │ │ │ ├── geom.go │ │ │ ├── hardscroll.go │ │ │ ├── hashmap.go │ │ │ ├── link.go │ │ │ ├── pen.go │ │ │ ├── screen.go │ │ │ ├── sequence.go │ │ │ ├── style.go │ │ │ ├── tabstop.go │ │ │ ├── utils.go │ │ │ ├── wrap.go │ │ │ └── writer.go │ │ └── term/ │ │ ├── LICENSE │ │ ├── term.go │ │ ├── term_other.go │ │ ├── term_plan9.go │ │ ├── term_unix.go │ │ ├── term_unix_bsd.go │ │ ├── term_unix_other.go │ │ ├── term_windows.go │ │ ├── terminal.go │ │ └── util.go │ ├── clipperhouse/ │ │ ├── displaywidth/ │ │ │ ├── .gitignore │ │ │ ├── AGENTS.md │ │ │ ├── CHANGELOG.md │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── gen.go │ │ │ ├── graphemes.go │ │ │ ├── trie.go │ │ │ └── width.go │ │ ├── stringish/ │ │ │ ├── .gitignore │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ └── interface.go │ │ └── uax29/ │ │ └── v2/ │ │ ├── LICENSE │ │ └── graphemes/ │ │ ├── README.md │ │ ├── iterator.go │ │ ├── reader.go │ │ ├── splitfunc.go │ │ └── trie.go │ ├── cpuguy83/ │ │ └── go-md2man/ │ │ └── v2/ │ │ ├── LICENSE.md │ │ └── md2man/ │ │ ├── debug.go │ │ ├── md2man.go │ │ └── roff.go │ ├── erikgeiser/ │ │ └── coninput/ │ │ ├── .gitignore │ │ ├── .golangci.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── keycodes.go │ │ ├── mode.go │ │ ├── read.go │ │ └── records.go │ ├── inconshreveable/ │ │ └── mousetrap/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── trap_others.go │ │ └── trap_windows.go │ ├── mattn/ │ │ ├── go-isatty/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── doc.go │ │ │ ├── go.test.sh │ │ │ ├── isatty_bsd.go │ │ │ ├── isatty_others.go │ │ │ ├── isatty_plan9.go │ │ │ ├── isatty_solaris.go │ │ │ ├── isatty_tcgets.go │ │ │ └── isatty_windows.go │ │ ├── go-localereader/ │ │ │ ├── README.md │ │ │ ├── localereader.go │ │ │ ├── localereader_unix.go │ │ │ └── localereader_windows.go │ │ └── go-runewidth/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── benchstat.txt │ │ ├── new.txt │ │ ├── old.txt │ │ ├── runewidth.go │ │ ├── runewidth_appengine.go │ │ ├── runewidth_js.go │ │ ├── runewidth_posix.go │ │ ├── runewidth_table.go │ │ └── runewidth_windows.go │ ├── muesli/ │ │ ├── ansi/ │ │ │ ├── .gitignore │ │ │ ├── .golangci.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── ansi.go │ │ │ ├── buffer.go │ │ │ ├── compressor/ │ │ │ │ └── writer.go │ │ │ └── writer.go │ │ ├── cancelreader/ │ │ │ ├── .gitignore │ │ │ ├── .golangci-soft.yml │ │ │ ├── .golangci.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── cancelreader.go │ │ │ ├── cancelreader_bsd.go │ │ │ ├── cancelreader_default.go │ │ │ ├── cancelreader_linux.go │ │ │ ├── cancelreader_select.go │ │ │ ├── cancelreader_unix.go │ │ │ └── cancelreader_windows.go │ │ ├── reflow/ │ │ │ ├── LICENSE │ │ │ ├── ansi/ │ │ │ │ ├── ansi.go │ │ │ │ ├── buffer.go │ │ │ │ └── writer.go │ │ │ └── wrap/ │ │ │ └── wrap.go │ │ └── termenv/ │ │ ├── .gitignore │ │ ├── .golangci-soft.yml │ │ ├── .golangci.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── ansi_compat.md │ │ ├── ansicolors.go │ │ ├── color.go │ │ ├── constants_linux.go │ │ ├── constants_solaris.go │ │ ├── constants_unix.go │ │ ├── constants_zos.go │ │ ├── copy.go │ │ ├── hyperlink.go │ │ ├── notification.go │ │ ├── output.go │ │ ├── profile.go │ │ ├── screen.go │ │ ├── style.go │ │ ├── templatehelper.go │ │ ├── termenv.go │ │ ├── termenv_other.go │ │ ├── termenv_posix.go │ │ ├── termenv_solaris.go │ │ ├── termenv_unix.go │ │ └── termenv_windows.go │ ├── rivo/ │ │ └── uniseg/ │ │ ├── LICENSE.txt │ │ ├── README.md │ │ ├── doc.go │ │ ├── eastasianwidth.go │ │ ├── emojipresentation.go │ │ ├── gen_breaktest.go │ │ ├── gen_properties.go │ │ ├── grapheme.go │ │ ├── graphemeproperties.go │ │ ├── graphemerules.go │ │ ├── line.go │ │ ├── lineproperties.go │ │ ├── linerules.go │ │ ├── properties.go │ │ ├── sentence.go │ │ ├── sentenceproperties.go │ │ ├── sentencerules.go │ │ ├── step.go │ │ ├── width.go │ │ ├── word.go │ │ ├── wordproperties.go │ │ └── wordrules.go │ ├── russross/ │ │ └── blackfriday/ │ │ └── v2/ │ │ ├── .gitignore │ │ ├── .travis.yml │ │ ├── LICENSE.txt │ │ ├── README.md │ │ ├── block.go │ │ ├── doc.go │ │ ├── entities.go │ │ ├── esc.go │ │ ├── html.go │ │ ├── inline.go │ │ ├── markdown.go │ │ ├── node.go │ │ └── smartypants.go │ ├── spf13/ │ │ ├── cobra/ │ │ │ ├── .gitignore │ │ │ ├── .golangci.yml │ │ │ ├── .mailmap │ │ │ ├── CONDUCT.md │ │ │ ├── CONTRIBUTING.md │ │ │ ├── LICENSE.txt │ │ │ ├── MAINTAINERS │ │ │ ├── Makefile │ │ │ ├── README.md │ │ │ ├── SECURITY.md │ │ │ ├── active_help.go │ │ │ ├── args.go │ │ │ ├── bash_completions.go │ │ │ ├── bash_completionsV2.go │ │ │ ├── cobra.go │ │ │ ├── command.go │ │ │ ├── command_notwin.go │ │ │ ├── command_win.go │ │ │ ├── completions.go │ │ │ ├── doc/ │ │ │ │ ├── man_docs.go │ │ │ │ ├── md_docs.go │ │ │ │ ├── rest_docs.go │ │ │ │ ├── util.go │ │ │ │ └── yaml_docs.go │ │ │ ├── fish_completions.go │ │ │ ├── flag_groups.go │ │ │ ├── powershell_completions.go │ │ │ ├── shell_completions.go │ │ │ └── zsh_completions.go │ │ └── pflag/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── .golangci.yaml │ │ ├── .travis.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bool.go │ │ ├── bool_func.go │ │ ├── bool_slice.go │ │ ├── bytes.go │ │ ├── count.go │ │ ├── duration.go │ │ ├── duration_slice.go │ │ ├── errors.go │ │ ├── flag.go │ │ ├── float32.go │ │ ├── float32_slice.go │ │ ├── float64.go │ │ ├── float64_slice.go │ │ ├── func.go │ │ ├── golangflag.go │ │ ├── int.go │ │ ├── int16.go │ │ ├── int32.go │ │ ├── int32_slice.go │ │ ├── int64.go │ │ ├── int64_slice.go │ │ ├── int8.go │ │ ├── int_slice.go │ │ ├── ip.go │ │ ├── ip_slice.go │ │ ├── ipmask.go │ │ ├── ipnet.go │ │ ├── ipnet_slice.go │ │ ├── string.go │ │ ├── string_array.go │ │ ├── string_slice.go │ │ ├── string_to_int.go │ │ ├── string_to_int64.go │ │ ├── string_to_string.go │ │ ├── text.go │ │ ├── time.go │ │ ├── uint.go │ │ ├── uint16.go │ │ ├── uint32.go │ │ ├── uint64.go │ │ ├── uint8.go │ │ └── uint_slice.go │ └── xo/ │ └── terminfo/ │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── caps.go │ ├── capvals.go │ ├── color.go │ ├── dec.go │ ├── load.go │ ├── param.go │ ├── stack.go │ └── terminfo.go ├── go.yaml.in/ │ └── yaml/ │ └── v3/ │ ├── LICENSE │ ├── NOTICE │ ├── README.md │ ├── apic.go │ ├── decode.go │ ├── emitterc.go │ ├── encode.go │ ├── parserc.go │ ├── readerc.go │ ├── resolve.go │ ├── scannerc.go │ ├── sorter.go │ ├── writerc.go │ ├── yaml.go │ ├── yamlh.go │ └── yamlprivateh.go ├── golang.org/ │ └── x/ │ ├── sys/ │ │ ├── LICENSE │ │ ├── PATENTS │ │ ├── unix/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── affinity_linux.go │ │ │ ├── aliases.go │ │ │ ├── asm_aix_ppc64.s │ │ │ ├── asm_bsd_386.s │ │ │ ├── asm_bsd_amd64.s │ │ │ ├── asm_bsd_arm.s │ │ │ ├── asm_bsd_arm64.s │ │ │ ├── asm_bsd_ppc64.s │ │ │ ├── asm_bsd_riscv64.s │ │ │ ├── asm_linux_386.s │ │ │ ├── asm_linux_amd64.s │ │ │ ├── asm_linux_arm.s │ │ │ ├── asm_linux_arm64.s │ │ │ ├── asm_linux_loong64.s │ │ │ ├── asm_linux_mips64x.s │ │ │ ├── asm_linux_mipsx.s │ │ │ ├── asm_linux_ppc64x.s │ │ │ ├── asm_linux_riscv64.s │ │ │ ├── asm_linux_s390x.s │ │ │ ├── asm_openbsd_mips64.s │ │ │ ├── asm_solaris_amd64.s │ │ │ ├── asm_zos_s390x.s │ │ │ ├── auxv.go │ │ │ ├── auxv_unsupported.go │ │ │ ├── bluetooth_linux.go │ │ │ ├── bpxsvc_zos.go │ │ │ ├── bpxsvc_zos.s │ │ │ ├── cap_freebsd.go │ │ │ ├── constants.go │ │ │ ├── dev_aix_ppc.go │ │ │ ├── dev_aix_ppc64.go │ │ │ ├── dev_darwin.go │ │ │ ├── dev_dragonfly.go │ │ │ ├── dev_freebsd.go │ │ │ ├── dev_linux.go │ │ │ ├── dev_netbsd.go │ │ │ ├── dev_openbsd.go │ │ │ ├── dev_zos.go │ │ │ ├── dirent.go │ │ │ ├── endian_big.go │ │ │ ├── endian_little.go │ │ │ ├── env_unix.go │ │ │ ├── fcntl.go │ │ │ ├── fcntl_darwin.go │ │ │ ├── fcntl_linux_32bit.go │ │ │ ├── fdset.go │ │ │ ├── gccgo.go │ │ │ ├── gccgo_c.c │ │ │ ├── gccgo_linux_amd64.go │ │ │ ├── ifreq_linux.go │ │ │ ├── ioctl_linux.go │ │ │ ├── ioctl_signed.go │ │ │ ├── ioctl_unsigned.go │ │ │ ├── ioctl_zos.go │ │ │ ├── mkall.sh │ │ │ ├── mkerrors.sh │ │ │ ├── mmap_nomremap.go │ │ │ ├── mremap.go │ │ │ ├── pagesize_unix.go │ │ │ ├── pledge_openbsd.go │ │ │ ├── ptrace_darwin.go │ │ │ ├── ptrace_ios.go │ │ │ ├── race.go │ │ │ ├── race0.go │ │ │ ├── readdirent_getdents.go │ │ │ ├── readdirent_getdirentries.go │ │ │ ├── sockcmsg_dragonfly.go │ │ │ ├── sockcmsg_linux.go │ │ │ ├── sockcmsg_unix.go │ │ │ ├── sockcmsg_unix_other.go │ │ │ ├── sockcmsg_zos.go │ │ │ ├── symaddr_zos_s390x.s │ │ │ ├── syscall.go │ │ │ ├── syscall_aix.go │ │ │ ├── syscall_aix_ppc.go │ │ │ ├── syscall_aix_ppc64.go │ │ │ ├── syscall_bsd.go │ │ │ ├── syscall_darwin.go │ │ │ ├── syscall_darwin_amd64.go │ │ │ ├── syscall_darwin_arm64.go │ │ │ ├── syscall_darwin_libSystem.go │ │ │ ├── syscall_dragonfly.go │ │ │ ├── syscall_dragonfly_amd64.go │ │ │ ├── syscall_freebsd.go │ │ │ ├── syscall_freebsd_386.go │ │ │ ├── syscall_freebsd_amd64.go │ │ │ ├── syscall_freebsd_arm.go │ │ │ ├── syscall_freebsd_arm64.go │ │ │ ├── syscall_freebsd_riscv64.go │ │ │ ├── syscall_hurd.go │ │ │ ├── syscall_hurd_386.go │ │ │ ├── syscall_illumos.go │ │ │ ├── syscall_linux.go │ │ │ ├── syscall_linux_386.go │ │ │ ├── syscall_linux_alarm.go │ │ │ ├── syscall_linux_amd64.go │ │ │ ├── syscall_linux_amd64_gc.go │ │ │ ├── syscall_linux_arm.go │ │ │ ├── syscall_linux_arm64.go │ │ │ ├── syscall_linux_gc.go │ │ │ ├── syscall_linux_gc_386.go │ │ │ ├── syscall_linux_gc_arm.go │ │ │ ├── syscall_linux_gccgo_386.go │ │ │ ├── syscall_linux_gccgo_arm.go │ │ │ ├── syscall_linux_loong64.go │ │ │ ├── syscall_linux_mips64x.go │ │ │ ├── syscall_linux_mipsx.go │ │ │ ├── syscall_linux_ppc.go │ │ │ ├── syscall_linux_ppc64x.go │ │ │ ├── syscall_linux_riscv64.go │ │ │ ├── syscall_linux_s390x.go │ │ │ ├── syscall_linux_sparc64.go │ │ │ ├── syscall_netbsd.go │ │ │ ├── syscall_netbsd_386.go │ │ │ ├── syscall_netbsd_amd64.go │ │ │ ├── syscall_netbsd_arm.go │ │ │ ├── syscall_netbsd_arm64.go │ │ │ ├── syscall_openbsd.go │ │ │ ├── syscall_openbsd_386.go │ │ │ ├── syscall_openbsd_amd64.go │ │ │ ├── syscall_openbsd_arm.go │ │ │ ├── syscall_openbsd_arm64.go │ │ │ ├── syscall_openbsd_libc.go │ │ │ ├── syscall_openbsd_mips64.go │ │ │ ├── syscall_openbsd_ppc64.go │ │ │ ├── syscall_openbsd_riscv64.go │ │ │ ├── syscall_solaris.go │ │ │ ├── syscall_solaris_amd64.go │ │ │ ├── syscall_unix.go │ │ │ ├── syscall_unix_gc.go │ │ │ ├── syscall_unix_gc_ppc64x.go │ │ │ ├── syscall_zos_s390x.go │ │ │ ├── sysvshm_linux.go │ │ │ ├── sysvshm_unix.go │ │ │ ├── sysvshm_unix_other.go │ │ │ ├── timestruct.go │ │ │ ├── unveil_openbsd.go │ │ │ ├── vgetrandom_linux.go │ │ │ ├── vgetrandom_unsupported.go │ │ │ ├── xattr_bsd.go │ │ │ ├── zerrors_aix_ppc.go │ │ │ ├── zerrors_aix_ppc64.go │ │ │ ├── zerrors_darwin_amd64.go │ │ │ ├── zerrors_darwin_arm64.go │ │ │ ├── zerrors_dragonfly_amd64.go │ │ │ ├── zerrors_freebsd_386.go │ │ │ ├── zerrors_freebsd_amd64.go │ │ │ ├── zerrors_freebsd_arm.go │ │ │ ├── zerrors_freebsd_arm64.go │ │ │ ├── zerrors_freebsd_riscv64.go │ │ │ ├── zerrors_linux.go │ │ │ ├── zerrors_linux_386.go │ │ │ ├── zerrors_linux_amd64.go │ │ │ ├── zerrors_linux_arm.go │ │ │ ├── zerrors_linux_arm64.go │ │ │ ├── zerrors_linux_loong64.go │ │ │ ├── zerrors_linux_mips.go │ │ │ ├── zerrors_linux_mips64.go │ │ │ ├── zerrors_linux_mips64le.go │ │ │ ├── zerrors_linux_mipsle.go │ │ │ ├── zerrors_linux_ppc.go │ │ │ ├── zerrors_linux_ppc64.go │ │ │ ├── zerrors_linux_ppc64le.go │ │ │ ├── zerrors_linux_riscv64.go │ │ │ ├── zerrors_linux_s390x.go │ │ │ ├── zerrors_linux_sparc64.go │ │ │ ├── zerrors_netbsd_386.go │ │ │ ├── zerrors_netbsd_amd64.go │ │ │ ├── zerrors_netbsd_arm.go │ │ │ ├── zerrors_netbsd_arm64.go │ │ │ ├── zerrors_openbsd_386.go │ │ │ ├── zerrors_openbsd_amd64.go │ │ │ ├── zerrors_openbsd_arm.go │ │ │ ├── zerrors_openbsd_arm64.go │ │ │ ├── zerrors_openbsd_mips64.go │ │ │ ├── zerrors_openbsd_ppc64.go │ │ │ ├── zerrors_openbsd_riscv64.go │ │ │ ├── zerrors_solaris_amd64.go │ │ │ ├── zerrors_zos_s390x.go │ │ │ ├── zptrace_armnn_linux.go │ │ │ ├── zptrace_linux_arm64.go │ │ │ ├── zptrace_mipsnn_linux.go │ │ │ ├── zptrace_mipsnnle_linux.go │ │ │ ├── zptrace_x86_linux.go │ │ │ ├── zsymaddr_zos_s390x.s │ │ │ ├── zsyscall_aix_ppc.go │ │ │ ├── zsyscall_aix_ppc64.go │ │ │ ├── zsyscall_aix_ppc64_gc.go │ │ │ ├── zsyscall_aix_ppc64_gccgo.go │ │ │ ├── zsyscall_darwin_amd64.go │ │ │ ├── zsyscall_darwin_amd64.s │ │ │ ├── zsyscall_darwin_arm64.go │ │ │ ├── zsyscall_darwin_arm64.s │ │ │ ├── zsyscall_dragonfly_amd64.go │ │ │ ├── zsyscall_freebsd_386.go │ │ │ ├── zsyscall_freebsd_amd64.go │ │ │ ├── zsyscall_freebsd_arm.go │ │ │ ├── zsyscall_freebsd_arm64.go │ │ │ ├── zsyscall_freebsd_riscv64.go │ │ │ ├── zsyscall_illumos_amd64.go │ │ │ ├── zsyscall_linux.go │ │ │ ├── zsyscall_linux_386.go │ │ │ ├── zsyscall_linux_amd64.go │ │ │ ├── zsyscall_linux_arm.go │ │ │ ├── zsyscall_linux_arm64.go │ │ │ ├── zsyscall_linux_loong64.go │ │ │ ├── zsyscall_linux_mips.go │ │ │ ├── zsyscall_linux_mips64.go │ │ │ ├── zsyscall_linux_mips64le.go │ │ │ ├── zsyscall_linux_mipsle.go │ │ │ ├── zsyscall_linux_ppc.go │ │ │ ├── zsyscall_linux_ppc64.go │ │ │ ├── zsyscall_linux_ppc64le.go │ │ │ ├── zsyscall_linux_riscv64.go │ │ │ ├── zsyscall_linux_s390x.go │ │ │ ├── zsyscall_linux_sparc64.go │ │ │ ├── zsyscall_netbsd_386.go │ │ │ ├── zsyscall_netbsd_amd64.go │ │ │ ├── zsyscall_netbsd_arm.go │ │ │ ├── zsyscall_netbsd_arm64.go │ │ │ ├── zsyscall_openbsd_386.go │ │ │ ├── zsyscall_openbsd_386.s │ │ │ ├── zsyscall_openbsd_amd64.go │ │ │ ├── zsyscall_openbsd_amd64.s │ │ │ ├── zsyscall_openbsd_arm.go │ │ │ ├── zsyscall_openbsd_arm.s │ │ │ ├── zsyscall_openbsd_arm64.go │ │ │ ├── zsyscall_openbsd_arm64.s │ │ │ ├── zsyscall_openbsd_mips64.go │ │ │ ├── zsyscall_openbsd_mips64.s │ │ │ ├── zsyscall_openbsd_ppc64.go │ │ │ ├── zsyscall_openbsd_ppc64.s │ │ │ ├── zsyscall_openbsd_riscv64.go │ │ │ ├── zsyscall_openbsd_riscv64.s │ │ │ ├── zsyscall_solaris_amd64.go │ │ │ ├── zsyscall_zos_s390x.go │ │ │ ├── zsysctl_openbsd_386.go │ │ │ ├── zsysctl_openbsd_amd64.go │ │ │ ├── zsysctl_openbsd_arm.go │ │ │ ├── zsysctl_openbsd_arm64.go │ │ │ ├── zsysctl_openbsd_mips64.go │ │ │ ├── zsysctl_openbsd_ppc64.go │ │ │ ├── zsysctl_openbsd_riscv64.go │ │ │ ├── zsysnum_darwin_amd64.go │ │ │ ├── zsysnum_darwin_arm64.go │ │ │ ├── zsysnum_dragonfly_amd64.go │ │ │ ├── zsysnum_freebsd_386.go │ │ │ ├── zsysnum_freebsd_amd64.go │ │ │ ├── zsysnum_freebsd_arm.go │ │ │ ├── zsysnum_freebsd_arm64.go │ │ │ ├── zsysnum_freebsd_riscv64.go │ │ │ ├── zsysnum_linux_386.go │ │ │ ├── zsysnum_linux_amd64.go │ │ │ ├── zsysnum_linux_arm.go │ │ │ ├── zsysnum_linux_arm64.go │ │ │ ├── zsysnum_linux_loong64.go │ │ │ ├── zsysnum_linux_mips.go │ │ │ ├── zsysnum_linux_mips64.go │ │ │ ├── zsysnum_linux_mips64le.go │ │ │ ├── zsysnum_linux_mipsle.go │ │ │ ├── zsysnum_linux_ppc.go │ │ │ ├── zsysnum_linux_ppc64.go │ │ │ ├── zsysnum_linux_ppc64le.go │ │ │ ├── zsysnum_linux_riscv64.go │ │ │ ├── zsysnum_linux_s390x.go │ │ │ ├── zsysnum_linux_sparc64.go │ │ │ ├── zsysnum_netbsd_386.go │ │ │ ├── zsysnum_netbsd_amd64.go │ │ │ ├── zsysnum_netbsd_arm.go │ │ │ ├── zsysnum_netbsd_arm64.go │ │ │ ├── zsysnum_openbsd_386.go │ │ │ ├── zsysnum_openbsd_amd64.go │ │ │ ├── zsysnum_openbsd_arm.go │ │ │ ├── zsysnum_openbsd_arm64.go │ │ │ ├── zsysnum_openbsd_mips64.go │ │ │ ├── zsysnum_openbsd_ppc64.go │ │ │ ├── zsysnum_openbsd_riscv64.go │ │ │ ├── zsysnum_zos_s390x.go │ │ │ ├── ztypes_aix_ppc.go │ │ │ ├── ztypes_aix_ppc64.go │ │ │ ├── ztypes_darwin_amd64.go │ │ │ ├── ztypes_darwin_arm64.go │ │ │ ├── ztypes_dragonfly_amd64.go │ │ │ ├── ztypes_freebsd_386.go │ │ │ ├── ztypes_freebsd_amd64.go │ │ │ ├── ztypes_freebsd_arm.go │ │ │ ├── ztypes_freebsd_arm64.go │ │ │ ├── ztypes_freebsd_riscv64.go │ │ │ ├── ztypes_linux.go │ │ │ ├── ztypes_linux_386.go │ │ │ ├── ztypes_linux_amd64.go │ │ │ ├── ztypes_linux_arm.go │ │ │ ├── ztypes_linux_arm64.go │ │ │ ├── ztypes_linux_loong64.go │ │ │ ├── ztypes_linux_mips.go │ │ │ ├── ztypes_linux_mips64.go │ │ │ ├── ztypes_linux_mips64le.go │ │ │ ├── ztypes_linux_mipsle.go │ │ │ ├── ztypes_linux_ppc.go │ │ │ ├── ztypes_linux_ppc64.go │ │ │ ├── ztypes_linux_ppc64le.go │ │ │ ├── ztypes_linux_riscv64.go │ │ │ ├── ztypes_linux_s390x.go │ │ │ ├── ztypes_linux_sparc64.go │ │ │ ├── ztypes_netbsd_386.go │ │ │ ├── ztypes_netbsd_amd64.go │ │ │ ├── ztypes_netbsd_arm.go │ │ │ ├── ztypes_netbsd_arm64.go │ │ │ ├── ztypes_openbsd_386.go │ │ │ ├── ztypes_openbsd_amd64.go │ │ │ ├── ztypes_openbsd_arm.go │ │ │ ├── ztypes_openbsd_arm64.go │ │ │ ├── ztypes_openbsd_mips64.go │ │ │ ├── ztypes_openbsd_ppc64.go │ │ │ ├── ztypes_openbsd_riscv64.go │ │ │ ├── ztypes_solaris_amd64.go │ │ │ └── ztypes_zos_s390x.go │ │ └── windows/ │ │ ├── aliases.go │ │ ├── dll_windows.go │ │ ├── env_windows.go │ │ ├── eventlog.go │ │ ├── exec_windows.go │ │ ├── memory_windows.go │ │ ├── mkerrors.bash │ │ ├── mkknownfolderids.bash │ │ ├── mksyscall.go │ │ ├── race.go │ │ ├── race0.go │ │ ├── security_windows.go │ │ ├── service.go │ │ ├── setupapi_windows.go │ │ ├── str.go │ │ ├── syscall.go │ │ ├── syscall_windows.go │ │ ├── types_windows.go │ │ ├── types_windows_386.go │ │ ├── types_windows_amd64.go │ │ ├── types_windows_arm.go │ │ ├── types_windows_arm64.go │ │ ├── zerrors_windows.go │ │ ├── zknownfolderids_windows.go │ │ └── zsyscall_windows.go │ └── text/ │ ├── LICENSE │ ├── PATENTS │ └── transform/ │ └── transform.go └── modules.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agent/workflows/validate-pr-check.md ================================================ --- description: How to validate the PR check workflow locally --- # Validate PR Check Locally You can validate the PR check workflow in two ways: manually running the commands or using `act` to simulate GitHub Actions. ## Option 1: Manual Validation (Fastest) Run the following commands in your terminal to mimic the workflow steps: 1. **Validate Code** ```bash # Check formatting test -z $(gofmt -l .) # Run static analysis go vet ./... # Run tests go test -v ./... ``` 2. **Verify Builds (Cross-compilation)** ```bash # Linux GOOS=linux GOARCH=amd64 go build -v ./cmd/witr GOOS=linux GOARCH=arm64 go build -v ./cmd/witr # macOS GOOS=darwin GOARCH=amd64 go build -v ./cmd/witr GOOS=darwin GOARCH=arm64 go build -v ./cmd/witr ``` ## Option 2: Using `act` (Docker required) If you have [act](https://github.com/nektos/act) installed, you can run the workflow in a container: ```bash # Run the specific job act -j validate act -j build # Or run the whole workflow for a pull_request event act pull_request ``` ================================================ FILE: .github/FUNDING.yml ================================================ github: pranshuparmar ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Create a report to help us improve title: "[Bug]: " labels: ["bug"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: description attributes: label: Describe the bug description: A clear and concise description of what the bug is. placeholder: I was doing X and Y happened... validations: required: true - type: textarea id: reproduce attributes: label: To Reproduce description: Steps to reproduce the behavior. placeholder: | 1. Run '...' 2. See error - type: input id: os attributes: label: OS description: e.g. macOS, Ubuntu placeholder: macOS 14.0 validations: required: true - type: input id: version attributes: label: Version description: e.g. v0.1.0 placeholder: v0.1.0 - type: input id: arch attributes: label: Architecture description: e.g. amd64, arm64 placeholder: amd64 - type: textarea id: context attributes: label: Additional context description: Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request title: "[Feature]: " description: Suggest an idea for this project labels: ["enhancement"] body: - type: markdown attributes: value: | Thank you for taking the time to suggest a feature! - type: textarea id: problem attributes: label: Is your feature request related to a problem? description: A clear and concise description of what the problem is. Ex. I'm currently unable to [...] placeholder: I wish I could... validations: required: true - type: textarea id: solution attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. placeholder: It would be great if... - type: textarea id: context attributes: label: Additional context description: Add any other context or screenshots about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description Briefly describe what this PR does. Mention existing issue number, if applicable. ## Type of change Check all that apply: - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (may affect existing functionality) - [ ] This change requires a documentation update ## Checklist - [ ] I have formatted my code using `go fmt ./...` - [ ] I have opened this PR against the `staging` branch - [ ] I have performed a self-review of my own code - [ ] I have added helpful comments where needed - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] New and existing unit tests pass locally with my changes ## Thank you for your contribution! 🎉 We’re excited to review your pull request. Please fill out the details above to help us understand your changes. Don’t worry if you can’t check every box, just do your best! ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: actions: patterns: - "*" ================================================ FILE: .github/workflows/e2e-tests.yml ================================================ name: E2E Tests on: pull_request: branches: [ "main" ] permissions: read-all jobs: build-and-test: name: "Build & Test" runs-on: ${{ matrix.runner }} strategy: matrix: include: - os: linux arch: amd64 runner: ubuntu-latest - os: darwin arch: amd64 runner: macos-15 - os: windows arch: amd64 runner: windows-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.25' - name: Build ${{ matrix.os }}/${{ matrix.arch }} shell: bash run: | GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o witr-${{ matrix.os }}-${{ matrix.arch }} ./cmd/witr chmod +x witr-${{ matrix.os }}-${{ matrix.arch }} - name: Verify Version shell: bash run: ./witr-${{ matrix.os }}-${{ matrix.arch }} --version - name: Verify Help shell: bash run: ./witr-${{ matrix.os }}-${{ matrix.arch }} --help test-freebsd: name: "Test: FreeBSD" runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Run on FreeBSD uses: vmactions/freebsd-vm@v1 with: usesh: true prepare: pkg install -y go run: | go build -o witr-freebsd-amd64 ./cmd/witr chmod +x witr-freebsd-amd64 ./witr-freebsd-amd64 --version ./witr-freebsd-amd64 --help ================================================ FILE: .github/workflows/pr-check.yml ================================================ name: PR Check on: pull_request: types: [opened, synchronize, reopened, edited] push: branches: - main - staging permissions: read-all jobs: semantic-pull-request: name: "Compliance: Check PR Title" if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - name: Check PR title uses: amannn/action-semantic-pull-request@v6 id: lint_pr_title continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Warn on failure if: steps.lint_pr_title.outcome == 'failure' run: echo "::warning::PR title does not follow semantic commit convention" lint: name: "Code Quality: Lint" runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.25' - name: Verify formatting run: | if [ -n "$(find . -name "*.go" -not -path "./vendor/*" | xargs gofmt -l)" ]; then echo "Go code is not formatted:" find . -name "*.go" -not -path "./vendor/*" | xargs gofmt -d exit 1 fi vet: name: "Code Quality: Vet" runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.25' - name: Run go vet run: go vet ./... test: name: "Tests: Unit" runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.25' - name: Run tests run: go test -v ./... ================================================ FILE: .github/workflows/release.yml ================================================ # .github/workflows/release.yml name: Release on: push: tags: - 'v*' permissions: contents: write concurrency: group: release-${{ github.ref }} cancel-in-progress: true env: GO_VERSION: '1.25' PROJECT_NAME: witr jobs: release: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ github.ref_name }} ================================================ FILE: .github/workflows/update-docs.yml ================================================ name: Update Documentation on: push: branches: - main workflow_dispatch: permissions: contents: write concurrency: group: ${{github.workflow}}-${{ github.ref }} cancel-in-progress: true env: GO_VERSION: '1.25' PROJECT_NAME: witr jobs: update-docs: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Create man.1 documentation run: | go run internal/tools/docgen/main.go -out ./docs/cli -format man - name: Create markdown documentation run: | go run internal/tools/docgen/main.go -out ./docs/cli -format markdown - name: Commit and push documentation env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add docs/cli if git diff --cached --quiet; then echo "No changes to commit" exit 0 fi git commit -m "docs: update CLI documentation" - name: Push documentation run: | git push origin ${{ github.ref }} ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Code coverage profiles and other test artifacts *.out coverage.* *.coverprofile profile.cov # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work go.work.sum # env file .env # Editor/IDE .idea/ .vscode/ # Nix result # Binary files /witr # Ignore build artifacts dist/ pkg-dist/ ================================================ FILE: .goreleaser.yml ================================================ version: 2 project_name: witr builds: - id: witr main: ./cmd/witr binary: witr goos: - linux - darwin - freebsd - windows goarch: - amd64 - arm64 env: - CGO_ENABLED=0 ldflags: - -s -w - -X github.com/pranshuparmar/witr/internal/version.Version=v{{ .Version }} - -X github.com/pranshuparmar/witr/internal/version.Commit={{ .Commit }} - -X github.com/pranshuparmar/witr/internal/version.BuildDate={{ .Date }} archives: - id: binaries formats: - binary format_overrides: - goos: windows formats: [ 'zip' ] ids: - witr name_template: "witr-{{ .Os }}-{{ .Arch }}" checksum: name_template: SHA256SUMS algorithm: sha256 nfpms: - id: witr package_name: witr file_name_template: "witr-{{ .Version }}-{{ .Os }}-{{ .Arch }}" formats: - apk - deb - rpm maintainer: Pranshu Parmar description: witr explains why a process or port is running by tracing its ancestry. homepage: https://github.com/pranshuparmar/witr license: Apache-2.0 contents: - src: docs/cli/witr.1 dst: /usr/share/man/man1/witr.1 file_info: mode: 0644 changelog: sort: asc use: github-native release: extra_files: - glob: docs/cli/witr.1 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to witr First off, thank you for considering contributing to **witr**! It's people like you that make the open-source community such an amazing place to learn, inspire, and create. All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 > If you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: > - Star the project > - Tweet about it > - Refer this project in your project's readme > - Mention the project at local meetups and tell your friends/colleagues ## Table of Contents - [Code of Conduct](#code-of-conduct) - [I Have a Question](#i-have-a-question) - [I Want To Contribute](#i-want-to-contribute) - [Reporting Bugs](#reporting-bugs) - [Suggesting Enhancements](#suggesting-enhancements) - [Your First Code Contribution](#your-first-code-contribution) - [Improving Documentation](#improving-documentation) - [Styleguides](#styleguides) - [Commit Messages](#commit-messages) - [Join The Project Team](#join-the-project-team) ## Building from source When you need to verify a change locally, compile the CLI with Go 1.25+ so that the embedded version data stays accurate: ```bash git clone https://github.com/pranshuparmar/witr.git cd witr go build -o witr ./cmd/witr ./witr --help # quick smoke test ``` - The `-ldflags` block injects commit/date metadata for `witr --version`. - The resulting `witr` binary lands in the repo root. ## Code Style ## Code of Conduct This project and everyone participating in it is governed by the [witr Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to . ## I Have a Question > If you want to ask a question, we assume that you have read the available [Documentation](README.md). Before you ask a question, it is best to search for existing [Issues](https://github.com/pranshuparmar/witr/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. If you then still feel the need to ask a question and need clarification, we recommend the following: - Open an [Issue](https://github.com/pranshuparmar/witr/issues/new). - Provide as much context as you can about what you're running into. - Provide project and platform versions (witr version, OS, Shell, target service (if possible) etc.), depending on what seems relevant. We will then answer as soon as possible. ## I Want To Contribute > ### Legal Notice > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. ### Suggesting Enhancements This section guides you through submitting an enhancement suggestion for **witr**, including completely new features and minor improvements to existing functionality. Following these steps helps maintainers and the community understand your suggestion and find related suggestions. #### Before Submitting an Enhancement - Make sure that you are using the latest version. - Read the [documentation](README.md) carefully and find out if the functionality is already covered, maybe by an individual configuration. - Search [Issues](https://github.com/pranshuparmar/witr/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a edge case, might be better to create a separate extension/library. #### How Do I Submit a Good Enhancement Suggestion? Enhancement suggestions are tracked as [GitHub issues](https://github.com/pranshuparmar/witr/issues). - Open an [Issue](https://github.com/pranshuparmar/witr/issues/new). - Use a **clear and descriptive title** for the issue to identify the suggestion. - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. - **Describe the current behavior** and explain which behavior you expected to see instead and why. At this point you can also tell which alternatives do not work for you. - **Explain why this enhancement would be useful** to most **witr** users. You may also want to point out the other projects that solved it better and which could serve as inspiration. ### Your First Code Contribution #### Setup 1. Fork the repository. 2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/witr.git` 3. Create a feature branch: `git checkout -b feature/your-feature-name` 4. Install dependencies: `go mod download` #### Development - Follow the existing code style. - Use `gofmt` to format your code. - Write unit tests for new functionality. - Ensure all tests pass: `go test ./...` #### Pull Request Process 1. **Squash your commits**: We prefer a clean history. Please squash your commits into a single logical commit before submitting. 2. **Rebase on `staging`**: Ensure your branch is up to date with the `staging` branch. 3. **Open a PR**: Open a PR against the `staging` branch, not `main`. 4. **Use the Template**: Fill out the PR template completely. 5. **Review**: Wait for a maintainer to review your PR. Address any feedback promptly. 6. **Merge**: Once approved, a maintainer will merge your PR. We **strictly use Squash and Merge** to keep the `main` history clean. ### Improving Documentation Documentation improvements are always welcome! If you find a typo or want to clarify a section, feel free to open a PR. ## Styleguides ### Commit Messages We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. Format: `(): ` Types: - `feat`: A new feature - `fix`: A bug fix - `docs`: Documentation only changes - `style`: Changes that do not affect the meaning of the code (white-space, formatting, etc) - `refactor`: A code change that neither fixes a bug nor adds a feature - `perf`: A code change that improves performance - `test`: Adding missing tests or correcting existing tests - `build`: Changes that affect the build system or external dependencies - `ci`: Changes to our CI configuration files and scripts - `chore`: Other changes that don't modify src or test files ## License By contributing, you agree that your contributions will be licensed under the Apache License 2.0. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2025 Pranshu Parmar Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ .PHONY: build test lint docs man clean BINARY := witr CMD := ./cmd/witr build: CGO_ENABLED=0 go build -o $(BINARY) $(CMD) test: go test ./... test-race: go test -race ./... lint: gofmt -l . go vet ./... docs: man markdown man: go run ./internal/tools/docgen -format man -out docs/cli markdown: go run ./internal/tools/docgen -format markdown -out docs/cli clean: rm -f $(BINARY) ================================================ FILE: README.md ================================================
# witr ### Why is this running? *with* [**Interactive TUI Mode**](#3-interactive-mode-tui) ✨ [![Go Version](https://img.shields.io/github/go-mod/go-version/pranshuparmar/witr?style=flat-square)](https://github.com/pranshuparmar/witr/blob/main/go.mod) [![Go Report Card](https://goreportcard.com/badge/github.com/pranshuparmar/witr?style=flat-square)](https://goreportcard.com/report/github.com/pranshuparmar/witr) [![Release](https://img.shields.io/github/actions/workflow/status/pranshuparmar/witr/release.yml?style=flat-square)](https://github.com/pranshuparmar/witr/actions/workflows/release.yml) [![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macos%20%7C%20windows%20%7C%20freebsd-blue?style=flat-square)](#6-platform-support)
[![Latest Release](https://img.shields.io/github/v/release/pranshuparmar/witr?label=Latest%20Release&style=flat-square)](https://github.com/pranshuparmar/witr/releases/latest) [![Package Managers](https://img.shields.io/badge/Package%20Managers-brew%20|%20conda%20|%20aur%20|%20winget%20|%20npm%20|%20ports%20|%20...%20-blue?style=flat-square)](https://repology.org/project/witr/versions) 📖 Read the [story](https://medium.com/@pranshu.parmar/witr-why-is-this-running-a9a97cbedd18) behind witr witr_banner
---
[**Purpose**](#1-purpose) • [**Installation**](#2-installation) • ✨ [**TUI**](#3-interactive-mode-tui) • [**Flags**](#4-flags--options) • [**Examples**](#5-example-outputs) • [**Platforms**](#6-platform-support)
[**Goals**](#7-goals) • [**Core Concept**](#8-core-concept) • [**Output Behavior**](#9-output-behavior) • [**Success Criteria**](#10-success-criteria) • [**Sponsors**](#11-sponsors)
--- ## 1. Purpose **witr** exists to answer a single question: > **Why is this running?** When something is running on a system, whether it is a process, a service, or something bound to a port, there is always a cause. That cause is often indirect, non-obvious, or spread across multiple layers such as supervisors, containers, services, or shells. Existing tools (`ps`, `top`, `lsof`, `ss`, `systemctl`, `docker ps`) expose state and metadata. They show _what_ is running, but leave the user to infer _why_ by manually correlating outputs across tools. **witr** makes that causality explicit. It explains **where a running thing came from**, **how it was started**, and **what chain of systems is responsible for it existing right now**, in a single, human-readable output or an **interactive TUI dashboard**. --- ## 2. Installation witr is distributed as a single static binary for Linux, macOS, FreeBSD, and Windows. witr is also independently packaged and maintained across multiple operating systems and ecosystems. An up-to-date overview of packaging status is available on [Repology](https://repology.org/project/witr/versions). Please note that community packages may lag GitHub releases due to independent review and validation. > [!TIP] > If you use a package manager (Homebrew, Conda, Winget, etc.), we recommend installing via that for easier updates. Otherwise, the install script is the quickest way to get started. --- ### 2.1 Quick Install #### Unix (Linux, macOS & FreeBSD) ```bash curl -fsSL https://raw.githubusercontent.com/pranshuparmar/witr/main/install.sh | bash ```
Script Details The script will: - Detect your operating system (`linux`, `darwin` or `freebsd`) - Detect your CPU architecture (`amd64` or `arm64`) - Download the latest released binary and man page - Install it to `/usr/local/bin/witr` - Install the man page to `/usr/local/share/man/man1/witr.1` - Pass INSTALL_PREFIX to override default install path
#### Windows (PowerShell) ```powershell irm https://raw.githubusercontent.com/pranshuparmar/witr/main/install.ps1 | iex ```
Script Details The script will: - Download the latest release (zip) and verify checksum. - Extract `witr.exe` to `%LocalAppData%\witr\bin`. - Add the bin directory to your User `PATH`.
--- ### 2.2 Package Managers
Homebrew (macOS & Linux) Homebrew
You can install **witr** using [Homebrew](https://brew.sh/) on macOS or Linux: ```bash brew install witr ```
Conda (macOS, Linux & Windows) Conda
You can install **witr** using [conda](https://docs.conda.io/en/latest/), [mamba](https://mamba.readthedocs.io/en/latest/), or [pixi](https://pixi.prefix.dev/latest/) on macOS, Linux, and Windows: ```bash conda install -c conda-forge witr # alternatively using mamba mamba install -c conda-forge witr # alternatively using pixi pixi global install witr ```
Arch Linux (AUR) AUR
On Arch Linux and derivatives, install from the [AUR package](https://aur.archlinux.org/packages/witr-bin): ```bash yay -S witr-bin # alternatively using paru paru -S witr-bin # or use your preferred AUR helper ```
Winget (Windows) Winget
You can install **witr** via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): ```powershell winget install -e --id PranshuParmar.witr ```
NPM (Cross-platform) NPM
You can install **witr** using [npm](https://www.npmjs.com/package/@pranshuparmar/witr): ```bash npm install -g @pranshuparmar/witr ```
FreeBSD Ports FreeBSD Port
You can install **witr** on FreeBSD from the [FreshPorts port](https://www.freshports.org/sysutils/witr/): ```bash pkg install witr # or pkg install sysutils/witr ``` Or build from Ports: ```bash cd /usr/ports/sysutils/witr/ make install clean ```
Chocolatey (Windows) Chocolatey
You can install **witr** using [Chocolatey](https://community.chocolatey.org): ```powershell choco install witr ```
Scoop (Windows) Scoop
You can install **witr** using [Scoop](https://scoop.sh): ```powershell scoop install main/witr ```
AOSC OS AOSC OS
You can install **witr** from the [AOSC OS repository](https://packages.aosc.io/packages/witr): ```bash oma install witr ```
GNU Guix GNU Guix
You can install **witr** from the [GNU Guix repository](https://packages.guix.gnu.org/packages/witr/): ```bash guix install witr ```
Uniget (Linux) Uniget
You can install **witr** using [uniget](https://uniget.dev/): ```bash uniget install witr ```
Aqua (macOS, Linux & Windows) Aqua
You can install **witr** using [aqua](https://aquaproj.github.io/): ```bash # Add package aqua g -i pranshuparmar/witr # Install package aqua i pranshuparmar/witr ```
Brioche (Linux) Brioche
You can install **witr** using [brioche](https://brioche.dev/): ```bash brioche install -r witr ```
Prebuilt Packages (deb, rpm, apk)
**witr** provides native packages for major Linux distributions. You can download the latest `.deb`, `.rpm`, or `.apk` package from the [GitHub releases page](https://github.com/pranshuparmar/witr/releases/latest). - Generic download command using `curl`: ```bash # Replace curl -LO https://github.com/pranshuparmar/witr/releases/latest/download/ ``` - **Debian/Ubuntu (.deb):** ```bash sudo dpkg -i ./witr-*.deb # Or, using apt for dependency resolution: sudo apt install ./witr-*.deb ``` - **Fedora/RHEL/CentOS (.rpm):** ```bash sudo rpm -i ./witr-*.rpm ``` - **Alpine Linux (.apk):** ```bash sudo apk add --allow-untrusted ./witr-*.apk ```
--- ### 2.3 Source & Manual Installation
Go (cross-platform)
You can install the latest version directly from source: ```bash go install github.com/pranshuparmar/witr/cmd/witr@latest ``` This will place the `witr` binary in your `$GOPATH/bin` or `$HOME/go/bin` directory. Make sure this directory is in your `PATH`.
Manual Installation
If you prefer manual installation, follow these simple steps for your platform: **Unix (Linux, macOS, FreeBSD)** ```bash # 1. Determine OS and Architecture OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) [ "$ARCH" = "x86_64" ] && ARCH="amd64" [ "$ARCH" = "aarch64" ] && ARCH="arm64" # 2. Download the binary curl -fsSL "https://github.com/pranshuparmar/witr/releases/latest/download/witr-${OS}-${ARCH}" -o witr # 3. Verify checksum (Optional) curl -fsSL "https://github.com/pranshuparmar/witr/releases/latest/download/SHA256SUMS" -o SHA256SUMS grep "witr-${OS}-${ARCH}" SHA256SUMS | (sha256sum -c - 2>/dev/null || shasum -a 256 -c - 2>/dev/null) rm SHA256SUMS # 4. Rename and install chmod +x witr sudo mkdir -p /usr/local/bin sudo mv witr /usr/local/bin/witr # 5. Install man page (Optional) sudo mkdir -p /usr/local/share/man/man1 sudo curl -fsSL https://github.com/pranshuparmar/witr/releases/latest/download/witr.1 -o /usr/local/share/man/man1/witr.1 ``` **Windows (PowerShell)** ```powershell # 1. Determine Architecture if ($env:PROCESSOR_ARCHITECTURE -eq "AMD64") { $ZipName = "witr-windows-amd64.zip" } elseif ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $ZipName = "witr-windows-arm64.zip" } else { Write-Error "Unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" exit 1 } # 2. Download the zip Invoke-WebRequest -Uri "https://github.com/pranshuparmar/witr/releases/latest/download/$ZipName" -OutFile "witr.zip" # 3. Extract the binary Expand-Archive -Path "witr.zip" -DestinationPath "." -Force # 4. Verify checksum (Optional) Invoke-WebRequest -Uri "https://github.com/pranshuparmar/witr/releases/latest/download/SHA256SUMS" -OutFile "SHA256SUMS" $hash = Get-FileHash -Algorithm SHA256 .\witr.zip $expected = Select-String -Path .\SHA256SUMS -Pattern $ZipName if ($expected -and $hash.Hash.ToLower() -eq $expected.Line.Split(' ')[0]) { Write-Host "Checksum OK" } else { Write-Host "Checksum Mismatch" } # 5. Install to local bin directory $InstallDir = "$env:LocalAppData\witr\bin" New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null Move-Item .\witr.exe $InstallDir\witr.exe -Force # 6. Add to User Path (Persistent) $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($UserPath -notlike "*$InstallDir*") { [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") $env:Path += ";$InstallDir" Write-Host "Added to Path. You may need to restart PowerShell." } # 7. Cleanup Remove-Item witr.zip Remove-Item SHA256SUMS ```
--- ### 2.4 Run Without Installation
Nix Flake
If you use Nix, you can build **witr** from source and run without installation: ```bash nix run github:pranshuparmar/witr -- --help ```
Pixi
If you use [pixi](https://pixi.prefix.dev/latest/), you can run without installation on Linux or macOS: ```bash pixi exec witr --help ```
--- ### 2.5 Other Operations
Verify Installation
```bash witr --version man witr ```
Shell Completions
`witr` supports tab completion for all flags. To enable it, add the appropriate line to your shell configuration: **Bash** ```bash echo 'eval "$(witr completion bash)"' >> ~/.bashrc source ~/.bashrc ``` **Zsh** ```zsh echo 'eval "$(witr completion zsh)"' >> ~/.zshrc source ~/.zshrc ``` **Fish** ```fish witr completion fish | source # To make it permanent: witr completion fish > ~/.config/fish/completions/witr.fish ``` **PowerShell** ```powershell witr completion powershell | Out-String | Invoke-Expression # To make it permanent, add the above line to your $PROFILE ```
Uninstallation
If you installed via a package manager (Homebrew, Conda, etc.), please use the respective uninstall command (e.g., `brew uninstall witr`). To completely remove script/manual installation of **witr**: **Unix (Linux, macOS, FreeBSD)** ```bash sudo rm -f /usr/local/bin/witr sudo rm -f /usr/local/share/man/man1/witr.1 ``` **Windows** ```powershell Remove-Item -Recurse -Force "$env:LocalAppData\witr" ```
--- ## 3. Interactive Mode (TUI) Running `witr` without any arguments or with the `-i` flag launches the **Interactive Mode (TUI)**. This provides a real-time, terminal-based dashboard for exploring processes and ports. ### Key Features: - **Live Process List**: Real-time view of all running processes with sorting and filtering. - **Port View**: Explore open ports and immediately see which processes are holding them. - **Process Details**: Deep-dive into a specific process to see its full ancestry tree, child processes, environment variables, working directory, and more. - **Process Actions**: Send signals (Kill, Terminate, Pause, Resume) or Renice processes directly from the UI. - **Mouse Support**: Navigate, sort columns, and click rows using your mouse. --- ## 4. Flags & Options ``` --env show environment variables for the process -x, --exact use exact name matching (no substring search) -f, --file strings file path(s) to find process for (repeatable) -h, --help help for witr -i, --interactive interactive mode (TUI) --json show result as JSON --no-color disable colorized output -p, --pid strings pid(s) to look up (repeatable) -o, --port strings port(s) to look up (repeatable) -s, --short show only ancestry -t, --tree show only ancestry as a tree --verbose show extended process information -v, --version version for witr --warnings show only warnings ``` Positional arguments (without flags) are treated as process or service names. Multiple names can be passed. By default, name matching uses substring matching (fuzzy search). Use `--exact` to match only processes with the exact name. All target flags (`--pid`, `--port`, `--file`) are repeatable and can be mixed with each other and with positional name arguments. When multiple targets are provided, results are shown sequentially with labeled dividers. All output modes (standard, short, tree, JSON, env, warnings, verbose) work with multiple inputs. The TUI is launched if no arguments or relevant flags (`--pid`, `--port`, `--file`) are provided, or if the `--interactive` flag is explicitly used. --- ## 5. Example Outputs ### 5.1 Name Based Query ```bash witr node ``` ``` Target : node Process : node (pid 14233) User : pm2 Command : node index.js Started : 2 days ago (Mon 2025-02-02 11:42:10 +05:30) Restarts : 1 Why It Exists : systemd (pid 1) → pm2 (pid 5034) → node (pid 14233) Source : pm2 Working Dir : /opt/apps/expense-manager Git Repo : expense-manager (main) Listening : 127.0.0.1:5001 ``` --- ### 5.2 Short Output ```bash witr --port 5000 --short ``` ``` systemd (pid 1) → PM2 v5.3.1: God (pid 1481580) → python (pid 1482060) ``` --- ### 5.3 Tree Output ```bash witr --pid 143895 --tree ``` ``` systemd (pid 1) └─ init-systemd(Ub (pid 2) └─ SessionLeader (pid 143858) └─ Relay(143860) (pid 143859) └─ bash (pid 143860) └─ sh (pid 143886) └─ node (pid 143895) ├─ node (pid 143930) ├─ node (pid 144189) └─ node (pid 144234) ``` Note: _Tree view includes child processes (up to 10) and highlights the target process._ --- ### 5.4 Multiple Matches ```bash witr ng ``` ``` Multiple matching processes found: [1] nginx (pid 2311) nginx -g daemon off; [2] nginx (pid 24891) nginx -g daemon off; [3] ngrok (pid 14233) ngrok http 5000 Re-run with: witr --pid ``` To avoid substring matching and only find processes with an exact name, use the `--exact` flag: ```bash witr nginx -x ``` --- ### 5.5 File Based Query ```bash witr --file /var/lib/dpkg/lock ``` Explains the process holding a file open. --- ### 5.6 Multiple Inputs ```bash witr nginx --port 5432 --pid 1234 ``` ``` ----- [name: nginx] ----- Target : nginx Process : nginx (pid 2311) ... ----- [port: 5432] ----- Target : postgres Process : postgres (pid 891) ... ----- [pid: 1234] ----- Target : node Process : node (pid 1234) ... ``` All target flags are repeatable and can be mixed. Results appear in the order you typed them. All output modes (`--short`, `--tree`, `--json`, `--env`, `--warnings`, `--verbose`) work with multiple inputs. --- ## 6. Platform Support - **Linux** (x86_64, arm64) - Full feature support (`/proc`). - **macOS** (x86_64, arm64) - Uses `ps`, `lsof`, `sysctl`, `pgrep`. - **Windows** (x86_64, arm64) - Uses `Get-CimInstance`, `tasklist`, `netstat`. - **FreeBSD** (x86_64, arm64) - Uses `procstat`, `ps`, `lsof`. --- ### 5.1 Feature Compatibility Matrix | Feature | Linux | macOS | Windows | FreeBSD | Notes | |---------|:-----:|:-----:|:-------:|:-------:|-------| | **Process Selection** | | By Name | ✅ | ✅ | ✅ | ✅ | | | By PID | ✅ | ✅ | ✅ | ✅ | | | By Port | ✅ | ✅ | ✅ | ✅ | | | By File | ✅ | ✅ | ❌ | ✅ | | | Multiple/mixed inputs | ✅ | ✅ | ✅ | ✅ | Repeatable flags, mixed types. | | Exact Match | ✅ | ✅ | ✅ | ✅ | | | Full command line | ✅ | ✅ | ✅ | ✅ | | | Process start time | ✅ | ✅ | ✅ | ✅ | | | Working directory | ✅ | ✅ | ✅ | ✅ | | | Environment variables | ✅ | ⚠️ | ❌ | ✅ | macOS: Partial support due to SIP restrictions. | | **Network** | | Listening ports | ✅ | ✅ | ✅ | ✅ | | | Bind addresses | ✅ | ✅ | ✅ | ✅ | | | Port → PID resolution | ✅ | ✅ | ✅ | ✅ | | | **Service Detection** | | Service Manager | ✅ | ✅ | ✅ | ✅ | Linux: systemd, macOS: launchd, Windows: Services, FreeBSD: rc.d | | Service Description | ✅ | ✅ | ✅ | ✅ | Linux: `Description`, macOS: `Comment`, Windows: `Display Name`, FreeBSD: `rc` header | | Configuration Source | ✅ | ✅ | ✅ | ✅ | Linux: Unit File, macOS: Plist, Windows: Registry Key, FreeBSD: Rc Script | | Supervisor | ✅ | ✅ | ✅ | ✅ | | | Containers | ✅ | ✅ | ✅ | ✅ | Docker (plus Compose mappings), Podman, K8s (Kubepods), Containerd. Colima on macOS/Linux. Jails on FreeBSD. | | SSH session detection | ✅ | ✅ | ✅ | ✅ | Detects remote IP and terminal. | | tmux/screen detection | ✅ | ✅ | ❌ | ✅ | Shows session name in source. | | Schedule detection | ✅ | ✅ | ❌ | ❌ | Linux: systemd timers, macOS: launchd intervals/calendar. | | Snap/Flatpak detection | ✅ | ❌ | ❌ | ❌ | | | **Health & Diagnostics** | | CPU usage detection | ✅ | ✅ | ✅ | ✅ | | | Memory usage detection | ✅ | ✅ | ✅ | ✅ | | | Health status detection | ✅ | ✅ | ✅ | ✅ | | | Open Files / Handles | ✅ | ✅ | ⚠️ | ✅ | Windows: count only. | | Deleted binary detection | ✅ | ✅ | ✅ | ✅ | Warns if executable is missing. | | Capability warnings | ✅ | ❌ | ❌ | ❌ | Warns about dangerous capabilities on non-root processes. | | **Context** | | Git repo/branch detection | ✅ | ✅ | ✅ | ✅ | | | **Interactive Mode (TUI)** | | Process Dashboard | ✅ | ✅ | ✅ | ✅ | | | Port Dashboard | ✅ | ✅ | ✅ | ✅ | | | Process Details | ✅ | ✅ | ✅ | ✅ | | | Process Actions | ✅ | ✅ | ❌ | ✅ | | **Legend:** ✅ Full support | ⚠️ Partial/limited support | ❌ Not available --- ### 5.2 Permissions Note #### Linux/FreeBSD witr inspects system directories which may require elevated permissions. If you are not seeing the expected information, try running witr with sudo: ```bash sudo witr [your arguments] ``` #### macOS On macOS, witr uses `ps`, `lsof`, and `launchctl` to gather process information. Some operations may require elevated permissions: ```bash sudo witr [your arguments] ``` Note: Due to macOS System Integrity Protection (SIP), some system process details may not be accessible even with sudo. #### Windows On Windows, witr uses `Get-CimInstance`, `tasklist`, and `netstat`. To see details for processes owned by other users or system services, you must run the terminal as **Administrator**. ```powershell # Run in Administrator PowerShell .\witr.exe [your arguments] ``` --- ## 7. Goals ### Primary goals - Explain **why a process exists**, not just that it exists - Reduce time‑to‑understanding during debugging and outages - Work with zero configuration - Be safe, read‑only, and non‑destructive - Prefer clarity over completeness ### Non‑goals - Not a monitoring tool - Not a performance profiler - Not a replacement for systemd/docker tooling - Not a remediation or auto‑fix tool --- ## 8. Core Concept witr treats **everything as a process question**. Ports, services, containers, and commands all eventually map to **PIDs**. Once a PID is identified, witr builds a causal chain explaining _why that PID exists_. At its core, witr answers: 1. What is running? 2. How did it start? 3. What is keeping it running? 4. What context does it belong to? --- ## 9. Output Behavior ### 9.1 Output Principles - Single screen by default (best effort) - Deterministic ordering - Narrative-style explanation - Best-effort detection with explicit uncertainty --- ### 9.2 Exit Codes witr returns meaningful exit codes for use in scripts, CI pipelines, and monitoring: | Code | Meaning | |------|---------| | 0 | Clean: process found, no warnings | | 1 | Warnings: process found but has one or more warnings | | 2 | Not found: no matching process or service | | 3 | Permission denied: insufficient privileges | | 4 | Invalid input: bad arguments or ambiguous match | #### Example Usage: ```bash witr nginx --short case $? in 0) echo "All clear" ;; 1) echo "Warnings detected" ;; 2) echo "Process not running" ;; 3) echo "Need elevated privileges" ;; 4) echo "Invalid input or ambiguous match" ;; esac ``` --- ### 9.3 Standard Output Sections #### Target What the user asked about. #### Process Executable, PID, user, command, start time and restart count. #### Why It Exists A causal ancestry chain showing how the process came to exist. This is the core value of witr. #### Source The primary system responsible for starting or supervising the process (best effort). Examples: - systemd unit with schedule info for timer-triggered services (Linux) - launchd service with schedule/trigger details (macOS) - SSH session (with remote IP and terminal) - docker container - pm2 - cron - interactive shell (detects tmux/screen sessions) - Snap/Flatpak sandbox (Linux) Only **one primary source** is selected. #### Context (best effort) - Working directory - Git repository name and branch - Container name / image (docker, podman, kubernetes, colima, containerd) - Public vs private bind #### Warnings Non‑blocking observations such as: - Process is running as root - Dangerous Linux capabilities on non-root processes (CAP_SYS_ADMIN, etc.) - Process is listening on a public interface (0.0.0.0 / ::) - Restarted multiple times (warning only if above threshold) - Process is using high memory (>1GB RSS) - Process has been running for over 90 days - Deleted binary, library injection indicators (LD_PRELOAD, DYLD_*) --- ## 10. Success Criteria witr is successful if: - A user can answer "why is this running?" within seconds - It reduces reliance on multiple tools - Output is understandable under stress - Users trust it during incidents --- ## 11. Sponsors Special thanks to the people supporting **witr** ❤️

================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions We currently support the following versions of **witr** with security updates: | OS | Supported | | ------- | --------- | | macOS | :white_check_mark: | | Linux | :white_check_mark: | | Windows | :x: | See our [Feature Compatibility Matrix](README.md#91-feature-compatibility-matrix) for more details. ## Reporting a Vulnerability We take the security of **witr** seriously. If you believe you have found a security vulnerability, please report it to us responsibly. **How to report:** Use the "Report a vulnerability" button in the repository’s [Security](https://github.com/pranshuparmar/witr/security) tab on GitHub to submit your report privately. Only maintainers and the reporter will see the submission. **What to include in your report:** - A description of the vulnerability. - Steps to reproduce the issue (including any sample code or configuration). - Potential impact of the vulnerability. - Any suggested mitigations or fixes. ## Our Response Process 1. **Acknowledgment**: We will acknowledge receipt of your report within 48 hours. 2. **Investigation**: We will investigate the report and determine the severity and impact. 3. **Fix**: If a vulnerability is confirmed, we will work on a fix and release a new version as soon as possible. 4. **Disclosure**: We will coordinate the disclosure of the vulnerability with you to ensure that users have time to update. ## Attribution This security policy is based on standard open-source practices. ================================================ FILE: cmd/witr/main.go ================================================ //go:build linux || darwin || freebsd || windows //go:generate go run ../../internal/tools/docgen -format man -out ../../docs/cli //go:generate go run ../../internal/tools/docgen -format markdown -out ../../docs/cli package main import ( "github.com/pranshuparmar/witr/internal/app" "github.com/pranshuparmar/witr/internal/version" ) // Override version at build time with ldflags: // go build -ldflags "-X github.com/pranshuparmar/witr/internal/version.Version=v0.3.0 -X github.com/pranshuparmar/witr/internal/version.Commit=$(git rev-parse --short HEAD) -X 'github.com/pranshuparmar/witr/internal/version.BuildDate=$(date +%Y-%m-%d)'" -o witr ./cmd/witr func main() { app.SetVersion(version.Version, version.Commit, version.BuildDate) app.Execute() } ================================================ FILE: cmd/witr/unsupported.go ================================================ //go:build !linux && !darwin && !freebsd && !windows package main import ( "fmt" "os" ) func main() { fmt.Fprintln( os.Stderr, "witr is only supported on Linux, macOS, Windows, and FreeBSD.\n\nIf you are seeing this message, you are attempting to build or run witr on an unsupported platform.\n\nPlease use Linux, macOS, Windows, or FreeBSD to build and run witr.", ) os.Exit(1) } ================================================ FILE: docs/cli/witr.1 ================================================ .nh .TH "WITR" "1" "Mar 2026" "" "" .SH NAME witr - Why is this running? .SH SYNOPSIS \fBwitr [process name...] [flags]\fP .SH DESCRIPTION witr explains why a process or port is running by tracing its ancestry. .SH OPTIONS \fB--env\fP[=false] show environment variables for the process .PP \fB-x\fP, \fB--exact\fP[=false] use exact name matching (no substring search) .PP \fB-f\fP, \fB--file\fP=[] file path(s) to find process for (repeatable) .PP \fB-h\fP, \fB--help\fP[=false] help for witr .PP \fB-i\fP, \fB--interactive\fP[=false] interactive mode (TUI) .PP \fB--json\fP[=false] show result as JSON .PP \fB--no-color\fP[=false] disable colorized output .PP \fB-p\fP, \fB--pid\fP=[] pid(s) to look up (repeatable) .PP \fB-o\fP, \fB--port\fP=[] port(s) to look up (repeatable) .PP \fB-s\fP, \fB--short\fP[=false] show only ancestry .PP \fB-t\fP, \fB--tree\fP[=false] show only ancestry as a tree .PP \fB--verbose\fP[=false] show extended process information .PP \fB--warnings\fP[=false] show only warnings .SH EXAMPLE .EX # Inspect a running process by name witr nginx # Look up a process by PID witr --pid 1234 # Find the process listening on a specific port witr --port 5432 # Find the process holding a lock on a file witr --file /var/lib/dpkg/lock # Inspect a process by name with exact matching (no fuzzy search) witr bun --exact # Show the full process ancestry (who started whom) witr postgres --tree # Show only warnings (suspicious env, arguments, parents) witr docker --warnings # Display only environment variables of the process witr node --env # Short, single-line output (useful for scripts) witr sshd --short # Disable colorized output (CI or piping) witr redis --no-color # Output machine-readable JSON witr chrome --json # Show extended process information (memory, I/O, file descriptors) witr mysql --verbose # Combine flags: inspect port, show environment variables, output JSON witr --port 8080 --env --json # Multiple inputs witr nginx node witr --port 8080 --port 3000 witr --pid 1234 --pid 5678 # Mixed inputs witr nginx --pid 1234 --port 8080 .EE ================================================ FILE: docs/cli/witr.md ================================================ ## witr Why is this running? ### Synopsis witr explains why a process or port is running by tracing its ancestry. ``` witr [process name...] [flags] ``` ### Examples ``` # Inspect a running process by name witr nginx # Look up a process by PID witr --pid 1234 # Find the process listening on a specific port witr --port 5432 # Find the process holding a lock on a file witr --file /var/lib/dpkg/lock # Inspect a process by name with exact matching (no fuzzy search) witr bun --exact # Show the full process ancestry (who started whom) witr postgres --tree # Show only warnings (suspicious env, arguments, parents) witr docker --warnings # Display only environment variables of the process witr node --env # Short, single-line output (useful for scripts) witr sshd --short # Disable colorized output (CI or piping) witr redis --no-color # Output machine-readable JSON witr chrome --json # Show extended process information (memory, I/O, file descriptors) witr mysql --verbose # Combine flags: inspect port, show environment variables, output JSON witr --port 8080 --env --json # Multiple inputs witr nginx node witr --port 8080 --port 3000 witr --pid 1234 --pid 5678 # Mixed inputs witr nginx --pid 1234 --port 8080 ``` ### Options ``` --env show environment variables for the process -x, --exact use exact name matching (no substring search) -f, --file strings file path(s) to find process for (repeatable) -h, --help help for witr -i, --interactive interactive mode (TUI) --json show result as JSON --no-color disable colorized output -p, --pid strings pid(s) to look up (repeatable) -o, --port strings port(s) to look up (repeatable) -s, --short show only ancestry -t, --tree show only ancestry as a tree --verbose show extended process information --warnings show only warnings ``` ================================================ FILE: flake.nix ================================================ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; }; outputs = { self, nixpkgs, }: let inherit (nixpkgs) lib; in { packages = lib.genAttrs [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ] ( system: let pkgs = nixpkgs.legacyPackages.${system}; version = if self ? rev then "git-${builtins.substring 0 7 self.rev}" else "dirty"; commit = self.rev or "dirty"; buildDate = pkgs.lib.concatStringsSep "-" [ (builtins.substring 0 4 self.lastModifiedDate) (builtins.substring 4 2 self.lastModifiedDate) (builtins.substring 6 2 self.lastModifiedDate) ]; in { default = pkgs.buildGoModule { pname = "witr"; inherit version; src = lib.cleanSourceWith { src = ./.; filter = path: _: let pathRelative = lib.removePrefix (toString ./.) (toString path); in builtins.any (p: lib.hasPrefix p pathRelative) [ "/go.mod" "/internal" "/pkg" "/cmd" "/doc" "/vendor" ]; }; vendorHash = null; ldflags = [ "-X github.com/pranshuparmar/witr/internal/version.Version=v${version}" "-X github.com/pranshuparmar/witr/internal/version.Commit=${commit}" "-X github.com/pranshuparmar/witr/internal/version.BuildDate=${buildDate}" ]; nativeBuildInputs = [ pkgs.installShellFiles ]; postInstall = '' installManPage ./doc/witr.* ''; meta = { description = "Why is this running?"; homepage = "https://github.com/pranshuparmar/witr"; license = lib.licenses.asl20; }; }; } ); formatter = lib.genAttrs [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ] ( system: nixpkgs.legacyPackages.${system}.nixpkgs-fmt ); apps = lib.genAttrs [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ] (system: { default = { type = "app"; program = "${self.packages.${system}.default}/bin/witr"; }; }); }; } ================================================ FILE: go.mod ================================================ module github.com/pranshuparmar/witr go 1.25 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/muesli/reflow v0.3.1-0.20230316100924-83f637991171 github.com/spf13/cobra v1.10.2 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) ================================================ FILE: go.sum ================================================ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.1-0.20230316100924-83f637991171 h1:twNwyBIUfWPxvaSS8yMYkJLlGO62pp09YhLND+MojXo= github.com/muesli/reflow v0.3.1-0.20230316100924-83f637991171/go.mod h1:mEMWZ0nzoGlTCHkXp5ljOWhHi1tjvtDGh7wuT1Thhsk= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= ================================================ FILE: install.ps1 ================================================ $ErrorActionPreference = "Stop" $Repo = "pranshuparmar/witr" $InstallDir = Join-Path $env:LOCALAPPDATA "witr\bin" $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") Write-Host "Installing witr..." # 1. Get Latest Tag Write-Host "Fetching latest version..." $LatestUrl = "https://api.github.com/repos/$Repo/releases/latest" try { $LatestJson = Invoke-RestMethod -Uri $LatestUrl $LatestTag = $LatestJson.tag_name } catch { Write-Error "Failed to fetch latest release version. check internet connection." exit 1 } Write-Host "Detected latest version: $LatestTag" # 2. Setup Paths if ($env:PROCESSOR_ARCHITECTURE -eq "AMD64") { $ZipName = "witr-windows-amd64.zip" } elseif ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $ZipName = "witr-windows-arm64.zip" } else { Write-Error "Unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" exit 1 } Write-Host "Detected Architecture: $($env:PROCESSOR_ARCHITECTURE)" $DownloadUrl = "https://github.com/$Repo/releases/download/$LatestTag/$ZipName" $ChecksumUrl = "https://github.com/$Repo/releases/download/$LatestTag/SHA256SUMS" $TempDir = [System.IO.Path]::GetTempPath() $ZipPath = Join-Path $TempDir $ZipName $ChecksumPath = Join-Path $TempDir "SHA256SUMS" # 3. Download Write-Host "Downloading $DownloadUrl..." Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath # 4. Verify Checksum Write-Host "Verifying checksum..." $Hash = Get-FileHash -Algorithm SHA256 $ZipPath $ExpectedLine = Select-String -Path $ChecksumPath -Pattern $ZipName if (-not $ExpectedLine) { Write-Error "Checksum verification failed: could not find $ZipName in SHA256SUMS" Remove-Item $ZipPath, $ChecksumPath -ErrorAction SilentlyContinue exit 1 } $ExpectedHash = $ExpectedLine.Line.Split(' ')[0].Trim() if ($Hash.Hash.ToLower() -ne $ExpectedHash.ToLower()) { Write-Error "Checksum mismatch!`nExpected: $ExpectedHash`nActual: $($Hash.Hash)" Remove-Item $ZipPath, $ChecksumPath -ErrorAction SilentlyContinue exit 1 } Write-Host "Checksum verified." # 5. Extract/Install if (-not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null } Write-Host "Extracting to $InstallDir..." Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force # Cleanup downloads Remove-Item $ZipPath, $ChecksumPath -ErrorAction SilentlyContinue # 6. Update PATH if ($UserPath -notlike "*$InstallDir*") { Write-Host "Adding $InstallDir to User Path..." $NewPath = "$UserPath;$InstallDir" [Environment]::SetEnvironmentVariable("Path", $NewPath, "User") $env:Path += ";$InstallDir" Write-Host "Path updated. You may need to restart your shell." } else { Write-Host "Install directory already in Path." } Write-Host "`nwitr ($LatestTag) installed successfully!" Write-Host "Try running: witr --help" ================================================ FILE: install.sh ================================================ #!/usr/bin/env bash # Installs the latest release of witr from GitHub # Repo: https://github.com/pranshuparmar/witr set -euo pipefail REPO="pranshuparmar/witr" # Standard configurable install prefix (override to avoid sudo): # INSTALL_PREFIX="$HOME/.local" ./install.sh INSTALL_PREFIX="${INSTALL_PREFIX:=/usr/local}" INSTALL_PATH="$INSTALL_PREFIX/bin/witr" MAN_PATH="$INSTALL_PREFIX/share/man/man1/witr.1" # Detect OS OS=$(uname -s | tr '[:upper:]' '[:lower:]') case "$OS" in linux) OS=linux ;; darwin) OS=darwin ;; freebsd) OS=freebsd ;; *) echo "Unsupported OS: $OS" >&2 exit 1 ;; esac # Detect Architecture ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; *) echo "Unsupported architecture: $ARCH" >&2 exit 1 ;; esac # Ensure required tools exist for cmd in curl install; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "Missing required command: $cmd" exit 1 fi done # Get latest release tag from GitHub API LATEST=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | cut -d '"' -f4) if [[ -z "$LATEST" ]]; then echo "Could not determine latest release tag." >&2 exit 1 fi # Construct download URL URL="https://github.com/$REPO/releases/download/$LATEST/witr-$OS-$ARCH" TMP=$(mktemp) MANURL="https://github.com/$REPO/releases/download/$LATEST/witr.1" MAN_TMP=$(mktemp) # Cleanup on exit cleanup() { rm -f "${TMP:-}" "${MAN_TMP:-}" } trap cleanup EXIT # Download release curl -fL "$URL" -o "$TMP" curl -fL "$MANURL" -o "$MAN_TMP" INSTALL_BIN_DIR=$(dirname "$INSTALL_PATH") INSTALL_MAN_DIR=$(dirname "$MAN_PATH") # Decide whether we need sudo (based on whether we can write to the target dirs) need_sudo=0 if ! mkdir -p "$INSTALL_BIN_DIR" 2>/dev/null; then need_sudo=1; fi if ! mkdir -p "$INSTALL_MAN_DIR" 2>/dev/null; then need_sudo=1; fi if [[ "$need_sudo" == "0" ]]; then [[ -w "$INSTALL_BIN_DIR" ]] || need_sudo=1 [[ -w "$INSTALL_MAN_DIR" ]] || need_sudo=1 fi SUDO=() if [[ "$need_sudo" == "1" ]]; then # checking for sudo because alpine using doas and people like me started to use run0 if command -v sudo >/dev/null 2>&1; then # echo "sudo is available" SUDO=(sudo) elif command -v doas >/dev/null 2>&1; then # echo "doas is available" SUDO=(doas) elif command -v run0 >/dev/null 2>&1; then # echo "run0 is available" SUDO=(run0) fi fi # Install ${SUDO[@]+"${SUDO[@]}"} install -m 755 "$TMP" "$INSTALL_PATH" # Install man page ${SUDO[@]+"${SUDO[@]}"} mkdir -p "$INSTALL_MAN_DIR" ${SUDO[@]+"${SUDO[@]}"} install -m 644 "$MAN_TMP" "$MAN_PATH" echo "witr installed successfully to $INSTALL_PATH (version: $LATEST, os: $OS, arch: $ARCH)" echo "Man page installed to $MAN_PATH" ================================================ FILE: internal/app/app.go ================================================ //go:build linux || darwin || freebsd || windows package app import ( "encoding/json" "errors" "fmt" "io" "os" "strconv" "strings" "github.com/pranshuparmar/witr/internal/output" "github.com/pranshuparmar/witr/internal/pipeline" procpkg "github.com/pranshuparmar/witr/internal/proc" "github.com/pranshuparmar/witr/internal/source" "github.com/pranshuparmar/witr/internal/target" "github.com/pranshuparmar/witr/internal/tui" "github.com/pranshuparmar/witr/pkg/model" "github.com/spf13/cobra" ) var ( version = "v0.0.0-dev" commit = "unknown" buildDate = "unknown" ) var rootCmd = &cobra.Command{ Use: "witr [process name...]", Short: "Why is this running?", Long: "witr explains why a process or port is running by tracing its ancestry.", Args: cobra.ArbitraryArgs, CompletionOptions: cobra.CompletionOptions{ HiddenDefaultCmd: false, DisableDefaultCmd: false, DisableNoDescFlag: false, }, Example: _genExamples(), RunE: runApp, } func _genExamples() string { return ` # Inspect a running process by name witr nginx # Look up a process by PID witr --pid 1234 # Find the process listening on a specific port witr --port 5432 # Find the process holding a lock on a file witr --file /var/lib/dpkg/lock # Inspect a process by name with exact matching (no fuzzy search) witr bun --exact # Show the full process ancestry (who started whom) witr postgres --tree # Show only warnings (suspicious env, arguments, parents) witr docker --warnings # Display only environment variables of the process witr node --env # Short, single-line output (useful for scripts) witr sshd --short # Disable colorized output (CI or piping) witr redis --no-color # Output machine-readable JSON witr chrome --json # Show extended process information (memory, I/O, file descriptors) witr mysql --verbose # Combine flags: inspect port, show environment variables, output JSON witr --port 8080 --env --json # Multiple inputs witr nginx node witr --port 8080 --port 3000 witr --pid 1234 --pid 5678 # Mixed inputs witr nginx --pid 1234 --port 8080 ` } // Exit codes const ( ExitOK = 0 ExitWarnings = 1 ExitInternalError = 1 ExitNotFound = 2 ExitPermission = 3 ExitInvalidInput = 4 ) // exitCodeError wraps an error with a specific exit code. type exitCodeError struct { code int err error } func (e *exitCodeError) Error() string { return e.err.Error() } func (e *exitCodeError) Unwrap() error { return e.err } func withExitCode(code int, err error) error { return &exitCodeError{code: code, err: err} } func Execute() { err := rootCmd.Execute() if err == nil { return } var ece *exitCodeError if errors.As(err, &ece) { os.Exit(ece.code) } os.Exit(1) } func init() { rootCmd.InitDefaultCompletionCmd() rootCmd.Version = version rootCmd.SetVersionTemplate(fmt.Sprintf("witr {{.Version}} (commit %s, built %s)\n", commit, buildDate)) rootCmd.SetErr(output.NewSafeTerminalWriter(os.Stderr)) rootCmd.Flags().StringSliceP("pid", "p", nil, "pid(s) to look up (repeatable)") rootCmd.Flags().StringSliceP("port", "o", nil, "port(s) to look up (repeatable)") rootCmd.Flags().StringSliceP("file", "f", nil, "file path(s) to find process for (repeatable)") rootCmd.Flags().BoolP("short", "s", false, "show only ancestry") rootCmd.Flags().BoolP("tree", "t", false, "show only ancestry as a tree") rootCmd.Flags().Bool("json", false, "show result as JSON") rootCmd.Flags().Bool("warnings", false, "show only warnings") rootCmd.Flags().Bool("no-color", false, "disable colorized output") rootCmd.Flags().Bool("env", false, "show environment variables for the process") rootCmd.Flags().Bool("verbose", false, "show extended process information") rootCmd.Flags().BoolP("exact", "x", false, "use exact name matching (no substring search)") rootCmd.Flags().BoolP("interactive", "i", false, "interactive mode (TUI)") } // appFlags holds all parsed CLI flags for convenience. type appFlags struct { short bool tree bool json bool warn bool noColor bool verbose bool exact bool env bool } func runApp(cmd *cobra.Command, args []string) error { interactiveFlag, _ := cmd.Flags().GetBool("interactive") if interactiveFlag { return runInteractive() } envFlag, _ := cmd.Flags().GetBool("env") pidFlags, _ := cmd.Flags().GetStringSlice("pid") portFlags, _ := cmd.Flags().GetStringSlice("port") fileFlags, _ := cmd.Flags().GetStringSlice("file") // Default to interactive mode if no arguments or relevant flags are provided if !envFlag && len(pidFlags) == 0 && len(portFlags) == 0 && len(fileFlags) == 0 && len(args) == 0 { return runInteractive() } flags := appFlags{ env: envFlag, exact: boolFlag(cmd, "exact"), short: boolFlag(cmd, "short"), tree: boolFlag(cmd, "tree"), json: boolFlag(cmd, "json"), warn: boolFlag(cmd, "warnings"), noColor: boolFlag(cmd, "no-color"), verbose: boolFlag(cmd, "verbose"), } // Collect all targets preserving command-line order targets := collectTargetsInOrder(os.Args[1:], args) if len(targets) == 0 { return withExitCode(ExitInvalidInput, fmt.Errorf("must specify --pid, --port, --file, or a process name")) } outw := cmd.OutOrStdout() outp := output.NewPrinter(outw) multiMode := len(targets) > 1 colorEnabled := !flags.noColor // For JSON multi-output, collect all JSON strings and wrap in array var jsonResults []string highestExit := ExitOK for i, t := range targets { if multiMode && !flags.json { printDivider(outp, t, colorEnabled, i > 0) } exitCode := processTarget(cmd, outw, outp, t, flags, multiMode, &jsonResults) if exitCode > highestExit { highestExit = exitCode } } // Emit JSON array for multi-target if flags.json && multiMode { indented := make([]string, len(jsonResults)) for i, r := range jsonResults { lines := strings.Split(r, "\n") for j := range lines { if j > 0 { lines[j] = " " + lines[j] } } indented[i] = " " + strings.Join(lines, "\n") } fmt.Fprintf(outw, "[\n%s\n]\n", strings.Join(indented, ",\n")) } if highestExit > ExitOK { cmd.SilenceErrors = true return withExitCode(highestExit, fmt.Errorf("completed with exit code %d", highestExit)) } return nil } func boolFlag(cmd *cobra.Command, name string) bool { v, _ := cmd.Flags().GetBool(name) return v } // collectTargetsInOrder walks the raw command-line arguments to build a target // list that preserves the order the user typed them in. func collectTargetsInOrder(rawArgs []string, positionalArgs []string) []model.Target { var targets []model.Target positionalIdx := 0 // Map flag names to target types flagType := map[string]model.TargetType{ "-p": model.TargetPID, "--pid": model.TargetPID, "-o": model.TargetPort, "--port": model.TargetPort, "-f": model.TargetFile, "--file": model.TargetFile, } // Track which positional args we've placed so we can insert them in order // between flag-based targets i := 0 for i < len(rawArgs) { arg := rawArgs[i] // Check for --flag=value form if strings.HasPrefix(arg, "--") { if eqIdx := strings.Index(arg, "="); eqIdx >= 0 { flagName := arg[:eqIdx] flagVal := arg[eqIdx+1:] if tt, ok := flagType[flagName]; ok { for _, v := range strings.Split(flagVal, ",") { v = strings.TrimSpace(v) if v != "" { targets = append(targets, model.Target{Type: tt, Value: v}) } } } i++ continue } } // Check for -f value or --flag value form if tt, ok := flagType[arg]; ok { if i+1 < len(rawArgs) { i++ for _, v := range strings.Split(rawArgs[i], ",") { v = strings.TrimSpace(v) if v != "" { targets = append(targets, model.Target{Type: tt, Value: v}) } } } i++ continue } // Skip known boolean flags and their short forms if strings.HasPrefix(arg, "-") { i++ continue } // Positional argument — use it as a name target if positionalIdx < len(positionalArgs) { targets = append(targets, model.Target{Type: model.TargetName, Value: positionalArgs[positionalIdx]}) positionalIdx++ } i++ } // Append any remaining positional args that weren't matched for positionalIdx < len(positionalArgs) { targets = append(targets, model.Target{Type: model.TargetName, Value: positionalArgs[positionalIdx]}) positionalIdx++ } return targets } // targetLabel returns a human-readable label for the divider. func targetLabel(t model.Target) string { switch t.Type { case model.TargetPID: return fmt.Sprintf("pid: %s", t.Value) case model.TargetPort: return fmt.Sprintf("port: %s", t.Value) case model.TargetFile: return fmt.Sprintf("file: %s", t.Value) default: return fmt.Sprintf("name: %s", t.Value) } } func printDivider(outp output.Printer, t model.Target, colorEnabled bool, needsNewline bool) { label := targetLabel(t) if needsNewline { outp.Println() } if colorEnabled { outp.Printf("%s----- [%s] -----%s\n", output.ColorCyan, label, output.ColorReset) } else { outp.Printf("----- [%s] -----\n", label) } } // jsonErrorEntry returns a JSON string representing a failed target lookup. func jsonErrorEntry(t model.Target, errMsg string) string { type errorEntry struct { Target model.Target Error string } data, _ := json.MarshalIndent(errorEntry{Error: errMsg, Target: t}, "", " ") return string(data) } // processTarget handles resolving and rendering a single target. // Returns the exit code for this target. func processTarget(cmd *cobra.Command, outw io.Writer, outp output.Printer, t model.Target, flags appFlags, multiMode bool, jsonResults *[]string) int { colorEnabled := !flags.noColor if flags.env { return processEnvTarget(outw, outp, t, flags, multiMode, jsonResults) } pids, err := target.Resolve(t, flags.exact) if err == nil && len(pids) == 0 { err = fmt.Errorf("no matching process found") } if err != nil { return handleResolveError(cmd, outw, outp, t, err, flags, multiMode, jsonResults) } if len(pids) > 1 { if multiMode && flags.json { *jsonResults = append(*jsonResults, jsonErrorEntry(t, fmt.Sprintf("multiple processes matched (%d results)", len(pids)))) } else { hint := "witr --pid " if flags.env { hint = "witr --pid --env" } printMultiMatch(outp, pids, colorEnabled, hint) } return ExitInvalidInput } pid := pids[0] var systemdService string if t.Type == model.TargetPort && pid == 1 && source.IsSystemdRunning() { if portNum, err := strconv.Atoi(t.Value); err == nil { if svc, err := procpkg.ResolveSystemdService(portNum); err == nil && svc != "" { systemdService = svc } } } res, err := pipeline.AnalyzePID(pipeline.AnalyzeConfig{ PID: pid, Verbose: flags.verbose, Tree: flags.tree, Target: t, }) if err != nil { if multiMode { if flags.json { *jsonResults = append(*jsonResults, jsonErrorEntry(t, err.Error())) } else { outp.Printf("Error: %v\n", err) } return classifyError(err) } errStr := err.Error() errorMsg := fmt.Sprintf("%s\n\nNo matching process or service found. Please check your query or try a different name/port/PID.\nFor usage and options, run: witr --help", errStr) cmd.PrintErrln(errorMsg) return classifyError(err) } if systemdService != "" { res.ResolvedTarget = strings.TrimSuffix(systemdService, ".service") } if t.Type == model.TargetPort { portNum := 0 fmt.Sscanf(t.Value, "%d", &portNum) if portNum > 0 { res.SocketInfo = procpkg.GetSocketStateForPort(portNum) source.EnrichSocketInfo(res.SocketInfo) } } renderResult(outw, res, flags, multiMode, jsonResults) if len(res.Warnings) > 0 { return ExitWarnings } return ExitOK } // processEnvTarget handles the --env flag for a single target. func processEnvTarget(outw io.Writer, outp output.Printer, t model.Target, flags appFlags, multiMode bool, jsonResults *[]string) int { colorEnabled := !flags.noColor pids, err := target.Resolve(t, flags.exact) if err != nil { if multiMode { if flags.json { *jsonResults = append(*jsonResults, jsonErrorEntry(t, err.Error())) } else { outp.Printf("Error: %v\n", err) } return classifyError(err) } outp.Printf("error: %v\n", err) return classifyError(err) } if len(pids) == 0 { if multiMode && flags.json { *jsonResults = append(*jsonResults, jsonErrorEntry(t, "no matching process found")) return ExitNotFound } outp.Println("No matching process found.") return ExitNotFound } if len(pids) > 1 { printMultiMatch(outp, pids, colorEnabled, "witr --pid --env") return ExitInvalidInput } pid := pids[0] procInfo, err := procpkg.ReadProcess(pid) if err != nil { outp.Printf("error: %v\n", err) return ExitInternalError } resEnv := model.Result{ Process: procInfo, Ancestry: []model.Process{procInfo}, } if flags.json { jsonStr, err := output.ToEnvJSON(resEnv) if err != nil { outp.Printf("failed to generate json output: %v\n", err) return ExitInternalError } if multiMode { *jsonResults = append(*jsonResults, jsonStr) } else { fmt.Fprintln(outw, jsonStr) } } else { output.RenderEnvOnly(outw, resEnv, colorEnabled) } return ExitOK } // handleResolveError handles target resolution errors, including Docker fallback. func handleResolveError(cmd *cobra.Command, outw io.Writer, outp output.Printer, t model.Target, err error, flags appFlags, multiMode bool, jsonResults *[]string) int { errStr := err.Error() colorEnabled := !flags.noColor if strings.Contains(errStr, "socket found but owning process not detected") { if t.Type == model.TargetPort { if portNum, convErr := strconv.Atoi(t.Value); convErr == nil { if match := procpkg.ResolveContainerByPort(portNum); match != nil { if flags.json { jsonStr, jsonErr := output.DockerFallbackToJSON(t.Value, match) if jsonErr != nil { outp.Printf("failed to generate json output: %v\n", jsonErr) return ExitInternalError } if multiMode { *jsonResults = append(*jsonResults, jsonStr) } else { fmt.Fprintln(outw, jsonStr) } } else if flags.short { output.RenderDockerFallbackShort(outw, t.Value, match, colorEnabled) } else { output.RenderDockerFallback(outw, t.Value, match, colorEnabled) } return ExitOK } } } if multiMode { if flags.json { *jsonResults = append(*jsonResults, jsonErrorEntry(t, "socket found but owning process not detected (try sudo)")) } else { outp.Printf("Error: socket found but owning process not detected (try sudo)\n") } return ExitPermission } errorMsg := fmt.Sprintf("%s\n\nA socket was found for the port, but the owning process could not be detected.\nThis may be due to insufficient permissions. Try running with sudo:\n sudo %s", errStr, strings.Join(os.Args, " ")) cmd.PrintErrln(errorMsg) return ExitPermission } if multiMode { if flags.json { *jsonResults = append(*jsonResults, jsonErrorEntry(t, errStr)) } else { outp.Printf("Error: %v\n", err) } return classifyError(err) } errorMsg := fmt.Sprintf("%s\n\nNo matching process or service found. Please check your query or try a different name/port/PID.\nFor usage and options, run: witr --help", errStr) cmd.PrintErrln(errorMsg) return classifyError(err) } // renderResult renders a single result in the appropriate output mode. func renderResult(outw io.Writer, res model.Result, flags appFlags, multiMode bool, jsonResults *[]string) { colorEnabled := !flags.noColor if flags.json { var jsonStr string var err error if flags.short { jsonStr, err = output.ToShortJSON(res) } else if flags.tree { jsonStr, err = output.ToTreeJSON(res) } else if flags.warn { jsonStr, err = output.ToWarningsJSON(res) } else { jsonStr, err = output.ToJSON(res) } if err != nil { fmt.Fprintf(outw, "failed to generate json output: %v\n", err) return } if multiMode { *jsonResults = append(*jsonResults, jsonStr) } else { fmt.Fprintln(outw, jsonStr) } } else if flags.warn { output.RenderWarnings(outw, res, colorEnabled) } else if flags.tree { output.PrintTree(outw, res.Ancestry, res.Children, colorEnabled) } else if flags.short { output.RenderShort(outw, res, colorEnabled) } else { output.RenderStandard(outw, res, colorEnabled, flags.verbose) } } func Root() *cobra.Command { return rootCmd } func runInteractive() error { v := version if v == "v0.0.0-dev" { v = "" } return tui.Start(v) } func printMultiMatch(outp output.Printer, pids []int, colorEnabled bool, hint string) { outp.Print("Multiple matching processes found:\n\n") for i, pid := range pids { proc, err := procpkg.ReadProcess(pid) var command, cmdline string if err != nil { command = "unknown" cmdline = procpkg.GetCmdline(pid) } else { command = proc.Command cmdline = proc.Cmdline } if colorEnabled { outp.Printf("[%d] %s%s%s (%spid %d%s)\n %s\n", i+1, output.ColorGreen, command, output.ColorReset, output.ColorDim, pid, output.ColorReset, cmdline) } else { outp.Printf("[%d] %s (pid %d)\n %s\n", i+1, command, pid, cmdline) } } outp.Println("\nRe-run with:") outp.Printf(" %s\n", hint) } // classifyError maps common error strings to exit codes. func classifyError(err error) int { msg := strings.ToLower(err.Error()) switch { case strings.Contains(msg, "permission denied") || strings.Contains(msg, "operation not permitted") || strings.Contains(msg, "insufficient permissions"): return ExitPermission case strings.Contains(msg, "no matching") || strings.Contains(msg, "no running process") || strings.Contains(msg, "not found") || strings.Contains(msg, "no process"): return ExitNotFound case strings.Contains(msg, "invalid") || strings.Contains(msg, "must specify"): return ExitInvalidInput default: return ExitInternalError } } func SetVersion(v string, c string, bd string) { version = v commit = c buildDate = bd rootCmd.Version = version rootCmd.SetVersionTemplate(fmt.Sprintf("witr {{.Version}} (commit %s, built %s)\n", commit, buildDate)) rootCmd.SilenceUsage = true } ================================================ FILE: internal/launchd/plist.go ================================================ //go:build darwin package launchd import ( "bytes" "encoding/xml" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" ) // LaunchdInfo contains parsed information about a launchd service type LaunchdInfo struct { Label string Comment string PlistPath string Domain string // user, system, or gui/ // Triggers RunAtLoad bool KeepAlive bool StartInterval int // seconds StartCalendarInterval string // human-readable schedule WatchPaths []string QueueDirectories []string // Program info Program string ProgramArguments []string } // plistDict represents a plist dictionary for XML parsing type plistDict struct { Keys []string Values []plistValue } type plistValue struct { String string Integer int Bool *bool Array []string Dict *plistDict } // plist search paths in order of precedence var plistSearchPaths = []string{ "~/Library/LaunchAgents", "/Library/LaunchAgents", "/Library/LaunchDaemons", "/System/Library/LaunchAgents", "/System/Library/LaunchDaemons", } // GetServiceLabel uses launchctl blame to get the service label for a PID func GetServiceLabel(pid int) (string, string, error) { // launchctl blame returns the service that started the process out, err := exec.Command("launchctl", "blame", strconv.Itoa(pid)).Output() if err != nil { return "", "", fmt.Errorf("launchctl blame failed: %w", err) } // Output format varies: // - "system/com.apple.example" or "gui/501/com.example.app" (real service) // - "speculative", "non-ipc demand", "launch job demand", "ipc (mach)" (blame reasons) line := strings.TrimSpace(string(out)) if line == "" { return "", "", fmt.Errorf("no service label found for pid %d", pid) } // Check if this is a real service path (contains "/" and starts with domain) if !strings.Contains(line, "/") { // This is a blame reason, not a service label // Try to find the service by querying launchctl list label, domain := findServiceByPID(pid) if label != "" { return label, domain, nil } return "", "", fmt.Errorf("process not managed by a named launchd service: %s", line) } // Parse domain and label from service path parts := strings.SplitN(line, "/", 2) if len(parts) < 2 { return line, "", nil } domain := parts[0] label := parts[1] // Handle gui/501/label format if domain == "gui" { subParts := strings.SplitN(label, "/", 2) if len(subParts) == 2 { domain = "gui/" + subParts[0] label = subParts[1] } } return label, domain, nil } // findServiceByPID queries launchctl list to find a service matching the given PID func findServiceByPID(pid int) (string, string) { // launchctl list shows: PID Status Label out, err := exec.Command("launchctl", "list").Output() if err != nil { return "", "" } pidStr := strconv.Itoa(pid) for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) >= 3 && fields[0] == pidStr { label := fields[2] // Determine domain based on label prefix domain := "user" if strings.HasPrefix(label, "com.apple.") { domain = "system" } return label, domain } } return "", "" } // FindPlistPath searches for the plist file for a given service label func FindPlistPath(label string) string { homeDir, _ := os.UserHomeDir() for _, searchPath := range plistSearchPaths { path := searchPath if strings.HasPrefix(path, "~") { path = filepath.Join(homeDir, path[1:]) } plistPath := filepath.Join(path, label+".plist") if _, err := os.Stat(plistPath); err == nil { return plistPath } } return "" } // ParsePlist reads and parses a launchd plist file func ParsePlist(path string) (*LaunchdInfo, error) { // Use plutil to convert to XML (handles binary plists) out, err := exec.Command("plutil", "-convert", "xml1", "-o", "-", path).Output() if err != nil { return nil, fmt.Errorf("failed to convert plist: %w", err) } info := &LaunchdInfo{ PlistPath: path, } // Parse the XML plist if err := parsePlistXML(out, info); err != nil { return nil, err } return info, nil } // parsePlistXML parses XML plist data into LaunchdInfo func parsePlistXML(data []byte, info *LaunchdInfo) error { decoder := xml.NewDecoder(bytes.NewReader(data)) var currentKey string var dictDepth int // Track dict nesting depth (1 = root dict) for { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: switch t.Name.Local { case "dict": dictDepth++ if dictDepth == 2 && currentKey == "StartCalendarInterval" { cal := parseCalendarDict(decoder) info.StartCalendarInterval = formatCalendarInterval(cal) currentKey = "" continue } // Skip other nested dicts by clearing currentKey if dictDepth > 1 { currentKey = "" } case "key": // Only capture keys at root dict level if dictDepth == 1 { var key string decoder.DecodeElement(&key, &t) currentKey = key } case "string": if dictDepth == 1 && currentKey != "" { var val string decoder.DecodeElement(&val, &t) handleStringValue(info, currentKey, val) currentKey = "" } case "integer": if dictDepth == 1 && currentKey != "" { var val string decoder.DecodeElement(&val, &t) if i, err := strconv.Atoi(val); err == nil { handleIntValue(info, currentKey, i) } currentKey = "" } case "true": if dictDepth == 1 && currentKey != "" { handleBoolValue(info, currentKey, true) currentKey = "" } case "false": if dictDepth == 1 && currentKey != "" { handleBoolValue(info, currentKey, false) currentKey = "" } case "array": if dictDepth == 1 && currentKey == "StartCalendarInterval" { intervals := parseCalendarArray(decoder) info.StartCalendarInterval = intervals currentKey = "" } else if dictDepth == 1 && currentKey != "" { arr := parseArray(decoder) handleArrayValue(info, currentKey, arr) currentKey = "" } } case xml.EndElement: if t.Name.Local == "dict" { dictDepth-- } } } return nil } func parseArray(decoder *xml.Decoder) []string { var result []string depth := 1 for depth > 0 { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: if t.Name.Local == "array" { depth++ } else if t.Name.Local == "string" { var val string decoder.DecodeElement(&val, &t) result = append(result, val) } case xml.EndElement: if t.Name.Local == "array" { depth-- } } } return result } // parseCalendarDict parses a single StartCalendarInterval dict into key-value pairs. func parseCalendarDict(decoder *xml.Decoder) map[string]int { result := make(map[string]int) var currentKey string for { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: switch t.Name.Local { case "key": var key string decoder.DecodeElement(&key, &t) currentKey = key case "integer": var val string decoder.DecodeElement(&val, &t) if currentKey != "" { if i, err := strconv.Atoi(val); err == nil { result[currentKey] = i } currentKey = "" } } case xml.EndElement: if t.Name.Local == "dict" { return result } } } return result } // parseCalendarArray parses an array of StartCalendarInterval dicts. func parseCalendarArray(decoder *xml.Decoder) string { var intervals []string depth := 1 for depth > 0 { token, err := decoder.Token() if err != nil { break } switch t := token.(type) { case xml.StartElement: if t.Name.Local == "array" { depth++ } else if t.Name.Local == "dict" { cal := parseCalendarDict(decoder) if s := formatCalendarInterval(cal); s != "" { intervals = append(intervals, s) } } case xml.EndElement: if t.Name.Local == "array" { depth-- } } } if len(intervals) == 0 { return "" } return strings.Join(intervals, "; ") } // formatCalendarInterval converts a calendar dict into a human-readable string. // Keys: Month, Day, Weekday (0=Sun), Hour, Minute func formatCalendarInterval(cal map[string]int) string { if len(cal) == 0 { return "" } weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} var parts []string if w, ok := cal["Weekday"]; ok && w >= 0 && w < len(weekdays) { parts = append(parts, weekdays[w]) } if m, ok := cal["Month"]; ok { parts = append(parts, fmt.Sprintf("month %d", m)) } if d, ok := cal["Day"]; ok { parts = append(parts, fmt.Sprintf("day %d", d)) } h, hasHour := cal["Hour"] min, hasMin := cal["Minute"] switch { case hasHour && hasMin: parts = append(parts, fmt.Sprintf("at %02d:%02d", h, min)) case hasHour: parts = append(parts, fmt.Sprintf("at %02d:00", h)) case hasMin: parts = append(parts, fmt.Sprintf("at *:%02d", min)) } if len(parts) == 0 { return "" } return strings.Join(parts, " ") } func handleStringValue(info *LaunchdInfo, key, val string) { switch key { case "Label": info.Label = val case "Comment": info.Comment = val case "Program": info.Program = val } } func handleIntValue(info *LaunchdInfo, key string, val int) { switch key { case "StartInterval": info.StartInterval = val } } func handleBoolValue(info *LaunchdInfo, key string, val bool) { switch key { case "RunAtLoad": info.RunAtLoad = val case "KeepAlive": info.KeepAlive = val } } func handleArrayValue(info *LaunchdInfo, key string, val []string) { switch key { case "ProgramArguments": info.ProgramArguments = val case "WatchPaths": info.WatchPaths = val case "QueueDirectories": info.QueueDirectories = val } } // GetLaunchdInfo retrieves full launchd information for a process func GetLaunchdInfo(pid int) (*LaunchdInfo, error) { label, domain, err := GetServiceLabel(pid) if err != nil { return nil, err } plistPath := FindPlistPath(label) if plistPath == "" { // Return basic info even if we can't find the plist return &LaunchdInfo{ Label: label, Domain: domain, }, nil } info, err := ParsePlist(plistPath) if err != nil { // Return basic info on parse error return &LaunchdInfo{ Label: label, Domain: domain, PlistPath: plistPath, }, nil } info.Domain = domain return info, nil } // FormatTriggers returns a human-readable description of what triggers the service func (info *LaunchdInfo) FormatTriggers() []string { var triggers []string if info.RunAtLoad { triggers = append(triggers, "RunAtLoad (starts at login/boot)") } if info.StartInterval > 0 { triggers = append(triggers, fmt.Sprintf("StartInterval (every %s)", formatDuration(info.StartInterval))) } if info.StartCalendarInterval != "" { triggers = append(triggers, fmt.Sprintf("StartCalendarInterval (%s)", info.StartCalendarInterval)) } if len(info.WatchPaths) > 0 { for _, p := range info.WatchPaths { triggers = append(triggers, fmt.Sprintf("WatchPaths: %s", p)) } } if len(info.QueueDirectories) > 0 { for _, p := range info.QueueDirectories { triggers = append(triggers, fmt.Sprintf("QueueDirectories: %s", p)) } } return triggers } func formatDuration(seconds int) string { if seconds < 60 { return fmt.Sprintf("%ds", seconds) } if seconds < 3600 { return fmt.Sprintf("%dm", seconds/60) } if seconds < 86400 { return fmt.Sprintf("%dh", seconds/3600) } return fmt.Sprintf("%dd", seconds/86400) } // DomainDescription returns a human-readable description of the domain func (info *LaunchdInfo) DomainDescription() string { switch { case info.Domain == "system": return "Launch Daemon" case strings.HasPrefix(info.Domain, "gui/"): return "Launch Agent" case info.Domain == "user": return "Launch Agent" default: return "launchd service" } } ================================================ FILE: internal/output/children.go ================================================ package output import ( "io" "github.com/pranshuparmar/witr/pkg/model" ) func PrintChildren(w io.Writer, root model.Process, children []model.Process, colorEnabled bool) { p := NewPrinter(w) rootName := root.Command if rootName == "" && root.Cmdline != "" { rootName = root.Cmdline } if rootName == "" { rootName = "unknown" } if colorEnabled { p.Printf("%sChildren%s of %s (%spid %d%s):\n", ColorGreen, ColorReset, rootName, ColorDim, root.PID, ColorReset) } else { p.Printf("Children of %s (pid %d):\n", rootName, root.PID) } if len(children) == 0 { if colorEnabled { p.Printf("%sNo child processes found.%s\n", ColorGreen, ColorReset) } else { p.Println("No child processes found.") } return } limit := 10 count := len(children) for i, child := range children { if i >= limit { remaining := count - limit if colorEnabled { p.Printf(" %s└─ %s... and %d more\n", ColorMagenta, ColorReset, remaining) } else { p.Printf(" └─ ... and %d more\n", remaining) } break } connector := "├─ " isLast := (i == count-1) || (i == limit-1 && count <= limit) if isLast { connector = "└─ " } childName := child.Command if childName == "" && child.Cmdline != "" { childName = child.Cmdline } if childName == "" { childName = "unknown" } if colorEnabled { p.Printf(" %s%s%s%s (%spid %d%s)\n", ColorMagenta, connector, ColorReset, childName, ColorDim, child.PID, ColorReset) } else { p.Printf(" %s%s (pid %d)\n", connector, childName, child.PID) } } } ================================================ FILE: internal/output/colors.go ================================================ package output var ( ColorReset = ansiString("\033[0m") ColorRed = ansiString("\033[31m") ColorGreen = ansiString("\033[32m") ColorBlue = ansiString("\033[34m") ColorCyan = ansiString("\033[36m") ColorMagenta = ansiString("\033[35m") ColorDim = ansiString("\033[2m") ColorDimYellow = ansiString("\033[2;33m") ) ================================================ FILE: internal/output/docker.go ================================================ package output import ( "encoding/json" "fmt" "io" "github.com/pranshuparmar/witr/pkg/model" ) // dockerSourceLabel builds the source label from a DockerPortMatch. func dockerSourceLabel(match *model.DockerPortMatch) string { if match.ComposeProject != "" && match.ComposeService != "" { return fmt.Sprintf("docker-compose: %s/%s", match.ComposeProject, match.ComposeService) } return "docker" } // RenderDockerFallback renders Docker container info when /proc scanning fails // but a container was identified via Docker CLI. func RenderDockerFallback(w io.Writer, portValue string, match *model.DockerPortMatch, colorEnabled bool) { out := NewPrinter(w) name := SanitizeTerminal(match.Name) image := SanitizeTerminal(match.Image) ports := SanitizeTerminal(match.Ports) // Target if colorEnabled { out.Printf("%sTarget%s : port %s\n\n", ColorBlue, ColorReset, portValue) } else { out.Printf("Target : port %s\n\n", portValue) } // Container if colorEnabled { out.Printf("%sContainer%s : %s%s%s\n", ColorBlue, ColorReset, ColorGreen, ansiString(name), ColorReset) } else { out.Printf("Container : %s\n", name) } // Image if colorEnabled { out.Printf("%sImage%s : %s\n", ColorBlue, ColorReset, ansiString(image)) } else { out.Printf("Image : %s\n", image) } // Ports if ports != "" { if colorEnabled { out.Printf("%sPorts%s : %s\n", ColorBlue, ColorReset, ansiString(ports)) } else { out.Printf("Ports : %s\n", ports) } } // Why It Exists if colorEnabled { out.Printf("\n%sWhy It Exists%s :\n ", ColorMagenta, ColorReset) out.Print("Docker Desktop (process not visible in current namespace)\n\n") } else { out.Printf("\nWhy It Exists :\n ") out.Print("Docker Desktop (process not visible in current namespace)\n\n") } // Source sourceLabel := dockerSourceLabel(match) if colorEnabled { out.Printf("%sSource%s : %s\n", ColorCyan, ColorReset, sourceLabel) } else { out.Printf("Source : %s\n", sourceLabel) } // Note if colorEnabled { out.Printf("\n%sNote%s : The owning process is not visible in this environment.\n", ColorDimYellow, ColorReset) out.Printf(" This is common when Docker Desktop runs in a separate namespace\n") out.Printf(" (e.g., WSL2 distro, macOS VM).\n") } else { out.Printf("\nNote : The owning process is not visible in this environment.\n") out.Printf(" This is common when Docker Desktop runs in a separate namespace\n") out.Printf(" (e.g., WSL2 distro, macOS VM).\n") } } // RenderDockerFallbackShort renders a single-line summary for --short mode. func RenderDockerFallbackShort(w io.Writer, portValue string, match *model.DockerPortMatch, colorEnabled bool) { out := NewPrinter(w) name := SanitizeTerminal(match.Name) image := SanitizeTerminal(match.Image) if colorEnabled { out.Printf("port %s → %s%s%s (%s) [%s]\n", portValue, ColorGreen, ansiString(name), ColorReset, ansiString(image), dockerSourceLabel(match)) } else { out.Printf("port %s → %s (%s) [%s]\n", portValue, name, image, dockerSourceLabel(match)) } } // DockerFallbackToJSON returns JSON output for a Docker fallback match. func DockerFallbackToJSON(portValue string, match *model.DockerPortMatch) (string, error) { type dockerResult struct { Target string ContainerID string ContainerName string Image string Ports string `json:",omitempty"` ComposeProject string `json:",omitempty"` ComposeService string `json:",omitempty"` Source string Note string } res := dockerResult{ Target: "port " + portValue, ContainerID: match.ID, ContainerName: match.Name, Image: match.Image, Ports: match.Ports, ComposeProject: match.ComposeProject, ComposeService: match.ComposeService, Source: dockerSourceLabel(match), Note: "The owning process is not visible in this environment. This is common when Docker Desktop runs in a separate namespace (e.g., WSL2 distro, macOS VM).", } data, err := json.MarshalIndent(res, "", " ") if err != nil { return "", err } return string(data), nil } ================================================ FILE: internal/output/docker_test.go ================================================ package output import ( "bytes" "encoding/json" "strings" "testing" "github.com/pranshuparmar/witr/pkg/model" ) func TestRenderDockerFallback(t *testing.T) { match := &model.DockerPortMatch{ ID: "abc123", Name: "my-container", Image: "nginx:latest", Ports: "0.0.0.0:8080->80/tcp", } var buf bytes.Buffer RenderDockerFallback(&buf, "8080", match, false) out := buf.String() expected := []string{ "Target : port 8080", "Container : my-container", "Image : nginx:latest", "Ports : 0.0.0.0:8080->80/tcp", "Why It Exists", "Docker Desktop", "Source : docker", "Note", } for _, want := range expected { if !strings.Contains(out, want) { t.Errorf("RenderDockerFallback output missing %q\nGot:\n%s", want, out) } } } func TestRenderDockerFallbackWithCompose(t *testing.T) { match := &model.DockerPortMatch{ ID: "abc123", Name: "myapp-db-1", Image: "postgres:16", Ports: "0.0.0.0:5432->5432/tcp", ComposeProject: "myapp", ComposeService: "db", } var buf bytes.Buffer RenderDockerFallback(&buf, "5432", match, false) out := buf.String() if !strings.Contains(out, "docker-compose: myapp/db") { t.Errorf("expected compose source label, got:\n%s", out) } } func TestRenderDockerFallbackShort(t *testing.T) { match := &model.DockerPortMatch{ ID: "abc123", Name: "my-container", Image: "nginx:latest", Ports: "0.0.0.0:8080->80/tcp", } var buf bytes.Buffer RenderDockerFallbackShort(&buf, "8080", match, false) out := buf.String() if !strings.Contains(out, "my-container") { t.Errorf("short output missing container name, got: %s", out) } if !strings.Contains(out, "nginx:latest") { t.Errorf("short output missing image, got: %s", out) } if !strings.Contains(out, "[docker]") { t.Errorf("short output missing source, got: %s", out) } // Should be a single line if strings.Count(out, "\n") != 1 { t.Errorf("short output should be single line, got: %s", out) } } func TestDockerFallbackToJSON(t *testing.T) { match := &model.DockerPortMatch{ ID: "abc123", Name: "sql-proxy", Image: "gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.13.0", Ports: "127.0.0.1:5432->5432/tcp", ComposeProject: "", ComposeService: "", } jsonStr, err := DockerFallbackToJSON("5432", match) if err != nil { t.Fatalf("DockerFallbackToJSON() error: %v", err) } var result map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { t.Fatalf("invalid JSON output: %v", err) } if result["Target"] != "port 5432" { t.Errorf("Target = %v, want %q", result["Target"], "port 5432") } if result["ContainerName"] != "sql-proxy" { t.Errorf("ContainerName = %v, want %q", result["ContainerName"], "sql-proxy") } if result["Source"] != "docker" { t.Errorf("Source = %v, want %q", result["Source"], "docker") } } func TestDockerFallbackToJSONCompose(t *testing.T) { match := &model.DockerPortMatch{ ID: "def456", Name: "myapp-db-1", Image: "postgres:16", Ports: "0.0.0.0:5432->5432/tcp", ComposeProject: "myapp", ComposeService: "db", } jsonStr, err := DockerFallbackToJSON("5432", match) if err != nil { t.Fatalf("DockerFallbackToJSON() error: %v", err) } var result map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { t.Fatalf("invalid JSON output: %v", err) } if result["Source"] != "docker-compose: myapp/db" { t.Errorf("Source = %v, want %q", result["Source"], "docker-compose: myapp/db") } } func TestRenderDockerFallbackSanitizesOutput(t *testing.T) { // Simulate a malicious container name with ANSI escape sequence match := &model.DockerPortMatch{ ID: "abc123", Name: "evil\x1b[31mcontainer", Image: "evil\x1b[0mimage", Ports: "0.0.0.0:80->80/tcp", } var buf bytes.Buffer RenderDockerFallback(&buf, "80", match, false) out := buf.String() if strings.Contains(out, "\x1b") { t.Errorf("output contains raw ANSI escape sequences, sanitization failed:\n%s", out) } } ================================================ FILE: internal/output/envonly.go ================================================ package output import ( "io" "github.com/pranshuparmar/witr/pkg/model" ) // RenderEnvOnly prints only the command and environment variables for a process func RenderEnvOnly(w io.Writer, r model.Result, colorEnabled bool) { p := NewPrinter(w) colorResetEnv := ansiString("") colorBlueEnv := ansiString("") colorRedEnv := ansiString("") colorGreenEnv := ansiString("") colorDimEnv := ansiString("") if colorEnabled { colorResetEnv = ColorReset colorBlueEnv = ColorBlue colorRedEnv = ColorRed colorGreenEnv = ColorGreen colorDimEnv = ColorDim } procName := r.Process.Command if len(r.Ancestry) > 0 { procName = r.Ancestry[len(r.Ancestry)-1].Command } if colorEnabled { p.Printf("%sProcess%s : %s%s%s (%spid %d%s)\n", colorBlueEnv, colorResetEnv, colorGreenEnv, procName, colorResetEnv, colorDimEnv, r.Process.PID, colorResetEnv) } else { p.Printf("Process : %s (pid %d)\n", procName, r.Process.PID) } p.Printf("%sCommand%s : %s\n", colorGreenEnv, colorResetEnv, r.Process.Cmdline) if len(r.Process.Env) > 0 { p.Printf("%sEnvironment%s :\n", colorBlueEnv, colorResetEnv) for _, env := range r.Process.Env { p.Printf(" %s\n", env) } } else { p.Printf("%sEnvironment%s : %sNo environment variables found.%s\n", colorBlueEnv, colorResetEnv, colorRedEnv, colorResetEnv) } } ================================================ FILE: internal/output/json.go ================================================ package output import ( "encoding/json" "github.com/pranshuparmar/witr/pkg/model" ) func ToJSON(r model.Result) (string, error) { data, err := json.MarshalIndent(r, "", " ") if err != nil { return "", err } return string(data), nil } type shortProcess struct { PID int Command string } func ToShortJSON(r model.Result) (string, error) { ancestry := make([]shortProcess, len(r.Ancestry)) for i, p := range r.Ancestry { ancestry[i] = shortProcess{PID: p.PID, Command: p.Command} } data, err := json.MarshalIndent(ancestry, "", " ") if err != nil { return "", err } return string(data), nil } func ToTreeJSON(r model.Result) (string, error) { type treeResult struct { Ancestry []shortProcess Children []shortProcess `json:",omitempty"` } res := treeResult{ Ancestry: make([]shortProcess, len(r.Ancestry)), } for i, p := range r.Ancestry { res.Ancestry[i] = shortProcess{PID: p.PID, Command: p.Command} } if len(r.Children) > 0 { res.Children = make([]shortProcess, len(r.Children)) for i, p := range r.Children { res.Children[i] = shortProcess{PID: p.PID, Command: p.Command} } } data, err := json.MarshalIndent(res, "", " ") if err != nil { return "", err } return string(data), nil } func ToWarningsJSON(r model.Result) (string, error) { type warningResult struct { PID int Process string Command string Warnings []string } procName := "unknown" if len(r.Ancestry) > 0 { procName = r.Ancestry[len(r.Ancestry)-1].Command } else if r.Process.Command != "" { procName = r.Process.Command } cmdLine := r.Process.Cmdline if cmdLine == "" { cmdLine = r.Process.Command } warnings := r.Warnings if warnings == nil { warnings = []string{} } res := warningResult{ PID: r.Process.PID, Process: procName, Command: cmdLine, Warnings: warnings, } data, err := json.MarshalIndent(res, "", " ") if err != nil { return "", err } return string(data), nil } func ToEnvJSON(r model.Result) (string, error) { type envResult struct { PID int Process string Command string Env []string } procName := "unknown" if len(r.Ancestry) > 0 { procName = r.Ancestry[len(r.Ancestry)-1].Command } else if r.Process.Command != "" { procName = r.Process.Command } res := envResult{ PID: r.Process.PID, Process: procName, Command: r.Process.Cmdline, Env: r.Process.Env, } data, err := json.MarshalIndent(res, "", " ") if err != nil { return "", err } return string(data), nil } ================================================ FILE: internal/output/printer.go ================================================ package output import ( "fmt" "io" ) type ansiString string // Printer writes terminal-safe output to an io.Writer // sanitizing any string-like arguments (string, []byte, error, fmt.Stringer) type Printer struct { w io.Writer } func NewPrinter(w io.Writer) Printer { return Printer{w: w} } func (p Printer) Printf(format string, args ...any) { fmt.Fprintf(p.w, format, sanitizePrintArgs(args)...) } func (p Printer) Print(args ...any) { fmt.Fprint(p.w, sanitizePrintArgs(args)...) } func (p Printer) Println(args ...any) { fmt.Fprintln(p.w, sanitizePrintArgs(args)...) } func sanitizePrintArgs(args []any) []any { if len(args) == 0 { return nil } out := make([]any, len(args)) for i, a := range args { switch v := a.(type) { case ansiString: // our own ansiString type is allowed to render as-is out[i] = string(v) case string: out[i] = SanitizeTerminal(v) case []byte: out[i] = SanitizeTerminal(string(v)) case error: out[i] = SanitizeTerminal(v.Error()) case fmt.Stringer: out[i] = SanitizeTerminal(v.String()) default: out[i] = a } } return out } ================================================ FILE: internal/output/safe_writer.go ================================================ package output import "io" // SafeTerminalWriter sanitizes all bytes written to it so the output is safe to // display in an interactive terminal, it should be used to print anything that // we don't control (like processes' args, env vars, ...) type SafeTerminalWriter struct { W io.Writer } func (w SafeTerminalWriter) Write(p []byte) (int, error) { if len(p) == 0 { return 0, nil } _, err := io.WriteString(w.W, SanitizeTerminal(string(p))) if err != nil { return 0, err } return len(p), nil } func NewSafeTerminalWriter(w io.Writer) io.Writer { return SafeTerminalWriter{W: w} } ================================================ FILE: internal/output/sanitize.go ================================================ package output import ( "strings" "unicode" "unicode/utf8" ) const hexDigits = "0123456789abcdef" // SanitizeTerminal makes a string safe to print to an interactive terminal // by just replacing control characters with visible escape sequences (e.g. "\x1b") // Examples: // - "hi\x1b[31mred" -> "hi\x1b[31mred" (ESC becomes visible) // - "nul:\x00" -> "nul:\x00" // - "bad:\xff" -> "bad:\xff" (invalid UTF-8 byte) // - "a\tb\nc" -> "a\tb\nc" (tabs/newlines are untouched) func SanitizeTerminal(s string) string { idx := 0 // fast path: scan until we find a control rune / invalid UTF-8 byte for idx < len(s) { r, size := utf8.DecodeRuneInString(s[idx:]) if r == utf8.RuneError && size == 1 { break } if r == '\n' || r == '\t' { idx += size continue } if unicode.IsControl(r) { break } idx += size } if idx == len(s) { return s } var b strings.Builder b.Grow(len(s) + 8) b.WriteString(s[:idx]) // slow path: walk the remainder and rewrite any control bytes/runes for idx < len(s) { r, size := utf8.DecodeRuneInString(s[idx:]) if r == utf8.RuneError && size == 1 { // preserve invalid bytes without letting them act as controls just in case appendEscapedByte(&b, s[idx]) idx++ continue } if r == '\n' || r == '\t' { b.WriteRune(r) idx += size continue } if unicode.IsControl(r) { appendEscapedRune(&b, r) idx += size continue } b.WriteString(s[idx : idx+size]) idx += size } return b.String() } func appendEscapedByte(b *strings.Builder, bt byte) { b.WriteString(`\\x`) b.WriteByte(hexDigits[bt>>4]) b.WriteByte(hexDigits[bt&0x0f]) } // while this looks extremely bad, it just outputs this: // - r = 0x1b -> "\x1b" // - r = 0x2028 -> "\u2028" // - r = 0x1f600 -> "\U0001f600" func appendEscapedRune(b *strings.Builder, r rune) { // 0xFF: "\xHH" (simple byte escape) if r <= 0xFF { appendEscapedByte(b, byte(r)) return } // <= 0xFFFF: "\uHHHH" (BMP escape) if r <= 0xFFFF { b.WriteString(`\\u`) b.WriteByte(hexDigits[(r>>12)&0x0f]) b.WriteByte(hexDigits[(r>>8)&0x0f]) b.WriteByte(hexDigits[(r>>4)&0x0f]) b.WriteByte(hexDigits[r&0x0f]) return } // otherwise: "\UHHHHHHHH" (full 32-bit escape) b.WriteString(`\\U`) b.WriteByte(hexDigits[(r>>28)&0x0f]) b.WriteByte(hexDigits[(r>>24)&0x0f]) b.WriteByte(hexDigits[(r>>20)&0x0f]) b.WriteByte(hexDigits[(r>>16)&0x0f]) b.WriteByte(hexDigits[(r>>12)&0x0f]) b.WriteByte(hexDigits[(r>>8)&0x0f]) b.WriteByte(hexDigits[(r>>4)&0x0f]) b.WriteByte(hexDigits[r&0x0f]) } ================================================ FILE: internal/output/sanitize_test.go ================================================ package output import ( "fmt" "strings" "testing" "unicode" ) func FuzzAppendEscapedRune(f *testing.F) { f.Add(uint32(0x00)) f.Add(uint32(0x1b)) f.Add(uint32(0x7f)) f.Add(uint32(0x80)) f.Add(uint32(0xff)) f.Add(uint32(0x100)) f.Add(uint32(0x20ac)) f.Add(uint32(0xffff)) f.Add(uint32(0x10000)) f.Add(uint32(0x10ffff)) f.Fuzz(func(t *testing.T, raw uint32) { // keep this within the valid Unicode scalar range r := rune(raw % (unicode.MaxRune + 1)) var b strings.Builder appendEscapedRune(&b, r) got := b.String() var want string switch { case r <= 0xFF: want = fmt.Sprintf(`\\x%02x`, r) case r <= 0xFFFF: want = fmt.Sprintf(`\\u%04x`, r) default: want = fmt.Sprintf(`\\U%08x`, r) } if got != want { t.Fatalf("appendEscapedRune(%#x) = %q, want %q", r, got, want) } // output must be visible ascii for i := 0; i < len(got); i++ { if got[i] >= 0x80 { t.Fatalf("appendEscapedRune(%#x) produced non-ASCII byte 0x%02x in %q", r, got[i], got) } } }) } ================================================ FILE: internal/output/short.go ================================================ package output import ( "io" "github.com/pranshuparmar/witr/pkg/model" ) func RenderShort(w io.Writer, r model.Result, colorEnabled bool) { p := NewPrinter(w) for i, proc := range r.Ancestry { if i > 0 { if colorEnabled { p.Printf("%s → %s", ColorMagenta, ColorReset) } else { p.Print(" → ") } } if colorEnabled { nameColor := ansiString("") if i == len(r.Ancestry)-1 { nameColor = ColorGreen } p.Printf("%s%s%s (%spid %d%s)", nameColor, proc.Command, ColorReset, ColorDim, proc.PID, ColorReset) } else { p.Printf("%s (pid %d)", proc.Command, proc.PID) } } p.Println() } ================================================ FILE: internal/output/standard.go ================================================ package output import ( "fmt" "io" "net" "sort" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // Maximum number of items to display in any list before truncating const MaxDisplayItems = 10 var detailLabels = map[string]string{ "type": " Type", "plist": " Plist", "triggers": " Trigger", "keepalive": " KeepAlive", } func formatDetailLabel(key string) string { if label, ok := detailLabels[key]; ok { return label } return " " + key } func RenderWarnings(w io.Writer, r model.Result, colorEnabled bool) { out := NewPrinter(w) proc := r.Process if len(r.Ancestry) > 0 { proc = r.Ancestry[len(r.Ancestry)-1] } proc.Command = SanitizeTerminal(proc.Command) if colorEnabled { out.Printf("%sProcess%s : %s%s%s (%spid %d%s)\n", ColorBlue, ColorReset, ColorGreen, proc.Command, ColorReset, ColorDim, proc.PID, ColorReset) if proc.Cmdline != "" { out.Printf("%sCommand%s : %s\n", ColorBlue, ColorReset, proc.Cmdline) } else { out.Printf("%sCommand%s : %s\n", ColorBlue, ColorReset, proc.Command) } } else { out.Printf("Process : %s (pid %d)\n", proc.Command, proc.PID) if proc.Cmdline != "" { out.Printf("Command : %s\n", proc.Cmdline) } else { out.Printf("Command : %s\n", proc.Command) } } if len(r.Warnings) == 0 { if colorEnabled { out.Printf("%sWarnings%s : %sNo warnings.%s\n", ColorRed, ColorReset, ColorGreen, ColorReset) } else { out.Println("Warnings : No warnings.") } return } if colorEnabled { out.Printf("%sWarnings%s :\n", ColorRed, ColorReset) for _, w := range r.Warnings { out.Printf(" • %s\n", SanitizeTerminal(w)) } } else { out.Println("Warnings :") for _, w := range r.Warnings { out.Printf(" • %s\n", SanitizeTerminal(w)) } } } func RenderStandard(w io.Writer, r model.Result, colorEnabled bool, verbose bool) { out := NewPrinter(w) if len(r.Ancestry) == 0 { out.Println("No process information available.") return } target := SanitizeTerminal(r.Ancestry[len(r.Ancestry)-1].Command) if colorEnabled { out.Printf("%sTarget%s : %s\n\n", ColorBlue, ColorReset, target) } else { out.Printf("Target : %s\n\n", target) } var proc = r.Ancestry[len(r.Ancestry)-1] proc.Command = SanitizeTerminal(proc.Command) proc.Cmdline = SanitizeTerminal(proc.Cmdline) proc.User = SanitizeTerminal(proc.User) proc.Container = SanitizeTerminal(proc.Container) proc.Service = SanitizeTerminal(proc.Service) proc.WorkingDir = SanitizeTerminal(proc.WorkingDir) proc.GitRepo = SanitizeTerminal(proc.GitRepo) proc.GitBranch = SanitizeTerminal(proc.GitBranch) if colorEnabled { out.Printf("%sProcess%s : %s%s%s (%spid %d%s)", ColorBlue, ColorReset, ColorGreen, proc.Command, ColorReset, ColorDim, proc.PID, ColorReset) } else { out.Printf("Process : %s (pid %d)", proc.Command, proc.PID) } // Health status if proc.Health != "" && proc.Health != "healthy" { health := SanitizeTerminal(proc.Health) healthColor := ColorRed if colorEnabled { out.Printf(" %s[%s]%s", healthColor, health, ColorReset) } else { out.Printf(" [%s]", health) } } // Forked status: only display if forked if proc.Forked == "forked" { forkColor := ColorDimYellow if colorEnabled { out.Printf(" %s{forked}%s", forkColor, ColorReset) } else { out.Printf(" {forked}") } } out.Println("") if proc.User != "" && proc.User != "unknown" { if colorEnabled { out.Printf("%sUser%s : %s\n", ColorBlue, ColorReset, proc.User) } else { out.Printf("User : %s\n", proc.User) } } // Container if proc.Container != "" { if colorEnabled { out.Printf("%sContainer%s : %s\n", ColorBlue, ColorReset, proc.Container) } else { out.Printf("Container : %s\n", proc.Container) } } // Service if proc.Service != "" { if colorEnabled { out.Printf("%sService%s : %s\n", ColorBlue, ColorReset, proc.Service) } else { out.Printf("Service : %s\n", proc.Service) } } if proc.Cmdline != "" { if colorEnabled { out.Printf("%sCommand%s : %s\n", ColorBlue, ColorReset, proc.Cmdline) } else { out.Printf("Command : %s\n", proc.Cmdline) } } else { if colorEnabled { out.Printf("%sCommand%s : %s\n", ColorBlue, ColorReset, proc.Command) } else { out.Printf("Command : %s\n", proc.Command) } } // Format as: 2 days ago (Mon 2025-02-02 11:42:10 +0530) startedAt := proc.StartedAt now := time.Now() dur := now.Sub(startedAt) var rel string switch { case dur.Hours() >= 48: days := int(dur.Hours()) / 24 rel = fmt.Sprintf("%d days ago", days) case dur.Hours() >= 24: rel = "1 day ago" case dur.Hours() >= 2: hours := int(dur.Hours()) rel = fmt.Sprintf("%d hours ago", hours) case dur.Minutes() >= 60: rel = "1 hour ago" default: mins := int(dur.Minutes()) if mins > 0 { rel = fmt.Sprintf("%d min ago", mins) } else { rel = "just now" } } dtStr := startedAt.Format("Mon 2006-01-02 15:04:05 -07:00") if colorEnabled { out.Printf("%sStarted%s : %s (%s)\n", ColorMagenta, ColorReset, rel, dtStr) } else { out.Printf("Started : %s (%s)\n", rel, dtStr) } if schedule, ok := r.Source.Details["schedule"]; ok { if colorEnabled { out.Printf("%sSchedule%s : %s\n", ColorMagenta, ColorReset, schedule) } else { out.Printf("Schedule : %s\n", schedule) } } // Why It Exists (short chain) if colorEnabled { out.Printf("\n%sWhy It Exists%s :\n ", ColorMagenta, ColorReset) for i, p := range r.Ancestry { name := p.Command if name == "" && p.Cmdline != "" { name = p.Cmdline } name = SanitizeTerminal(name) nameColor := ansiString("") if i == len(r.Ancestry)-1 { nameColor = ColorGreen } out.Printf("%s%s%s (%spid %d%s)", nameColor, name, ColorReset, ColorDim, p.PID, ColorReset) if i < len(r.Ancestry)-1 { out.Printf(" %s\u2192%s ", ColorMagenta, ColorReset) } } out.Print("\n\n") } else { out.Printf("\nWhy It Exists :\n ") for i, p := range r.Ancestry { name := p.Command if name == "" && p.Cmdline != "" { name = p.Cmdline } name = SanitizeTerminal(name) out.Printf("%s (pid %d)", name, p.PID) if i < len(r.Ancestry)-1 { out.Printf(" \u2192 ") } } out.Print("\n\n") } // Source sourceLabel := string(r.Source.Type) sourceName := SanitizeTerminal(r.Source.Name) if colorEnabled { if r.Source.Name != "" && r.Source.Name != sourceLabel { out.Printf("%sSource%s : %s (%s)\n", ColorCyan, ColorReset, sourceName, sourceLabel) } else { out.Printf("%sSource%s : %s\n", ColorCyan, ColorReset, sourceLabel) } } else { if r.Source.Name != "" && r.Source.Name != sourceLabel { out.Printf("Source : %s (%s)\n", sourceName, sourceLabel) } else { out.Printf("Source : %s\n", sourceLabel) } } // Description if r.Source.Description != "" { if colorEnabled { out.Printf("%sDescription%s : %s\n", ColorCyan, ColorReset, SanitizeTerminal(r.Source.Description)) } else { out.Printf("Description : %s\n", SanitizeTerminal(r.Source.Description)) } } // Unit File / Config Source if r.Source.UnitFile != "" { label := "Unit File" switch r.Source.Type { case model.SourceLaunchd: label = "Plist File" case model.SourceWindowsService: label = "Registry Key" case model.SourceBsdRc: label = "Rc Script" } var pad string if len(label) < 12 { pad = strings.Repeat(" ", 12-len(label)) } else { pad = " " } if colorEnabled { out.Printf("%s%s%s%s: %s\n", ColorCyan, label, ColorReset, pad, r.Source.UnitFile) } else { out.Printf("%s%s: %s\n", label, pad, r.Source.UnitFile) } } // Source details (launchd triggers, plist path, etc.) if len(r.Source.Details) > 0 { // Display in consistent order detailKeys := []string{"type", "plist", "triggers", "keepalive"} for _, key := range detailKeys { if val, ok := r.Source.Details[key]; ok { label := formatDetailLabel(key) if colorEnabled { out.Printf("%s%s%s : %s\n", ColorDim, label, ColorReset, SanitizeTerminal(val)) } else { out.Printf("%s : %s\n", label, SanitizeTerminal(val)) } } } } // Context group if colorEnabled { if proc.WorkingDir != "" && proc.WorkingDir != "unknown" { out.Printf("\n%sWorking Dir%s : %s\n", ColorCyan, ColorReset, proc.WorkingDir) } if proc.GitRepo != "" { if proc.GitBranch != "" { out.Printf("%sGit Repo%s : %s (%s)\n", ColorCyan, ColorReset, proc.GitRepo, proc.GitBranch) } else { out.Printf("%sGit Repo%s : %s\n", ColorCyan, ColorReset, proc.GitRepo) } } } else { if proc.WorkingDir != "" && proc.WorkingDir != "unknown" { out.Printf("\nWorking Dir : %s\n", proc.WorkingDir) } if proc.GitRepo != "" { if proc.GitBranch != "" { out.Printf("Git Repo : %s (%s)\n", proc.GitRepo, proc.GitBranch) } else { out.Printf("Git Repo : %s\n", proc.GitRepo) } } } // Listening section (address:port) if len(proc.ListeningPorts) > 0 && len(proc.BindAddresses) == len(proc.ListeningPorts) { count := len(proc.ListeningPorts) displayed := 0 for i := range proc.ListeningPorts { if displayed >= MaxDisplayItems { remaining := count - displayed out.Printf(" ... and %d more\n", remaining) break } addr := proc.BindAddresses[i] port := proc.ListeningPorts[i] if addr != "" && port > 0 { hostPort := net.JoinHostPort(addr, strconv.Itoa(port)) safeHostPort := SanitizeTerminal(hostPort) if colorEnabled { if i == 0 { out.Printf("%sListening%s : %s\n", ColorGreen, ColorReset, safeHostPort) } else { out.Printf(" %s\n", safeHostPort) } } else { if i == 0 { out.Printf("Listening : %s\n", safeHostPort) } else { out.Printf(" %s\n", safeHostPort) } } displayed++ } } } // Warnings if len(r.Warnings) > 0 { if colorEnabled { out.Printf("\n%sWarnings%s :\n", ColorRed, ColorReset) for _, w := range r.Warnings { out.Printf(" • %s\n", SanitizeTerminal(w)) } } else { out.Println("\nWarnings :") for _, w := range r.Warnings { out.Printf(" • %s\n", SanitizeTerminal(w)) } } } // Extended information for verbose mode if verbose { out.Println() // Resource context (thermal state, sleep prevention, CPU) if r.ResourceContext != nil { if colorEnabled { if r.ResourceContext.CPUUsage > 70 { out.Printf("%sCPU%s : %s%.1f%%%s\n", ColorRed, ColorReset, ColorDimYellow, r.ResourceContext.CPUUsage, ColorReset) } else { out.Printf("%sCPU%s : %.1f%%\n", ColorGreen, ColorReset, r.ResourceContext.CPUUsage) } } else { out.Printf("CPU : %.1f%%\n", r.ResourceContext.CPUUsage) } if r.ResourceContext.PreventsSleep { if colorEnabled { out.Printf("%sEnergy%s : %sPreventing system sleep%s\n", ColorRed, ColorReset, ColorDimYellow, ColorReset) } else { out.Printf("Energy : Preventing system sleep\n") } } if r.ResourceContext.ThermalState != "" { thermalState := SanitizeTerminal(r.ResourceContext.ThermalState) if colorEnabled { out.Printf("%sThermal%s : %s%s%s\n", ColorRed, ColorReset, ColorDimYellow, thermalState, ColorReset) } else { out.Printf("Thermal : %s\n", thermalState) } } } // Memory information if proc.Memory.VMS > 0 { if colorEnabled { out.Printf("\n%sMemory%s:\n", ColorGreen, ColorReset) out.Printf(" Virtual : %.1f MB\n", proc.Memory.VMSMB) out.Printf(" Resident : %.1f MB\n", proc.Memory.RSSMB) if r.ResourceContext != nil && r.ResourceContext.MemoryUsage > 0 { out.Printf(" Private : %.1f MB\n", float64(r.ResourceContext.MemoryUsage)/(1024*1024)) } if proc.Memory.Shared > 0 { out.Printf(" Shared : %.1f MB\n", float64(proc.Memory.Shared)/(1024*1024)) } } else { out.Printf("\nMemory:\n") out.Printf(" Virtual : %.1f MB\n", proc.Memory.VMSMB) out.Printf(" Resident : %.1f MB\n", proc.Memory.RSSMB) if r.ResourceContext != nil && r.ResourceContext.MemoryUsage > 0 { out.Printf(" Private : %.1f MB\n", float64(r.ResourceContext.MemoryUsage)/(1024*1024)) } if proc.Memory.Shared > 0 { out.Printf(" Shared : %.1f MB\n", float64(proc.Memory.Shared)/(1024*1024)) } } } // I/O statistics if proc.IO.ReadBytes > 0 || proc.IO.WriteBytes > 0 { if colorEnabled { out.Printf("\n%sI/O Statistics%s:\n", ColorGreen, ColorReset) if proc.IO.ReadBytes > 0 { out.Printf(" Read : %.1f MB (%d ops)\n", float64(proc.IO.ReadBytes)/(1024*1024), proc.IO.ReadOps) } if proc.IO.WriteBytes > 0 { out.Printf(" Write : %.1f MB (%d ops)\n", float64(proc.IO.WriteBytes)/(1024*1024), proc.IO.WriteOps) } } else { out.Printf("\nI/O Statistics:\n") if proc.IO.ReadBytes > 0 { out.Printf(" Read : %.1f MB (%d ops)\n", float64(proc.IO.ReadBytes)/(1024*1024), proc.IO.ReadOps) } if proc.IO.WriteBytes > 0 { out.Printf(" Write : %.1f MB (%d ops)\n", float64(proc.IO.WriteBytes)/(1024*1024), proc.IO.WriteOps) } } } // File context (open files, locks) if r.FileContext != nil { if r.FileContext.OpenFiles > 0 && r.FileContext.FileLimit == 0 { if colorEnabled { out.Printf("\n%sOpen Files%s : %d of unlimited\n", ColorGreen, ColorReset, r.FileContext.OpenFiles) } else { out.Printf("\nOpen Files : %d of unlimited\n", r.FileContext.OpenFiles) } } if r.FileContext.OpenFiles > 0 && r.FileContext.FileLimit > 0 { usagePercent := float64(r.FileContext.OpenFiles) / float64(r.FileContext.FileLimit) * 100 if colorEnabled { if usagePercent > 80 { out.Printf("\n%sOpen Files%s : %s%d of %d (%.0f%%)%s\n", ColorRed, ColorReset, ColorDimYellow, r.FileContext.OpenFiles, r.FileContext.FileLimit, usagePercent, ColorReset) } else { out.Printf("\n%sOpen Files%s : %d of %d (%.0f%%)\n", ColorGreen, ColorReset, r.FileContext.OpenFiles, r.FileContext.FileLimit, usagePercent) } } else { out.Printf("\nOpen Files : %d of %d (%.0f%%)\n", r.FileContext.OpenFiles, r.FileContext.FileLimit, usagePercent) } } if len(r.FileContext.LockedFiles) > 0 { count := len(r.FileContext.LockedFiles) firstLocked := SanitizeTerminal(r.FileContext.LockedFiles[0]) if colorEnabled { out.Printf("%sLocks%s : %s\n", ColorGreen, ColorReset, firstLocked) } else { out.Printf("Locks : %s\n", firstLocked) } for i, f := range r.FileContext.LockedFiles[1:] { if 1+i >= MaxDisplayItems { remaining := count - (1 + i) out.Printf(" ... and %d more\n", remaining) break } out.Printf(" %s\n", SanitizeTerminal(f)) } } } // File descriptors if proc.FDCount > 0 { // Sort file descriptors numerically sort.Slice(proc.FileDescs, func(i, j int) bool { fdI := proc.FileDescs[i] fdJ := proc.FileDescs[j] idxI := strings.Index(fdI, " ") idxJ := strings.Index(fdJ, " ") if idxI == -1 || idxJ == -1 { return fdI < fdJ } numI, errI := strconv.Atoi(fdI[:idxI]) numJ, errJ := strconv.Atoi(fdJ[:idxJ]) if errI == nil && errJ == nil { return numI < numJ } return fdI < fdJ }) if colorEnabled { if proc.FDLimit == 0 { out.Printf("\n%sFile Descriptors%s: %d/unlimited\n", ColorGreen, ColorReset, proc.FDCount) } else { out.Printf("\n%sFile Descriptors%s: %d/%d\n", ColorGreen, ColorReset, proc.FDCount, proc.FDLimit) } if len(proc.FileDescs) > 0 && len(proc.FileDescs) <= MaxDisplayItems { for _, fd := range proc.FileDescs { safeFd := SanitizeTerminal(fd) safeFd = strings.Replace(safeFd, "->", string(ColorMagenta)+"->"+string(ColorReset), 1) out.Printf(" %s\n", ansiString(safeFd)) } } else if len(proc.FileDescs) > MaxDisplayItems { out.Printf(" Showing first %d of %d descriptors:\n", MaxDisplayItems, len(proc.FileDescs)) for i := 0; i < MaxDisplayItems; i++ { safeFd := SanitizeTerminal(proc.FileDescs[i]) safeFd = strings.Replace(safeFd, "->", string(ColorMagenta)+"->"+string(ColorReset), 1) out.Printf(" %s\n", ansiString(safeFd)) } out.Printf(" ... and %d more\n", len(proc.FileDescs)-MaxDisplayItems) } } else { if proc.FDLimit == 0 { out.Printf("\nFile Descriptors: %d/unlimited\n", proc.FDCount) } else { out.Printf("\nFile Descriptors: %d/%d\n", proc.FDCount, proc.FDLimit) } if len(proc.FileDescs) > 0 && len(proc.FileDescs) <= MaxDisplayItems { for _, fd := range proc.FileDescs { out.Printf(" %s\n", SanitizeTerminal(fd)) } } else if len(proc.FileDescs) > MaxDisplayItems { out.Printf(" Showing first %d of %d descriptors:\n", MaxDisplayItems, len(proc.FileDescs)) for i := 0; i < MaxDisplayItems; i++ { out.Printf(" %s\n", SanitizeTerminal(proc.FileDescs[i])) } out.Printf(" ... and %d more\n", len(proc.FileDescs)-MaxDisplayItems) } } } // Socket state (for port queries) if r.SocketInfo != nil { state := SanitizeTerminal(r.SocketInfo.State) explanation := SanitizeTerminal(r.SocketInfo.Explanation) workaround := SanitizeTerminal(r.SocketInfo.Workaround) if colorEnabled { out.Printf("%sSocket%s : %s\n", ColorGreen, ColorReset, state) if explanation != "" { out.Printf(" %s\n", explanation) } if workaround != "" { out.Printf(" %s%s%s\n", ColorDimYellow, workaround, ColorReset) } } else { out.Printf("Socket : %s\n", state) if explanation != "" { out.Printf(" %s\n", explanation) } if workaround != "" { out.Printf(" %s\n", workaround) } } } // Threads if proc.ThreadCount > 1 { if colorEnabled { out.Printf("\n%sThreads%s: %d\n", ColorGreen, ColorReset, proc.ThreadCount) } else { out.Printf("\nThreads: %d\n", proc.ThreadCount) } } // Child processes if len(r.Children) > 0 { out.Println("") PrintChildren(w, r.Process, r.Children, colorEnabled) } } } ================================================ FILE: internal/output/tree.go ================================================ package output import ( "io" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func PrintTree(w io.Writer, chain []model.Process, children []model.Process, colorEnabled bool) { p := NewPrinter(w) for i, proc := range chain { indent := strings.Repeat(" ", i) if i > 0 { if colorEnabled { p.Printf("%s%s└─ %s", indent, ColorMagenta, ColorReset) } else { p.Printf("%s└─ ", indent) } } if colorEnabled { cmdColor := ansiString("") if i == len(chain)-1 { cmdColor = ColorGreen } p.Printf("%s%s%s (%spid %d%s)\n", cmdColor, proc.Command, ColorReset, ColorDim, proc.PID, ColorReset) } else { p.Printf("%s (pid %d)\n", proc.Command, proc.PID) } } if len(children) == 0 { return } baseIndent := strings.Repeat(" ", len(chain)) limit := 10 count := len(children) for i, child := range children { if i >= limit { remaining := count - limit if colorEnabled { p.Printf("%s%s└─ %s... and %d more\n", baseIndent, ColorMagenta, ColorReset, remaining) } else { p.Printf("%s└─ ... and %d more\n", baseIndent, remaining) } break } connector := "├─ " isLast := (i == count-1) || (i == limit-1 && count <= limit) if isLast { connector = "└─ " } if colorEnabled { p.Printf("%s%s%s%s%s (%spid %d%s)\n", baseIndent, ColorMagenta, connector, ColorReset, child.Command, ColorDim, child.PID, ColorReset) } else { p.Printf("%s%s%s (pid %d)\n", baseIndent, connector, child.Command, child.PID) } } } ================================================ FILE: internal/pipeline/analyze.go ================================================ package pipeline import ( "sort" "strconv" procpkg "github.com/pranshuparmar/witr/internal/proc" "github.com/pranshuparmar/witr/internal/source" "github.com/pranshuparmar/witr/pkg/model" ) type AnalyzeConfig struct { PID int Verbose bool Tree bool Target model.Target } func AnalyzePID(cfg AnalyzeConfig) (model.Result, error) { ancestry, err := procpkg.ResolveAncestry(cfg.PID) if err != nil { return model.Result{}, err } src := source.Detect(ancestry) var proc model.Process resolvedTarget := "unknown" if len(ancestry) > 0 { proc = ancestry[len(ancestry)-1] resolvedTarget = proc.Command } // Collect child PIDs once and reuse for both extended info and tree output var childPIDs []int var childProcesses []model.Process if (cfg.Verbose || cfg.Tree) && proc.PID > 0 { snapshot, err := procpkg.ListProcessSnapshot() if err == nil { for _, p := range snapshot { if p.PPID == proc.PID { childPIDs = append(childPIDs, p.PID) childProcesses = append(childProcesses, p) } } sort.Slice(childProcesses, func(i, j int) bool { return childProcesses[i].PID < childProcesses[j].PID }) sort.Ints(childPIDs) } } if cfg.Verbose && len(ancestry) > 0 { memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, err := procpkg.ReadExtendedInfo(cfg.PID) if err == nil { proc.Memory = memInfo proc.IO = ioStats proc.FileDescs = fileDescs proc.FDCount = fdCount proc.FDLimit = fdLimit proc.Children = childPIDs proc.ThreadCount = threadCount ancestry[len(ancestry)-1] = proc } } var resCtx *model.ResourceContext var fileCtx *model.FileContext if cfg.Verbose { resCtx = procpkg.GetResourceContext(cfg.PID) fileCtx = procpkg.GetFileContext(cfg.PID) } restartCount := 0 if src.Type == model.SourceSystemd { if v, ok := src.Details["NRestarts"]; ok { if count, err := strconv.Atoi(v); err == nil { restartCount = count } } } res := model.Result{ Target: cfg.Target, ResolvedTarget: resolvedTarget, Process: proc, RestartCount: restartCount, Ancestry: ancestry, Source: src, Warnings: source.Warnings(ancestry, src.Type), ResourceContext: resCtx, FileContext: fileCtx, Children: childProcesses, } return res, nil } ================================================ FILE: internal/proc/ancestry.go ================================================ package proc import ( "fmt" "github.com/pranshuparmar/witr/pkg/model" ) func ResolveAncestry(pid int) ([]model.Process, error) { var chain []model.Process seen := make(map[int]bool) current := pid for current > 0 { if seen[current] { break // loop protection } seen[current] = true p, err := ReadProcess(current) if err != nil { break } chain = append(chain, p) if p.PPID == 0 || p.PID == 1 { break } current = p.PPID } if len(chain) == 0 { return nil, fmt.Errorf("no process ancestry found") } // Reverse the chain to get root for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { chain[i], chain[j] = chain[j], chain[i] } return chain, nil } ================================================ FILE: internal/proc/boot_darwin.go ================================================ //go:build darwin package proc import ( "os/exec" "strconv" "strings" "time" ) func bootTime() time.Time { // Use sysctl kern.boottime on macOS out, err := exec.Command("sysctl", "-n", "kern.boottime").Output() if err != nil { return time.Now() } // Output format: { sec = 1703123456, usec = 123456 } ... outStr := string(out) if idx := strings.Index(outStr, "sec = "); idx != -1 { start := idx + 6 end := strings.Index(outStr[start:], ",") if end != -1 { secStr := outStr[start : start+end] sec, err := strconv.ParseInt(strings.TrimSpace(secStr), 10, 64) if err == nil { return time.Unix(sec, 0) } } } return time.Now() } func ticksPerSecond() int { return 100 // macOS default (same as Linux) } ================================================ FILE: internal/proc/boot_freebsd.go ================================================ //go:build freebsd package proc import ( "os/exec" "strconv" "strings" "time" ) func bootTime() time.Time { // Use sysctl kern.boottime on FreeBSD (same format as macOS) out, err := exec.Command("sysctl", "-n", "kern.boottime").Output() if err != nil { return time.Now() } // Output format: { sec = 1703123456, usec = 123456 } ... outStr := string(out) if idx := strings.Index(outStr, "sec = "); idx != -1 { start := idx + 6 end := strings.Index(outStr[start:], ",") if end != -1 { secStr := outStr[start : start+end] sec, err := strconv.ParseInt(strings.TrimSpace(secStr), 10, 64) if err == nil { return time.Unix(sec, 0) } } } return time.Now() } func ticksPerSecond() int { // FreeBSD default (same as Linux/macOS) return 100 } ================================================ FILE: internal/proc/boot_linux.go ================================================ //go:build linux package proc import ( "bufio" "os" "strconv" "strings" "time" ) func bootTime() time.Time { f, err := os.Open("/proc/stat") if err != nil { return time.Now() } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "btime") { parts := strings.Fields(line) if len(parts) < 2 { continue } sec, _ := strconv.ParseInt(parts[1], 10, 64) return time.Unix(sec, 0) } } return time.Now() } func ticksPerSecond() int { return 100 // Linux default; portable enough for now } ================================================ FILE: internal/proc/boot_windows.go ================================================ //go:build windows package proc import ( "os/exec" "strings" "time" ) func bootTime() time.Time { // powershell Get-CimInstance Win32_OperatingSystem out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -ExpandProperty LastBootUpTime | Get-Date -Format 'yyyyMMddHHmmss'").Output() if err != nil { return time.Now() } // Output format: // 20231025123456 val := strings.TrimSpace(string(out)) if len(val) < 14 { return time.Now() } // Parse 20231025123456 t, err := time.ParseInLocation("20060102150405", val[:14], time.Local) if err != nil { return time.Now() } return t } ================================================ FILE: internal/proc/capabilities_linux.go ================================================ //go:build linux package proc import ( "fmt" "os" "strconv" "strings" ) // Capability bit positions from include/uapi/linux/capability.h var capNames = map[int]string{ 0: "CAP_CHOWN", 1: "CAP_DAC_OVERRIDE", 2: "CAP_DAC_READ_SEARCH", 3: "CAP_FOWNER", 4: "CAP_FSETID", 5: "CAP_KILL", 6: "CAP_SETGID", 7: "CAP_SETUID", 8: "CAP_SETPCAP", 9: "CAP_LINUX_IMMUTABLE", 10: "CAP_NET_BIND_SERVICE", 11: "CAP_NET_BROADCAST", 12: "CAP_NET_ADMIN", 13: "CAP_NET_RAW", 14: "CAP_IPC_LOCK", 15: "CAP_IPC_OWNER", 16: "CAP_SYS_MODULE", 17: "CAP_SYS_RAWIO", 18: "CAP_SYS_CHROOT", 19: "CAP_SYS_PTRACE", 20: "CAP_SYS_PACCT", 21: "CAP_SYS_ADMIN", 22: "CAP_SYS_BOOT", 23: "CAP_SYS_NICE", 24: "CAP_SYS_RESOURCE", 25: "CAP_SYS_TIME", 26: "CAP_SYS_TTY_CONFIG", 27: "CAP_MKNOD", 28: "CAP_LEASE", 29: "CAP_AUDIT_WRITE", 30: "CAP_AUDIT_CONTROL", 31: "CAP_SETFCAP", 32: "CAP_MAC_OVERRIDE", 33: "CAP_MAC_ADMIN", 34: "CAP_SYSLOG", 35: "CAP_WAKE_ALARM", 36: "CAP_BLOCK_SUSPEND", 37: "CAP_AUDIT_READ", 38: "CAP_PERFMON", 39: "CAP_BPF", 40: "CAP_CHECKPOINT_RESTORE", } // ReadCapabilities reads the effective capabilities of a process from /proc//status. func ReadCapabilities(pid int) []string { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) if err != nil { return nil } for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, "CapEff:\t") { hex := strings.TrimSpace(strings.TrimPrefix(line, "CapEff:")) return decodeCapabilities(hex) } } return nil } // decodeCapabilities converts a hex capability bitmask into named capabilities. func decodeCapabilities(hex string) []string { val, err := strconv.ParseUint(hex, 16, 64) if err != nil { return nil } if val == 0 { return nil } var caps []string for bit := 0; bit < 64; bit++ { if val&(1<= 2 { pidStr := strings.Trim(parts[len(parts)-1], "\"") name := strings.Trim(parts[len(parts)-2], "\"") cpid, err := strconv.Atoi(pidStr) if err != nil { continue } children = append(children, model.Process{ PID: cpid, PPID: pid, Command: name, }) } } sortProcesses(children) return children, nil } ================================================ FILE: internal/proc/cmdline_darwin.go ================================================ //go:build darwin package proc import ( "os/exec" "strconv" "strings" ) // GetCmdline returns the command line for a given PID func GetCmdline(pid int) string { out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output() if err != nil { return "(unknown)" } cmdline := strings.TrimSpace(string(out)) if cmdline == "" { return "(unknown)" } return cmdline } ================================================ FILE: internal/proc/cmdline_freebsd.go ================================================ //go:build freebsd package proc import ( "os/exec" "strconv" "strings" ) // GetCmdline returns the command line for a given PID func GetCmdline(pid int) string { // FreeBSD syntax: ps -p -o args out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args").Output() if err != nil { return "(unknown)" } // Skip header line lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) < 2 { return "(unknown)" } cmdline := strings.TrimSpace(lines[1]) if cmdline == "" { return "(unknown)" } return cmdline } ================================================ FILE: internal/proc/cmdline_linux.go ================================================ //go:build linux package proc import ( "fmt" "os" "strings" ) // GetCmdline returns the command line for a given PID func GetCmdline(pid int) string { cmdlineBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) if err != nil { return "(unknown)" } cmd := strings.ReplaceAll(string(cmdlineBytes), "\x00", " ") cmdline := strings.TrimSpace(cmd) if cmdline == "" { return "(unknown)" } return cmdline } ================================================ FILE: internal/proc/cmdline_windows.go ================================================ //go:build windows package proc import ( "fmt" "os/exec" "strings" ) // GetCmdline returns the command line for a given PID func GetCmdline(pid int) string { // powershell Get-CimInstance ... out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", fmt.Sprintf("Get-CimInstance -ClassName Win32_Process -Filter \"ProcessId=%d\" | Select-Object -ExpandProperty CommandLine", pid)).Output() if err != nil { return "(unknown)" } val := strings.TrimSpace(string(out)) if val == "" { return "(unknown)" } return val } ================================================ FILE: internal/proc/command.go ================================================ package proc import ( "path/filepath" "strings" ) // deriveDisplayCommand returns a human-readable command name that avoids // kernel comm-field truncation (typically 15-16 chars on Linux/macOS/FreeBSD) // by falling back to the executable name extracted from the full command line // when the short name looks clipped. func deriveDisplayCommand(comm, cmdline string) string { trimmedComm := strings.TrimSpace(comm) exe := extractExecutableName(cmdline) if trimmedComm == "" { return exe } if exe == "" { return trimmedComm } if strings.HasPrefix(exe, trimmedComm) && len(trimmedComm) < len(exe) { return exe } return trimmedComm } // containsWholeWord checks if s contains word as a standalone token, // not as a substring of a larger number or identifier. func containsWholeWord(s, word string) bool { idx := 0 for { i := strings.Index(s[idx:], word) if i < 0 { return false } start := idx + i end := start + len(word) leftOK := start == 0 || !isWordChar(s[start-1]) rightOK := end == len(s) || !isWordChar(s[end]) if leftOK && rightOK { return true } idx = start + 1 } } func isWordChar(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' } func extractExecutableName(cmdline string) string { args := splitCmdline(cmdline) for _, arg := range args { if arg == "" { continue } if strings.Contains(arg, "=") && !strings.Contains(arg, "/") { // Skip leading environment assignments. continue } clean := strings.Trim(arg, "\"'") if clean == "" { continue } base := filepath.Base(clean) if base == "." || base == "" || base == "/" { continue } return base } return "" } ================================================ FILE: internal/proc/command_test.go ================================================ package proc import ( "testing" ) func TestDeriveDisplayCommand(t *testing.T) { t.Parallel() tests := []struct { name string comm string cmdline string want string }{ { name: "falls back to executable when ps truncates name", comm: "AccessibilityVis", cmdline: "/System/Library/PrivateFrameworks/AccessibilitySupport.framework/Versions/A/Resources/AccessibilityVisualsAgent.app/Contents/MacOS/AccessibilityVisualsAgent", want: "AccessibilityVisualsAgent", }, { name: "keeps comm when executable does not share prefix", comm: "python3", cmdline: "python3 /tmp/script.py", want: "python3", }, { name: "uses executable when comm empty", comm: "", cmdline: "\"/Applications/App Name/MyBinary\" --flag", want: "MyBinary", }, { name: "ignores env assignments before executable", comm: "AccessibilityUIServer", cmdline: "PATH=/usr/bin /System/Library/CoreServices/AccessibilityUIServer.app/Contents/MacOS/AccessibilityUIServer", want: "AccessibilityUIServer", }, { name: "recovers truncated Linux comm (15 char limit)", comm: "my-very-long-pr", cmdline: "/usr/local/bin/my-very-long-process-name --daemon", want: "my-very-long-process-name", }, { name: "handles nginx-style cmdline where first token has colon", comm: "nginx", cmdline: "nginx: master process /usr/sbin/nginx", want: "nginx:", }, { name: "keeps comm when it matches exe exactly", comm: "nginx", cmdline: "/usr/sbin/nginx -g daemon off;", want: "nginx", }, { name: "returns comm when both empty", comm: "", cmdline: "", want: "", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := deriveDisplayCommand(tt.comm, tt.cmdline); got != tt.want { t.Fatalf("deriveDisplayCommand(%q, %q) = %q, want %q", tt.comm, tt.cmdline, got, tt.want) } }) } } func TestContainsWholeWord(t *testing.T) { t.Parallel() tests := []struct { s, word string want bool }{ {"pid 12 sleep", "12", true}, {"pid 120 sleep", "12", false}, {"pid 312 sleep", "12", false}, {"12 sleep", "12", true}, {"sleep 12", "12", true}, {"(12)", "12", true}, {"pid:12:sleep", "12", true}, {"", "12", false}, {"no match here", "12", false}, } for _, tt := range tests { if got := containsWholeWord(tt.s, tt.word); got != tt.want { t.Errorf("containsWholeWord(%q, %q) = %v, want %v", tt.s, tt.word, got, tt.want) } } } func TestExtractExecutableName(t *testing.T) { t.Parallel() tests := []struct { name string cmdline string want string }{ { name: "handles quoted path with spaces", cmdline: "\"/Applications/Visual Tool.app/Contents/MacOS/Visual Tool\" --flag", want: "Visual Tool", }, { name: "skips env assignment tokens", cmdline: "FOO=bar BAR=baz /usr/local/bin/server --mode production", want: "server", }, { name: "returns empty when no executable found", cmdline: "", want: "", }, { name: "handles simple command", cmdline: "/usr/bin/my-very-long-process-name --flag", want: "my-very-long-process-name", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := extractExecutableName(tt.cmdline); got != tt.want { t.Fatalf("extractExecutableName(%q) = %q, want %q", tt.cmdline, got, tt.want) } }) } } ================================================ FILE: internal/proc/container.go ================================================ package proc import ( "context" "fmt" "os/exec" "strings" "time" "unicode" "github.com/pranshuparmar/witr/pkg/model" ) // ResolveContainerByPort queries the Docker CLI for a container publishing the given port. // Returns nil if Docker is not available or no container matches. func ResolveContainerByPort(port int) *model.DockerPortMatch { if _, err := exec.LookPath("docker"); err != nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() format := "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}|{{.Label \"com.docker.compose.project\"}}|{{.Label \"com.docker.compose.service\"}}" cmd := exec.CommandContext(ctx, "docker", "ps", "--filter", fmt.Sprintf("publish=%d", port), "--format", format) out, err := cmd.Output() if err != nil { return nil } line := strings.TrimSpace(string(out)) if line == "" { return nil } // Take the first matching container if multiple lines if idx := strings.Index(line, "\n"); idx >= 0 { line = line[:idx] } parts := strings.SplitN(line, "|", 6) if len(parts) < 6 { return nil } return &model.DockerPortMatch{ ID: parts[0], Name: parts[1], Image: parts[2], Ports: parts[3], ComposeProject: parts[4], ComposeService: parts[5], } } // resolveContainerName attempts to resolve a container ID to a name using the specified runtime CLI. func resolveContainerName(id, runtime string) string { var cmd *exec.Cmd var prefix string switch runtime { case "docker": if _, err := exec.LookPath("docker"); err != nil { return "" } cmd = exec.Command("docker", "inspect", id, "--format", "{{.Name}}|{{index .Config.Labels \"com.docker.compose.project\"}}|{{index .Config.Labels \"com.docker.compose.service\"}}") prefix = "docker: " case "podman": if _, err := exec.LookPath("podman"); err != nil { return "" } cmd = exec.Command("podman", "inspect", id, "--format", "{{.Name}}") prefix = "podman: " case "crictl": if _, err := exec.LookPath("crictl"); err != nil { return "" } cmd = exec.Command("crictl", "inspect", id, "-o", "go-template", "--template", "{{.status.metadata.name}}") prefix = "" // crictl names are usually clean case "nerdctl": if _, err := exec.LookPath("nerdctl"); err != nil { return "" } cmd = exec.Command("nerdctl", "inspect", id, "--format", "{{.Name}}") prefix = "containerd: " default: return "" } out, err := cmd.Output() if err != nil { return "" } output := strings.TrimSpace(string(out)) if runtime == "docker" { parts := strings.Split(output, "|") if len(parts) == 3 { name := strings.TrimPrefix(parts[0], "/") project := parts[1] service := parts[2] if project != "" && service != "" { return "docker: " + project + "/" + service + " (" + name + ")" } if name != "" { return "docker: " + name } return "" } } name := strings.TrimPrefix(output, "/") if name != "" { if prefix != "" { return prefix + name } return name } return "" } // findLongHexID searches for a 64-character hexadecimal string in the input. func findLongHexID(s string) string { for i := 0; i <= len(s)-64; i++ { if s[i] < '0' || (s[i] > '9' && s[i] < 'a') { continue } sub := s[i : i+64] isHex := true for j := 0; j < 64; j++ { c := sub[j] if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { isHex = false break } } if isHex { return sub } } return "" } // shortID returns the first 12 characters of a container ID, or the full // string if it is shorter than 12 characters. func shortID(id string) string { if len(id) > 12 { return id[:12] } return id } // extractFlagValue extracts the value of a specific flag from a command line string. func extractFlagValue(cmdline string, flags ...string) string { args := splitCmdline(cmdline) for i, arg := range args { for _, flag := range flags { if arg == flag && i+1 < len(args) { return args[i+1] } } } return "" } // splitCmdline splits a command line string into arguments, handling quotes and escapes. func splitCmdline(cmdline string) []string { var args []string var current strings.Builder var quote rune escaped := false for _, r := range cmdline { switch { case escaped: current.WriteRune(r) escaped = false case r == '\\': escaped = true case r == '"' || r == '\'': if quote == 0 { quote = r continue } if quote == r { quote = 0 continue } current.WriteRune(r) case unicode.IsSpace(r) && quote == 0: if current.Len() > 0 { args = append(args, current.String()) current.Reset() } default: current.WriteRune(r) } } if current.Len() > 0 { args = append(args, current.String()) } return args } ================================================ FILE: internal/proc/container_detect.go ================================================ package proc import "strings" // detectContainerFromCmdline checks the command line for container runtime patterns. // Used by darwin, freebsd, and windows where cgroup-based detection is not available. func detectContainerFromCmdline(cmdline string) string { if cmdline == "" { return "" } lowerCmd := strings.ToLower(cmdline) switch { case strings.Contains(lowerCmd, "docker"): if name := extractFlagValue(cmdline, "--name"); name != "" { return "docker: " + name } return "docker" case strings.Contains(lowerCmd, "podman"), strings.Contains(lowerCmd, "libpod"): if name := extractFlagValue(cmdline, "--name"); name != "" { return "podman: " + name } return "podman" case strings.Contains(lowerCmd, "minikube"): if profile := extractFlagValue(cmdline, "-p", "--profile"); profile != "" { return "k8s: " + profile } return "kubernetes" case strings.Contains(lowerCmd, "kind"): if name := extractFlagValue(cmdline, "--name"); name != "" { return "k8s: " + name } return "kubernetes" case strings.Contains(lowerCmd, "kubepods"): if id := findLongHexID(cmdline); id != "" { if name := resolveContainerName(id, "crictl"); name != "" { return "k8s: " + name } return "k8s (" + shortID(id) + ")" } return "kubernetes" case strings.Contains(lowerCmd, "colima"): if profile := extractFlagValue(cmdline, "-p", "--profile"); profile != "" { return "colima: " + profile } return "colima: default" case strings.Contains(lowerCmd, "nerdctl"): if name := extractFlagValue(cmdline, "--name"); name != "" { return "containerd: " + name } return "containerd" case strings.Contains(lowerCmd, "containerd"): if name := extractFlagValue(cmdline, "--name"); name != "" { return "containerd: " + name } return "containerd" } return "" } ================================================ FILE: internal/proc/container_test.go ================================================ package proc import ( "testing" ) func TestSplitCmdline(t *testing.T) { tests := []struct { name string in string want []string }{ {"simple", "docker ps", []string{"docker", "ps"}}, {"quoted", `docker inspect --format "{{.Name}}"`, []string{"docker", "inspect", "--format", "{{.Name}}"}}, {"empty", "", nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := splitCmdline(tt.in) if len(got) != len(tt.want) { t.Fatalf("splitCmdline(%q) = %v, want %v", tt.in, got, tt.want) } for i := range got { if got[i] != tt.want[i] { t.Fatalf("splitCmdline(%q)[%d] = %q, want %q", tt.in, i, got[i], tt.want[i]) } } }) } } func TestFindLongHexID(t *testing.T) { tests := []struct { name string in string want string }{ {"found", "/docker/" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "/cgroup", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"}, {"not found", "no hex here", ""}, {"too short", "a1b2c3d4e5f6", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := findLongHexID(tt.in) if got != tt.want { t.Fatalf("findLongHexID() = %q, want %q", got, tt.want) } }) } } func TestExtractFlagValue(t *testing.T) { tests := []struct { name string cmdline string flags []string want string }{ {"found", "docker run --name myapp", []string{"--name"}, "myapp"}, {"not found", "docker run myapp", []string{"--name"}, ""}, {"at end", "docker run --name", []string{"--name"}, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractFlagValue(tt.cmdline, tt.flags...) if got != tt.want { t.Fatalf("extractFlagValue() = %q, want %q", got, tt.want) } }) } } ================================================ FILE: internal/proc/docker_proxy.go ================================================ package proc import ( "os/exec" "strings" ) func resolveDockerProxyContainer(cmdline string) string { var containerIP string parts := strings.Fields(cmdline) for i, part := range parts { if part == "-container-ip" && i+1 < len(parts) { containerIP = parts[i+1] break } } if containerIP == "" { return "" } out, err := exec.Command("docker", "network", "inspect", "bridge", "--format", "{{range .Containers}}{{.Name}}:{{.IPv4Address}}{{\"\\n\"}}{{end}}").Output() if err != nil { return "" } for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } colonIdx := strings.Index(line, ":") if colonIdx == -1 { continue } name := line[:colonIdx] ip := strings.Split(line[colonIdx+1:], "/")[0] if ip == containerIP { return "target: " + name } } return "" } ================================================ FILE: internal/proc/extended_darwin.go ================================================ //go:build darwin package proc import ( "errors" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // ReadExtendedInfo assembles the additional process facts. // Child PID discovery is handled by the caller to avoid redundant process scans. func ReadExtendedInfo(pid int) (model.MemoryInfo, model.IOStats, []string, int, uint64, int, error) { memInfo, threadCount, memErr := readDarwinTaskInfo(pid) fdCount, fileDescs, fdErr := readDarwinFDs(pid) ioStats, ioErr := readDarwinIO(pid) fdLimit := detectDarwinFileLimit() if memErr != nil && fdErr != nil && ioErr != nil { return memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, errors.Join(memErr, fdErr, ioErr) } return memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, nil } // detectDarwinFileLimit reads launchctl's maxfiles limit (soft cap) so we can // compute descriptor headroom, falling back to the shell's ulimit if launchctl // is unavailable. func detectDarwinFileLimit() uint64 { if data, err := exec.Command("launchctl", "limit", "maxfiles").Output(); err == nil { for line := range strings.Lines(string(data)) { if strings.Contains(line, "maxfiles") { if limit, ok := parseLaunchctlLimitLine(line); ok { return limit } } } } if data, err := exec.Command("sh", "-c", "ulimit -n").Output(); err == nil { if limit, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64); err == nil { return limit } } return 0 } func parseLaunchctlLimitLine(line string) (uint64, bool) { fields := strings.Fields(line) if len(fields) < 2 { return 0, false } soft := fields[1] if strings.EqualFold(soft, "unlimited") { return 0, true } limit, err := strconv.ParseUint(soft, 10, 64) if err != nil { return 0, false } return limit, true } ================================================ FILE: internal/proc/extended_darwin_test.go ================================================ //go:build darwin package proc import "testing" func TestParseLaunchctlLimitLine(t *testing.T) { for name, tc := range map[string]struct { line string limit uint64 valid bool }{ "numeric": {line: "maxfiles 1024 unlimited", limit: 1024, valid: true}, "unlimited": {line: "maxfiles unlimited unlimited", limit: 0, valid: true}, "invalid": {line: "maxfiles --", limit: 0, valid: false}, "short": {line: "oops", limit: 0, valid: false}, } { name := name tc := tc t.Run(name, func(t *testing.T) { t.Parallel() limit, ok := parseLaunchctlLimitLine(tc.line) if ok != tc.valid || limit != tc.limit { t.Fatalf("parseLaunchctlLimitLine(%q) = (%d, %t), want (%d, %t)", tc.line, limit, ok, tc.limit, tc.valid) } }) } } ================================================ FILE: internal/proc/extended_freebsd.go ================================================ //go:build freebsd package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // ReadExtendedInfo reads extended process information for verbose output on FreeBSD. // Child PID discovery is handled by the caller to avoid redundant process scans. func ReadExtendedInfo(pid int) (model.MemoryInfo, model.IOStats, []string, int, uint64, int, error) { var memInfo model.MemoryInfo var ioStats model.IOStats var fileDescs []string var threadCount int var fdCount int var fdLimit uint64 // 1. Get Memory info using ps // rss = resident set size in 1024 byte blocks // vsz = virtual size in 1024 byte blocks cmd := exec.Command("ps", "-o", "rss,vsz", "-p", strconv.Itoa(pid)) out, err := cmd.Output() if err == nil { lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) >= 2 { fields := strings.Fields(lines[1]) if len(fields) >= 2 { // RSS if rss, err := strconv.ParseUint(fields[0], 10, 64); err == nil { memInfo.RSS = rss * 1024 memInfo.RSSMB = float64(memInfo.RSS) / (1024 * 1024) } // VSZ if vsz, err := strconv.ParseUint(fields[1], 10, 64); err == nil { memInfo.VMS = vsz * 1024 memInfo.VMSMB = float64(memInfo.VMS) / (1024 * 1024) } } } } // 2. Count threads using ps -H (threads) check // `ps -H` threadCmd := exec.Command("ps", "-H", "-p", strconv.Itoa(pid)) if threadOut, err := threadCmd.Output(); err == nil { lines := strings.Split(strings.TrimSpace(string(threadOut)), "\n") if len(lines) > 1 { threadCount = len(lines) - 1 } } // 3. Get file descriptors using lsof (best effort) fdCmd := exec.Command("sh", "-c", fmt.Sprintf("lsof -p %d | wc -l", pid)) if fdOut, err := fdCmd.Output(); err == nil { str := strings.TrimSpace(string(fdOut)) if count, err := strconv.Atoi(str); err == nil { if count > 0 { fdCount = count - 1 } } } return memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, nil } ================================================ FILE: internal/proc/extended_linux.go ================================================ //go:build linux package proc import ( "fmt" "os" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // ReadExtendedInfo reads extended process information for verbose output. // Child PID discovery is handled by the caller to avoid redundant /proc scans. func ReadExtendedInfo(pid int) (model.MemoryInfo, model.IOStats, []string, int, uint64, int, error) { var memInfo model.MemoryInfo var ioStats model.IOStats var fileDescs []string var threadCount int fdCount := 0 var fdLimit uint64 // Read memory info from /proc/[pid]/statm if statmData, err := os.ReadFile(fmt.Sprintf("/proc/%d/statm", pid)); err == nil { fields := strings.Fields(string(statmData)) if len(fields) >= 7 { pageSize := uint64(os.Getpagesize()) // statm fields: total resident shared text lib data dirty total, _ := strconv.ParseUint(fields[0], 10, 64) resident, _ := strconv.ParseUint(fields[1], 10, 64) shared, _ := strconv.ParseUint(fields[2], 10, 64) text, _ := strconv.ParseUint(fields[3], 10, 64) lib, _ := strconv.ParseUint(fields[4], 10, 64) data, _ := strconv.ParseUint(fields[5], 10, 64) dirty, _ := strconv.ParseUint(fields[6], 10, 64) memInfo = model.MemoryInfo{ VMS: total * pageSize, RSS: resident * pageSize, VMSMB: float64(total*pageSize) / (1024 * 1024), RSSMB: float64(resident*pageSize) / (1024 * 1024), Shared: shared * pageSize, Text: text * pageSize, Lib: lib * pageSize, Data: data * pageSize, Dirty: dirty * pageSize, } } } // Read I/O stats from /proc/[pid]/io if ioData, err := os.ReadFile(fmt.Sprintf("/proc/%d/io", pid)); err == nil { lines := strings.Split(string(ioData), "\n") for _, line := range lines { if strings.HasPrefix(line, "read_bytes:") { if val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64); err == nil { ioStats.ReadBytes = val } } else if strings.HasPrefix(line, "write_bytes:") { if val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64); err == nil { ioStats.WriteBytes = val } } else if strings.HasPrefix(line, "syscr:") { if val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "syscr:")), 10, 64); err == nil { ioStats.ReadOps = val } } else if strings.HasPrefix(line, "syscw:") { if val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "syscw:")), 10, 64); err == nil { ioStats.WriteOps = val } } } } // Read file descriptors from /proc/[pid]/fd if fdDir, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid)); err == nil { fdCount = len(fdDir) for _, fdEntry := range fdDir { fdPath := fmt.Sprintf("/proc/%d/fd/%s", pid, fdEntry.Name()) if linkTarget, err := os.Readlink(fdPath); err == nil { fileDescs = append(fileDescs, fmt.Sprintf("%s -> %s", fdEntry.Name(), linkTarget)) } } } // Reuse the shared file limit parser fdLimit = uint64(getFileLimit(pid)) // Get thread count from /proc/[pid]/status if statusData, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil { lines := strings.Split(string(statusData), "\n") for _, line := range lines { if strings.HasPrefix(line, "Threads:") { if count, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Threads:"))); err == nil { threadCount = count } break } } } return memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, nil } ================================================ FILE: internal/proc/extended_windows.go ================================================ //go:build windows package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // ReadExtendedInfo reads extended process information for verbose output. // Child PID discovery is handled by the caller to avoid redundant process scans. func ReadExtendedInfo(pid int) (model.MemoryInfo, model.IOStats, []string, int, uint64, int, error) { var memInfo model.MemoryInfo var ioStats model.IOStats var fileDescs []string var threadCount int var fdCount int var fdLimit uint64 // Use powershell to get process details psScript := fmt.Sprintf("Get-CimInstance -ClassName Win32_Process -Filter \"ProcessId=%d\" | ForEach-Object { \"HandleCount=$($_.HandleCount)\"; \"ReadOperationCount=$($_.ReadOperationCount)\"; \"ReadTransferCount=$($_.ReadTransferCount)\"; \"ThreadCount=$($_.ThreadCount)\"; \"VirtualSize=$($_.VirtualSize)\"; \"WorkingSetSize=$($_.WorkingSetSize)\"; \"WriteOperationCount=$($_.WriteOperationCount)\"; \"WriteTransferCount=$($_.WriteTransferCount)\" }", pid) cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", psScript) out, err := cmd.Output() if err != nil { return memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, fmt.Errorf("powershell extended info: %w", err) } lines := strings.Split(string(out), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) switch key { case "ReadOperationCount": ioStats.ReadOps, _ = strconv.ParseUint(val, 10, 64) case "ReadTransferCount": ioStats.ReadBytes, _ = strconv.ParseUint(val, 10, 64) case "WriteOperationCount": ioStats.WriteOps, _ = strconv.ParseUint(val, 10, 64) case "WriteTransferCount": ioStats.WriteBytes, _ = strconv.ParseUint(val, 10, 64) case "ThreadCount": threadCount, _ = strconv.Atoi(val) case "VirtualSize": memInfo.VMS, _ = strconv.ParseUint(val, 10, 64) memInfo.VMSMB = float64(memInfo.VMS) / (1024 * 1024) case "WorkingSetSize": memInfo.RSS, _ = strconv.ParseUint(val, 10, 64) memInfo.RSSMB = float64(memInfo.RSS) / (1024 * 1024) case "HandleCount": fdCount, _ = strconv.Atoi(val) } } return memInfo, ioStats, fileDescs, fdCount, fdLimit, threadCount, nil } ================================================ FILE: internal/proc/fd_darwin.go ================================================ //go:build darwin package proc import ( "os/exec" "strconv" "strings" ) // socketsForPID returns socket inodes/identifiers for a given PID // On macOS, we use lsof to get this information func socketsForPID(pid int) []string { var inodes []string // Use lsof to find sockets for this PID // -i TCP = TCP sockets // -s TCP:LISTEN = only in LISTEN state // -n = don't resolve hostnames // -P = don't resolve port names out, err := exec.Command("lsof", "-i", "TCP", "-s", "TCP:LISTEN", "-n", "-P", "-F", "pn").Output() if err != nil { return inodes } // Parse lsof output seen := make(map[string]bool) var blocks = strings.Split(string(out), "p") for i := range blocks { if strings.HasPrefix(blocks[i], strconv.Itoa(pid)+"\n") { for line := range strings.Lines(blocks[i]) { if len(line) == 0 { continue } if line[0] == 'n' { // n
addr, port := parseNetstatAddr(strings.TrimSpace(line[1:])) if port > 0 { // Create pseudo-inode matching the format in readListeningSockets inode := addr + ":" + strconv.Itoa(port) if !seen[inode] { seen[inode] = true inodes = append(inodes, inode) } } } } break } } return inodes } ================================================ FILE: internal/proc/fd_freebsd.go ================================================ //go:build freebsd package proc import ( "os/exec" "strconv" "strings" ) // socketsForPID returns socket inodes/identifiers for a given PID // On FreeBSD, we use sockstat to get this information func socketsForPID(pid int) []string { var inodes []string // Use sockstat to find sockets for this PID // -P tcp = TCP protocol // -p = specific process seen := make(map[string]bool) for _, flag := range []string{"-4", "-6"} { out, err := exec.Command("sockstat", flag, "-P", "tcp").Output() if err != nil { continue } pidStr := strconv.Itoa(pid) for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) < 6 { continue } // Skip header if fields[0] == "USER" { continue } // Check if this line is for our PID if fields[2] != pidStr { continue } localAddr := fields[5] proto := fields[4] // tcp4 or tcp6 address, port := parseSockstatAddr(localAddr, proto) if port > 0 { // Create pseudo-inode matching the format in readListeningSockets inode := pidStr + ":" + strconv.Itoa(port) + ":" + address if !seen[inode] { seen[inode] = true inodes = append(inodes, inode) } } } } return inodes } ================================================ FILE: internal/proc/fd_linux.go ================================================ //go:build linux package proc import ( "os" "path/filepath" "strconv" "strings" ) func socketsForPID(pid int) []string { var inodes []string seen := make(map[string]bool) fdPath := "/proc/" + strconv.Itoa(pid) + "/fd" entries, err := os.ReadDir(fdPath) if err != nil { return inodes } for _, e := range entries { link, err := os.Readlink(filepath.Join(fdPath, e.Name())) if err != nil { continue } if strings.HasPrefix(link, "socket:[") { inode := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]") if !seen[inode] { seen[inode] = true inodes = append(inodes, inode) } } } return inodes } ================================================ FILE: internal/proc/filecontext_darwin.go ================================================ //go:build darwin package proc import ( "os/exec" "slices" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetFileContext returns file descriptor and lock info for a process func GetFileContext(pid int) *model.FileContext { ctx := &model.FileContext{} // Get open file count openFiles, fileLimit := getOpenFileCount(pid) ctx.OpenFiles = openFiles ctx.FileLimit = fileLimit // Get locked files ctx.LockedFiles = getLockedFiles(pid) // Only return if we have meaningful data to show // Show if: high file usage (>50% of limit) or has locks if len(ctx.LockedFiles) > 0 { return ctx } if ctx.FileLimit > 0 && ctx.OpenFiles > 0 { usagePercent := float64(ctx.OpenFiles) / float64(ctx.FileLimit) * 100 if usagePercent > 50 { return ctx } } return nil } // getOpenFileCount returns the number of open files and the limit for a process func getOpenFileCount(pid int) (int, int) { // Use lsof to count open files // lsof -p returns all open files out, err := exec.Command("lsof", "-p", strconv.Itoa(pid)).Output() if err != nil { return 0, 0 } // Count lines (subtract 1 for header) openFiles := 0 for line := range strings.Lines(string(out)) { if strings.TrimSpace(line) != "" { openFiles++ } } if openFiles > 0 { openFiles-- // Subtract header line } // Get file limit using launchctl or ulimit fileLimit := getFileLimit(pid) return openFiles, fileLimit } // getFileLimit returns the file descriptor limit for a process func getFileLimit(pid int) int { // Try to get per-process limit // On macOS, we can use launchctl limit or check /proc equivalent // Default to common macOS limits // Try launchctl limit (system-wide soft limit) out, err := exec.Command("launchctl", "limit", "maxfiles").Output() if err == nil { // Format: maxfiles 256 unlimited fields := strings.Fields(string(out)) if len(fields) >= 2 { limit, err := strconv.Atoi(fields[1]) if err == nil { return limit } } } // Default macOS limit return 256 } // getLockedFiles returns files with locks held by the process func getLockedFiles(pid int) []string { var locked []string // Use lsof to find locked files // -p for specific process // Look for lock indicators in the output out, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-F", "fn").Output() if err != nil { return locked } // Parse lsof -F output // f = file descriptor info // n = file name var currentFD string for line := range strings.Lines(string(out)) { if len(line) == 0 { continue } switch line[0] { case 'f': currentFD = strings.TrimSpace(line[1:]) case 'n': fileName := strings.TrimSpace(line[1:]) // Check if this FD indicates a lock // Common lock indicators: .lock files, fcntl locks shown with 'l' type if strings.HasSuffix(fileName, ".lock") || strings.HasSuffix(fileName, ".pid") || strings.Contains(fileName, "/lock") { if !slices.Contains(locked, fileName) { locked = append(locked, fileName) } } _ = currentFD // Used for future lock type detection } } // Also check for actual fcntl/flock locks using lsof -F with lock info out2, err := exec.Command("lsof", "-p", strconv.Itoa(pid)).Output() if err == nil { for line := range strings.Lines(string(out2)) { fields := strings.Fields(line) // Look for lock type indicators (varies by lsof version) // Typically shows "r" for read lock, "w" for write lock, "R" for read lock on entire file if len(fields) >= 5 { lockType := fields[4] if lockType == "r" || lockType == "w" || lockType == "R" || lockType == "W" { // This file has a lock if len(fields) >= 9 { fileName := fields[8] if !slices.Contains(locked, fileName) { locked = append(locked, fileName) } } } } } } return locked } ================================================ FILE: internal/proc/filecontext_freebsd.go ================================================ //go:build freebsd package proc import ( "os/exec" "slices" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetFileContext returns file descriptor and lock info for a process func GetFileContext(pid int) *model.FileContext { ctx := &model.FileContext{} // Run fstat once and reuse output for both open file count and lock detection fstatOutput, _ := exec.Command("fstat", "-p", strconv.Itoa(pid)).Output() openFiles, fileLimit := getOpenFileCount(fstatOutput, pid) ctx.OpenFiles = openFiles ctx.FileLimit = fileLimit ctx.LockedFiles = getLockedFiles(fstatOutput) // Only return if we have meaningful data to show // Show if: high file usage (>50% of limit) or has locks if len(ctx.LockedFiles) > 0 { return ctx } if ctx.FileLimit > 0 && ctx.OpenFiles > 0 { usagePercent := float64(ctx.OpenFiles) / float64(ctx.FileLimit) * 100 if usagePercent > 50 { return ctx } } return nil } func getOpenFileCount(fstatOut []byte, pid int) (int, int) { if len(fstatOut) == 0 { return 0, 0 } openFiles := 0 for line := range strings.Lines(string(fstatOut)) { if strings.TrimSpace(line) != "" { openFiles++ } } if openFiles > 0 { openFiles-- // Subtract header line } // Get file limit using sysctl or limits fileLimit := getFileLimit(pid) return openFiles, fileLimit } // getFileLimit returns the file descriptor limit for a process func getFileLimit(pid int) int { // Try procstat to get limits out, err := exec.Command("procstat", "-l", strconv.Itoa(pid)).Output() if err == nil { // Parse procstat -l output for openfiles limit for line := range strings.Lines(string(out)) { if strings.Contains(line, "openfiles") { fields := strings.Fields(line) if len(fields) >= 3 { limit, err := strconv.Atoi(fields[2]) if err == nil { return limit } } } } } // Fallback: get system-wide limit out, err = exec.Command("sysctl", "-n", "kern.maxfilesperproc").Output() if err == nil { limit, err := strconv.Atoi(strings.TrimSpace(string(out))) if err == nil { return limit } } // Default FreeBSD limit return 1024 } func getLockedFiles(fstatOut []byte) []string { var locked []string if len(fstatOut) == 0 { return locked } for line := range strings.Lines(string(fstatOut)) { // Look for lock indicators in the output if strings.Contains(line, ".lock") || strings.Contains(line, ".pid") || strings.Contains(line, "/lock") { fields := strings.Fields(line) if len(fields) >= 8 { fileName := fields[len(fields)-1] if !slices.Contains(locked, fileName) { locked = append(locked, fileName) } } } } return locked } ================================================ FILE: internal/proc/filecontext_linux.go ================================================ //go:build linux package proc import ( "errors" "fmt" "os" "os/exec" "slices" "strconv" "strings" "syscall" "github.com/pranshuparmar/witr/pkg/model" ) // GetFileContext returns file descriptor and lock info for a process // Will return nil if the context could not be gathered. func GetFileContext(pid int) *model.FileContext { var fileContext model.FileContext fdDir := fmt.Sprintf("/proc/%v/fd", pid) fdFiles, err := os.ReadDir(fdDir) if err != nil { return nil } fileContext.OpenFiles = len(fdFiles) fileContext.FileLimit = getFileLimit(pid) fileContext.LockedFiles = getLockedFiles(pid) fileContext.WatchedDirs = getWatchedDirs(fdDir, fdFiles) return &fileContext } func getFileLimit(pid int) int { var linuxDefaultMaxOpenFile = getDefaultMaxOpenFiles() // Read /proc//limits for file limit data, err := os.ReadFile(fmt.Sprintf("/proc/%v/limits", pid)) if err != nil { return linuxDefaultMaxOpenFile } dataString := string(data) for line := range strings.Lines(dataString) { if !strings.HasPrefix(line, "Max open files") { continue } // Data in format: "Max open files $SOFT_LOCK_NUMBER $HARD_LOCK_NUMBER files" fields := strings.Fields(line) if len(fields) < 4 { return linuxDefaultMaxOpenFile } softLimitString := fields[3] if softLimitString == "unlimited" { return 0 } softLimit, err := strconv.Atoi(softLimitString) if err != nil { return linuxDefaultMaxOpenFile } return softLimit } return linuxDefaultMaxOpenFile } func getDefaultMaxOpenFiles() int { // This seems to be a common default for many systems. const reasonableDefault int = 1024 // https://www.man7.org/linux/man-pages/man2/getrlimit.2.html var rlimit syscall.Rlimit err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit) if err != nil { return reasonableDefault } return int(rlimit.Max) } func getLockedFiles(pid int) []string { files, err := getLockedFilesLslocks(pid) if errors.Is(err, exec.ErrNotFound) { return getLockedFilesProc(pid) } return files } func getLockedFilesLslocks(pid int) ([]string, error) { var locked []string output, err := exec.Command("lslocks", "-o", "PATH", "-p", strconv.Itoa(pid)).Output() if err != nil { return nil, err } // First line of output is PATH (column name) which is not interesting. skippedFirst := false for fileName := range strings.Lines(string(output)) { if !skippedFirst { skippedFirst = true continue } locked = append(locked, strings.TrimSpace(fileName)) } return locked, nil } // get list of locked files by the process func getLockedFilesProc(pid int) []string { lockedFileData, err := os.ReadFile("/proc/locks") if err != nil { return nil } var result []string // Output Pattern: : pidStr := strconv.Itoa(pid) for _, line := range strings.Split(string(lockedFileData), "\n") { if line == "" { continue } fields := strings.Fields(line) if len(fields) < 8 { continue } // lockType := fields[1] // FLOCK, POSIX, or OFDLCK lockPid := fields[4] // PID that owns the lock deviceInode := fields[5] // Device:Inode identifier // consider POSIX locks (these have valid PIDs) // Skip OFDLCK as PID is -1 (owned by multiple processes) // skip FLOCK as it may not have valid PID association if lockPid == pidStr { // Store device:inode as identifier (resolving to file path would require scanning filesystem) if !slices.Contains(result, deviceInode) { result = append(result, deviceInode) } } } return result } // get list of directories being accessed by the process // directories being watched/accessed (detectable via /proc//fd) func getWatchedDirs(fdDir string, entries []os.DirEntry) []string { var result []string seen := make(map[string]bool) for _, entry := range entries { path := fmt.Sprintf("%s/%s", fdDir, entry.Name()) target, err := os.Readlink(path) if err != nil { continue } // Check if target is a directory info, err := os.Stat(target) if err != nil { continue } if info.IsDir() { if !seen[target] { seen[target] = true result = append(result, target) } } } return result } ================================================ FILE: internal/proc/filecontext_windows.go ================================================ //go:build windows package proc import "github.com/pranshuparmar/witr/pkg/model" func GetFileContext(pid int) *model.FileContext { return nil } ================================================ FILE: internal/proc/git.go ================================================ package proc import ( "os" "path/filepath" "strings" ) func detectGitInfo(cwd string) (string, string) { if cwd == "" || cwd == "unknown" { return "", "" } searchDir := cwd for depth := 0; depth < 10; depth++ { gitDir := filepath.Join(searchDir, ".git") if fi, err := os.Stat(gitDir); err == nil && fi.IsDir() { gitRepo := filepath.Base(searchDir) gitBranch := "" headFile := filepath.Join(gitDir, "HEAD") if head, err := os.ReadFile(headFile); err == nil { headStr := strings.TrimSpace(string(head)) if strings.HasPrefix(headStr, "ref: ") { ref := strings.TrimPrefix(headStr, "ref: ") gitBranch = strings.TrimPrefix(ref, "refs/heads/") } } return gitRepo, gitBranch } parent := filepath.Dir(searchDir) if parent == searchDir { break } searchDir = parent } return "", "" } ================================================ FILE: internal/proc/libproc_darwin_cgo.go ================================================ //go:build darwin && cgo && !internal_witr_cgo_disabled package proc /* #cgo CFLAGS: -mmacosx-version-min=11.0 #cgo LDFLAGS: -mmacosx-version-min=11.0 #include #include #include #include #include #include #include #include static int witr_proc_pid_rusage(int pid, int flavor, struct rusage_info_v4 *usage) { int rv = proc_pid_rusage(pid, flavor, (rusage_info_t)usage); if (rv != 0) { return errno; } return 0; } static int witr_proc_pidtaskinfo(int pid, struct proc_taskinfo *info) { int rv = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, info, PROC_PIDTASKINFO_SIZE); if (rv < 0) { return errno; } if (rv < PROC_PIDTASKINFO_SIZE) { return EIO; } return 0; } static int witr_proc_pidlistfds(int pid, struct proc_fdinfo *fds, int bufsize, int *bytes_used) { int rv = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, bufsize); if (rv < 0) { if (bytes_used) { *bytes_used = 0; } return errno; } if (bytes_used) { *bytes_used = rv; } return 0; } static int witr_proc_pidfdinfo_vnode(int pid, int fd, struct vnode_fdinfowithpath *info) { int rv = proc_pidfdinfo(pid, fd, PROC_PIDFDVNODEPATHINFO, info, PROC_PIDFDVNODEPATHINFO_SIZE); if (rv < 0) { return errno; } if (rv < PROC_PIDFDVNODEPATHINFO_SIZE) { return EIO; } return 0; } static int witr_proc_pidfdinfo_socket(int pid, int fd, struct socket_fdinfo *info) { int rv = proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, info, PROC_PIDFDSOCKETINFO_SIZE); if (rv < 0) { return errno; } if (rv < PROC_PIDFDSOCKETINFO_SIZE) { return EIO; } return 0; } static void witr_format_socket(const struct socket_fdinfo *info, char *buf, size_t buf_len) { if (buf_len == 0) { return; } buf[0] = '\0'; if (!info) { return; } const struct in_sockinfo *ini = &info->psi.soi_proto.pri_tcp.tcpsi_ini; char laddr[INET6_ADDRSTRLEN]; char faddr[INET6_ADDRSTRLEN]; laddr[0] = '\0'; faddr[0] = '\0'; uint16_t lport = ntohs((uint16_t)ini->insi_lport); uint16_t fport = ntohs((uint16_t)ini->insi_fport); if (ini->insi_vflag & INI_IPV4) { inet_ntop(AF_INET, &ini->insi_laddr.ina_46.i46a_addr4, laddr, sizeof(laddr)); inet_ntop(AF_INET, &ini->insi_faddr.ina_46.i46a_addr4, faddr, sizeof(faddr)); } else if (ini->insi_vflag & INI_IPV6) { inet_ntop(AF_INET6, &ini->insi_laddr.ina_6, laddr, sizeof(laddr)); inet_ntop(AF_INET6, &ini->insi_faddr.ina_6, faddr, sizeof(faddr)); } if (laddr[0] == '\0') { strlcpy(laddr, "?", sizeof(laddr)); } if (faddr[0] == '\0') { strlcpy(faddr, "?", sizeof(faddr)); } snprintf(buf, buf_len, "%s:%u -> %s:%u", laddr, lport, faddr, fport); } */ import "C" import ( "errors" "fmt" "unsafe" "github.com/pranshuparmar/witr/pkg/model" ) func readDarwinIO(pid int) (model.IOStats, error) { var stats model.IOStats var usage C.struct_rusage_info_v4 errno := C.witr_proc_pid_rusage(C.int(pid), C.RUSAGE_INFO_V4, &usage) if errno != 0 { switch errno { case C.ESRCH, C.EPERM: return stats, nil default: return stats, fmt.Errorf("proc_pid_rusage: %d", errno) } } stats.ReadBytes = uint64(usage.ri_diskio_bytesread) stats.WriteBytes = uint64(usage.ri_diskio_byteswritten) return stats, nil } func readDarwinTaskInfo(pid int) (model.MemoryInfo, int, error) { var info C.struct_proc_taskinfo if errno := C.witr_proc_pidtaskinfo(C.int(pid), &info); errno != 0 { switch errno { case C.ESRCH, C.EPERM: return model.MemoryInfo{}, 0, nil default: return model.MemoryInfo{}, 0, fmt.Errorf("proc_pidinfo taskinfo: %d", errno) } } mem := model.MemoryInfo{ VMS: uint64(info.pti_virtual_size), RSS: uint64(info.pti_resident_size), VMSMB: float64(info.pti_virtual_size) / (1024 * 1024), RSSMB: float64(info.pti_resident_size) / (1024 * 1024), } return mem, int(info.pti_threadnum), nil } func readDarwinFDs(pid int) (int, []string, error) { const bytesPerEntry = int(C.sizeof_struct_proc_fdinfo) entries := make([]C.struct_proc_fdinfo, 256) for { var used C.int errno := C.witr_proc_pidlistfds(C.int(pid), &entries[0], C.int(len(entries)*bytesPerEntry), &used) if errno == C.EINVAL && len(entries) < 16384 { entries = make([]C.struct_proc_fdinfo, len(entries)*2) continue } if errno != 0 { switch errno { case C.ESRCH, C.EPERM: return 0, nil, nil default: return 0, nil, fmt.Errorf("proc_pidinfo listfds: %d", errno) } } bytesUsed := int(used) if bytesUsed%bytesPerEntry != 0 { return 0, nil, errors.New("listfds returned partial record") } count := bytesUsed / bytesPerEntry return count, formatFDEntries(pid, entries[:count]), nil } } func formatFDEntries(pid int, entries []C.struct_proc_fdinfo) []string { var out []string for _, entry := range entries { if len(out) >= 10 { break } fd := int(entry.proc_fd) label := fdTypeLabel(entry.proc_fdtype) switch entry.proc_fdtype { case C.PROX_FDTYPE_VNODE: var vnode C.struct_vnode_fdinfowithpath if errno := C.witr_proc_pidfdinfo_vnode(C.int(pid), C.int(fd), &vnode); errno == 0 { path := C.GoString(&vnode.pvip.vip_path[0]) if path == "" { path = "" } out = append(out, fmt.Sprintf("%d -> %s", fd, path)) continue } case C.PROX_FDTYPE_SOCKET: var sock C.struct_socket_fdinfo if errno := C.witr_proc_pidfdinfo_socket(C.int(pid), C.int(fd), &sock); errno == 0 { buf := make([]byte, 128) C.witr_format_socket(&sock, (*C.char)(unsafe.Pointer(&buf[0])), C.size_t(len(buf))) desc := C.GoString((*C.char)(unsafe.Pointer(&buf[0]))) out = append(out, fmt.Sprintf("%d -> %s", fd, desc)) continue } } out = append(out, fmt.Sprintf("%d (%s)", fd, label)) } return out } func fdTypeLabel(fdType C.uint32_t) string { switch fdType { case C.PROX_FDTYPE_VNODE: return "vnode" case C.PROX_FDTYPE_SOCKET: return "socket" case C.PROX_FDTYPE_PIPE: return "pipe" case C.PROX_FDTYPE_KQUEUE: return "kqueue" case C.PROX_FDTYPE_FSEVENTS: return "fsevents" case C.PROX_FDTYPE_NEXUS: return "nexus" case C.PROX_FDTYPE_NETPOLICY: return "netpolicy" default: return fmt.Sprintf("fdtype-%d", fdType) } } func describeSocket(info *C.struct_socket_fdinfo) string { buf := make([]byte, 128) C.witr_format_socket(info, (*C.char)(unsafe.Pointer(&buf[0])), C.size_t(len(buf))) desc := C.GoString((*C.char)(unsafe.Pointer(&buf[0]))) if desc == "" { return "socket" } return desc } ================================================ FILE: internal/proc/libproc_darwin_stub.go ================================================ //go:build darwin && (!cgo || internal_witr_cgo_disabled) package proc import "github.com/pranshuparmar/witr/pkg/model" func readDarwinIO(pid int) (model.IOStats, error) { return model.IOStats{}, nil } func readDarwinTaskInfo(pid int) (model.MemoryInfo, int, error) { return model.MemoryInfo{}, 0, nil } func readDarwinFDs(pid int) (int, []string, error) { return 0, nil, nil } ================================================ FILE: internal/proc/libproc_darwin_test.go ================================================ //go:build darwin && !internal_witr_cgo_disabled package proc import ( "os" "testing" ) func TestReadDarwinIO(t *testing.T) { pid := os.Getpid() stats, err := readDarwinIO(pid) if err != nil { t.Fatalf("readDarwinIO(%d) error: %v", pid, err) } if stats.ReadBytes == 0 && stats.WriteBytes == 0 { t.Log("disk I/O counters are zero; process likely idle") } } ================================================ FILE: internal/proc/net_darwin.go ================================================ //go:build darwin package proc import ( "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func ListOpenPorts() ([]model.OpenPort, error) { cmd := exec.Command("lsof", "-i", "-P", "-n") out, err := cmd.Output() if err != nil { return nil, err } var ports []model.OpenPort lines := strings.Split(string(out), "\n") startIdx := 0 if len(lines) > 0 && strings.HasPrefix(lines[0], "COMMAND") { startIdx = 1 } for _, line := range lines[startIdx:] { fields := strings.Fields(line) if len(fields) < 9 { continue } pidStr := fields[1] pid, err := strconv.Atoi(pidStr) if err != nil { continue } protocol := fields[7] if protocol != "TCP" && protocol != "UDP" { if strings.Contains(line, "TCP") { protocol = "TCP" } else if strings.Contains(line, "UDP") { protocol = "UDP" } else { protocol = "UNKNOWN" } } nameField := fields[8] // Address:Port state := "UNKNOWN" if len(fields) > 9 { state = strings.Trim(fields[9], "()") } else if protocol == "UDP" { state = "OPEN" } addr, port := parseNetstatAddr(nameField) if port == 0 { lastColon := strings.LastIndex(nameField, ":") if lastColon != -1 { portStr := nameField[lastColon+1:] if p, err := strconv.Atoi(portStr); err == nil { port = p addr = nameField[:lastColon] if addr == "*" { addr = "0.0.0.0" } } } } if port > 0 { ports = append(ports, model.OpenPort{ PID: pid, Port: port, Address: addr, Protocol: protocol, State: state, }) } } return ports, nil } // parseNetstatAddr parses addresses like "*.8080", "127.0.0.1.8080", "[::1].8080" func parseNetstatAddr(addr string) (string, int) { // Handle IPv6 format [::]:port or [::1]:port if strings.HasPrefix(addr, "[") { // IPv6 format bracketEnd := strings.LastIndex(addr, "]") if bracketEnd == -1 { return "", 0 } ip := addr[1:bracketEnd] rest := addr[bracketEnd+1:] // rest should be ":port" or ".port" if len(rest) > 1 && (rest[0] == ':' || rest[0] == '.') { port, err := strconv.Atoi(rest[1:]) if err == nil { if ip == "::" || ip == "" { return "::", port } return ip, port } } return "", 0 } // Handle formats like "*:8080" or "*.8080" if strings.HasPrefix(addr, "*") { if len(addr) > 1 && (addr[1] == ':' || addr[1] == '.') { port, err := strconv.Atoi(addr[2:]) if err == nil { return "0.0.0.0", port } } return "", 0 } // Handle IPv4 format: "127.0.0.1:8080" or "127.0.0.1.8080" // Try colon-separated first (standard format) if idx := strings.LastIndex(addr, ":"); idx != -1 { ip := addr[:idx] portStr := addr[idx+1:] port, err := strconv.Atoi(portStr) if err == nil { return ip, port } } // macOS netstat uses dot-separated: "127.0.0.1.8080" // Find the last dot and check if what follows is a port if idx := strings.LastIndex(addr, "."); idx != -1 { portStr := addr[idx+1:] port, err := strconv.Atoi(portStr) if err == nil { ip := addr[:idx] return ip, port } } return "", 0 } ================================================ FILE: internal/proc/net_freebsd.go ================================================ //go:build freebsd package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // ListOpenPorts returns all open ports func ListOpenPorts() ([]model.OpenPort, error) { var openPorts []model.OpenPort sockets := make(map[string]model.Socket) for _, flag := range []string{"-4", "-6"} { out, err := exec.Command("sockstat", flag).Output() if err != nil { continue } parseSockstatOutput(string(out), sockets) } for _, s := range sockets { openPorts = append(openPorts, model.OpenPort{ PID: extractPID(s.Inode), Port: s.Port, Address: s.Address, Protocol: s.Protocol, State: s.State, }) } return openPorts, nil } func extractPID(inode string) int { parts := strings.Split(inode, ":") if len(parts) > 0 { pid, _ := strconv.Atoi(parts[0]) return pid } return 0 } func readListeningSockets() (map[string]model.Socket, error) { ports, err := ListOpenPorts() if err != nil { return nil, err } sockets := make(map[string]model.Socket) for _, p := range ports { if p.State == "LISTEN" || p.State == "OPEN" { inode := fmt.Sprintf("%d:%d:%s", p.PID, p.Port, p.Address) sockets[inode] = model.Socket{ Inode: inode, Port: p.Port, Address: p.Address, State: p.State, Protocol: p.Protocol, } } } return sockets, nil } func parseSockstatOutput(output string, sockets map[string]model.Socket) { for line := range strings.Lines(output) { fields := strings.Fields(line) if len(fields) < 7 { continue } if fields[0] == "USER" { continue } pid := fields[2] proto := fields[4] // tcp4, tcp6, udp4, udp6 localAddr := fields[5] foreignAddr := fields[6] state := "UNKNOWN" protocol := "UNKNOWN" if strings.Contains(proto, "tcp") { protocol = "TCP" if strings.Contains(proto, "6") { protocol = "TCP6" } if foreignAddr == "*:*" || foreignAddr == "0.0.0.0:0" || foreignAddr == "[::]:0" { state = "LISTEN" } else { state = "ESTABLISHED" } } else if strings.Contains(proto, "udp") { protocol = "UDP" if strings.Contains(proto, "6") { protocol = "UDP6" } state = "OPEN" } address, port := parseSockstatAddr(localAddr, proto) if port > 0 { inode := pid + ":" + strconv.Itoa(port) + ":" + address sockets[inode] = model.Socket{ Inode: inode, Port: port, Address: address, Protocol: protocol, State: state, } } } } // parseSockstatAddr parses addresses like "*:80", "127.0.0.1:8080", "[::1]:8080" // proto is the protocol field from sockstat (tcp4 or tcp6) to distinguish IPv4 vs IPv6 func parseSockstatAddr(addr string, proto string) (string, int) { // Handle IPv6 format [::]:port or [::1]:port if strings.HasPrefix(addr, "[") { bracketEnd := strings.LastIndex(addr, "]") if bracketEnd == -1 { return "", 0 } ip := addr[1:bracketEnd] rest := addr[bracketEnd+1:] // rest should be ":port" if len(rest) > 1 && rest[0] == ':' { port, err := strconv.Atoi(rest[1:]) if err == nil { // Return IPv6 address without brackets for proper formatting with net.JoinHostPort return ip, port } } return "", 0 } // Handle wildcard format "*:port" // Distinguish between IPv4 and IPv6 based on protocol if strings.HasPrefix(addr, "*:") { port, err := strconv.Atoi(addr[2:]) if err == nil { // If proto is tcp6, return IPv6 any address with brackets if strings.Contains(proto, "6") { return "::", port } // Default to IPv4 any address return "0.0.0.0", port } return "", 0 } // Handle IPv4 format "127.0.0.1:8080" // FreeBSD sockstat uses colon as separator if idx := strings.LastIndex(addr, ":"); idx != -1 { ip := addr[:idx] portStr := addr[idx+1:] port, err := strconv.Atoi(portStr) if err == nil { if ip == "*" { // Check protocol for IPv6 vs IPv4 if strings.Contains(proto, "6") { return "[::]", port } return "0.0.0.0", port } // If IP contains colons (IPv6), wrap with brackets if strings.Contains(ip, ":") { return ip, port } return ip, port } } // Handle dot-separated format (some FreeBSD versions) // "127.0.0.1.8080" if idx := strings.LastIndex(addr, "."); idx != -1 { portStr := addr[idx+1:] port, err := strconv.Atoi(portStr) if err == nil { ip := addr[:idx] return ip, port } } return "", 0 } ================================================ FILE: internal/proc/net_linux.go ================================================ //go:build linux package proc import ( "bufio" "encoding/hex" "fmt" "net" "os" "strconv" "strings" "sync" "time" "github.com/pranshuparmar/witr/pkg/model" ) // Cached socket table to avoid re-parsing /proc/net/* on every ReadProcess call // during ancestry walks (typically 5-10 calls within milliseconds). var ( socketCache map[string]model.Socket socketCacheTime time.Time socketCacheMu sync.Mutex socketCacheTTL = 2 * time.Second ) func readSocketsCached() (map[string]model.Socket, error) { socketCacheMu.Lock() defer socketCacheMu.Unlock() if socketCache != nil && time.Since(socketCacheTime) < socketCacheTTL { return socketCache, nil } sockets, err := readSockets() if err != nil { return nil, err } socketCache = sockets socketCacheTime = time.Now() return sockets, nil } var stateMap = map[string]string{ "01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV", "04": "FIN_WAIT1", "05": "FIN_WAIT2", "06": "TIME_WAIT", "07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN", "0B": "CLOSING", } func readSockets() (map[string]model.Socket, error) { sockets := make(map[string]model.Socket) parse := func(path, proto string, ipv6 bool) { f, err := os.Open(path) if err != nil { return } defer f.Close() scanner := bufio.NewScanner(f) scanner.Scan() // skip header for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 10 { continue } local := fields[1] stateHex := fields[3] inode := fields[9] state, ok := stateMap[stateHex] if !ok { state = "UNKNOWN" } addr, port := parseAddr(local, ipv6) sockets[inode] = model.Socket{ Inode: inode, Port: port, Address: addr, State: state, Protocol: proto, } } } parse("/proc/net/tcp", "TCP", false) parse("/proc/net/tcp6", "TCP6", true) parse("/proc/net/udp", "UDP", false) parse("/proc/net/udp6", "UDP6", true) return sockets, nil } func parseAddr(raw string, ipv6 bool) (string, int) { parts := strings.Split(raw, ":") if len(parts) < 2 { return "", 0 } portHex := parts[1] port, _ := strconv.ParseInt(portHex, 16, 32) ipHex := parts[0] b, err := hex.DecodeString(ipHex) if err != nil { return "", int(port) } if ipv6 { if len(b) != 16 { return "::", int(port) } // /proc/net/tcp6 stores IPv6 as 4 little-endian 32-bit groups // Reverse bytes within each 4-byte group ip := make(net.IP, 16) for i := 0; i < 4; i++ { ip[i*4+0] = b[i*4+3] ip[i*4+1] = b[i*4+2] ip[i*4+2] = b[i*4+1] ip[i*4+3] = b[i*4+0] } return ip.String(), int(port) } if len(b) < 4 { return "", int(port) } ip := strconv.Itoa(int(b[3])) + "." + strconv.Itoa(int(b[2])) + "." + strconv.Itoa(int(b[1])) + "." + strconv.Itoa(int(b[0])) return ip, int(port) } func ListOpenPorts() ([]model.OpenPort, error) { sockets, err := readSockets() if err != nil { return nil, err } var openPorts []model.OpenPort // Scan proc procs, err := os.ReadDir("/proc") if err != nil { return nil, err } for _, p := range procs { if !p.IsDir() { continue } pid, err := strconv.Atoi(p.Name()) if err != nil { continue } // Scan fds fdPath := fmt.Sprintf("/proc/%d/fd", pid) fds, err := os.ReadDir(fdPath) if err != nil { continue } for _, fd := range fds { link, err := os.Readlink(fmt.Sprintf("%s/%s", fdPath, fd.Name())) if err != nil { continue } if strings.HasPrefix(link, "socket:[") { inode := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]") if s, ok := sockets[inode]; ok { openPorts = append(openPorts, model.OpenPort{ PID: pid, Port: s.Port, Address: s.Address, Protocol: s.Protocol, State: s.State, }) } } } } return openPorts, nil } ================================================ FILE: internal/proc/net_linux_test.go ================================================ //go:build linux package proc import ( "encoding/hex" "fmt" "net" "testing" ) func encodeProcNetTCP6(ip net.IP, port int) string { ip16 := ip.To16() if ip16 == nil { return "" } // /proc/net/tcp6 stores IPv6 as 4 LE 32-bit groups // parseAddr reverses bytes within each 4-byte group to decode // so we just inverse the transformation for our tests stored := make([]byte, 16) for i := 0; i < 4; i++ { stored[i*4+0] = ip16[i*4+3] stored[i*4+1] = ip16[i*4+2] stored[i*4+2] = ip16[i*4+1] stored[i*4+3] = ip16[i*4+0] } return hex.EncodeToString(stored) + ":" + fmt.Sprintf("%04X", port) } func TestParseAddr(t *testing.T) { tests := []struct { name string raw string ipv6 bool wantAddr string wantPort int }{ { name: "IPv4 localhost", raw: "0100007F:0277", ipv6: false, wantAddr: "127.0.0.1", wantPort: 631, }, { name: "IPv4 all interfaces", raw: "00000000:0050", ipv6: false, wantAddr: "0.0.0.0", wantPort: 80, }, { name: "IPv6 loopback ::1", raw: "00000000000000000000000001000000:0277", ipv6: true, wantAddr: "::1", wantPort: 631, }, { name: "IPv6 all interfaces ::", raw: "00000000000000000000000000000000:01BB", ipv6: true, wantAddr: "::", wantPort: 443, }, { name: "IPv6 link-local fe80::1", raw: encodeProcNetTCP6(net.ParseIP("fe80::1"), 8080), ipv6: true, wantAddr: "fe80::1", wantPort: 8080, }, // Edge cases { name: "Empty input", raw: "", ipv6: false, wantAddr: "", wantPort: 0, }, { name: "Missing colon separator", raw: "0100007F0277", ipv6: false, wantAddr: "", wantPort: 0, }, { name: "Invalid hex in IPv4", raw: "ZZZZZZZZ:0050", ipv6: false, wantAddr: "", wantPort: 80, }, { name: "Invalid hex in IPv6", raw: "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ:0050", ipv6: true, wantAddr: "", wantPort: 80, }, { name: "Wrong length IPv6 (too short)", raw: "0000000000000000:0277", ipv6: true, wantAddr: "::", wantPort: 631, }, { name: "Wrong length IPv4 (too short)", raw: "01007F:0277", ipv6: false, wantAddr: "", wantPort: 631, }, { name: "Only colon", raw: ":", ipv6: false, wantAddr: "", wantPort: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotAddr, gotPort := parseAddr(tt.raw, tt.ipv6) if gotAddr != tt.wantAddr { t.Errorf("parseAddr() gotAddr = %v, want %v", gotAddr, tt.wantAddr) } if gotPort != tt.wantPort { t.Errorf("parseAddr() gotPort = %v, want %v", gotPort, tt.wantPort) } }) } } ================================================ FILE: internal/proc/net_windows.go ================================================ package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func ListOpenPorts() ([]model.OpenPort, error) { out, err := exec.Command("netstat", "-ano").Output() if err != nil { return nil, err } lines := strings.Split(string(out), "\n") var ports []model.OpenPort seen := make(map[string]bool) for _, line := range lines { fields := strings.Fields(line) // TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 888 (len 5) // UDP 0.0.0.0:123 *:* 999 (len 4) if len(fields) < 4 { continue } proto := fields[0] if proto != "TCP" && proto != "UDP" && proto != "TCPv6" && proto != "UDPv6" { continue } var pidStr, state string if len(fields) == 4 { pidStr = fields[3] state = "LISTEN" } else if len(fields) >= 5 { pidStr = fields[4] state = fields[3] if state == "LISTENING" { state = "LISTEN" } } pid, err := strconv.Atoi(pidStr) if err != nil { continue } localAddr := fields[1] lastColon := strings.LastIndex(localAddr, ":") if lastColon == -1 { continue } portStr := localAddr[lastColon+1:] ip := localAddr[:lastColon] if len(ip) > 2 && strings.HasPrefix(ip, "[") && strings.HasSuffix(ip, "]") { ip = ip[1 : len(ip)-1] } port, err := strconv.Atoi(portStr) if err == nil { key := fmt.Sprintf("%d|%d|%s", pid, port, ip) if !seen[key] { ports = append(ports, model.OpenPort{ PID: pid, Port: port, Address: ip, Protocol: proto, State: state, }) seen[key] = true } } } return ports, nil } func GetListeningPortsForPID(pid int) ([]int, []string) { // netstat -ano | findstr LISTENING | findstr // But findstr is not perfect. // Better: netstat -ano // Parse output. out, err := exec.Command("netstat", "-ano").Output() if err != nil { return nil, nil } lines := strings.Split(string(out), "\n") var ports []int var addrs []string seen := make(map[string]bool) pidStr := strconv.Itoa(pid) for _, line := range lines { fields := strings.Fields(line) // TCP: Proto LocalAddr ForeignAddr State PID (5 fields) // UDP: Proto LocalAddr *:* PID (4 fields) if len(fields) < 4 { continue } proto := strings.ToUpper(fields[0]) var matchPID string if strings.HasPrefix(proto, "TCP") { if len(fields) < 5 || fields[3] != "LISTENING" { continue } matchPID = fields[4] } else if strings.HasPrefix(proto, "UDP") { matchPID = fields[3] } else { continue } if matchPID != pidStr { continue } localAddr := fields[1] // Parse IP:Port lastColon := strings.LastIndex(localAddr, ":") if lastColon == -1 { continue } portStr := localAddr[lastColon+1:] ip := localAddr[:lastColon] // specialized handling for [::] or [::1] on windows to avoid double bracket if len(ip) > 2 && strings.HasPrefix(ip, "[") && strings.HasSuffix(ip, "]") { ip = ip[1 : len(ip)-1] } port, err := strconv.Atoi(portStr) if err == nil { key := ip + ":" + portStr if !seen[key] { ports = append(ports, port) addrs = append(addrs, ip) seen[key] = true } } } return ports, addrs } ================================================ FILE: internal/proc/peb_windows.go ================================================ //go:build windows package proc import ( "fmt" "path/filepath" "syscall" "time" "unsafe" ) // Win32 API constants and structures const ( PROCESS_QUERY_INFORMATION = 0x0400 PROCESS_VM_READ = 0x0010 PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 TH32CS_SNAPPROCESS = 0x00000002 ) var ( modntdll = syscall.NewLazyDLL("ntdll.dll") procNtQueryInfo = modntdll.NewProc("NtQueryInformationProcess") modkernel32 = syscall.NewLazyDLL("kernel32.dll") procReadProcessMem = modkernel32.NewProc("ReadProcessMemory") procGetProcessTimes = modkernel32.NewProc("GetProcessTimes") procQueryFullProcessImageName = modkernel32.NewProc("QueryFullProcessImageNameW") procCreateToolhelp32Snapshot = modkernel32.NewProc("CreateToolhelp32Snapshot") procProcess32First = modkernel32.NewProc("Process32FirstW") procProcess32Next = modkernel32.NewProc("Process32NextW") ) type processBasicInformation struct { ExitStatus uintptr PebBaseAddress uintptr AffinityMask uintptr BasePriority uintptr UniqueProcessId uintptr InheritedFromUniqueProcessId uintptr } type unicodeString struct { Length uint16 MaximumLength uint16 Buffer uintptr } // Partial RTL_USER_PROCESS_PARAMETERS type rtlUserProcessParameters struct { Reserved1 [16]byte Reserved2 [5]uintptr CurrentDirectoryPath unicodeString CurrentDirectoryHandle uintptr DllPath unicodeString ImagePathName unicodeString CommandLine unicodeString Environment uintptr } type PROCESSENTRY32 struct { Size uint32 CntUsage uint32 ProcessID uint32 DefaultHeapID uintptr ModuleID uint32 CntThreads uint32 ParentProcessID uint32 PriClassBase int32 Flags uint32 ExeFile [260]uint16 } type Win32ProcessInfo struct { PPID int CommandLine string Exe string Cwd string Env []string StartedAt time.Time } func GetProcessDetailedInfo(pid int) (Win32ProcessInfo, error) { var info Win32ProcessInfo // 1. Try Full Access (Query Info + VM Read) handle, err := syscall.OpenProcess(PROCESS_QUERY_INFORMATION|PROCESS_VM_READ, false, uint32(pid)) if err == nil { defer syscall.CloseHandle(handle) err := getFullProcessInfo(handle, pid, &info) if err == nil { return info, nil } // If getFullProcessInfo fails (e.g. PEB read error), fall through to limited } // 2. Fallback: Try Limited Access (Query Limited Info) // This allows getting Exe Path and Start Time for elevated processes from standard user. handleLimited, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { // Fallback: If we can't open the process (Access Denied), try getting basic info from the snapshot. ppid, exe, snapErr := getInfoFromSnapshot(pid) if snapErr == nil { info.PPID = ppid info.Exe = exe info.CommandLine = exe return info, nil } return info, err } defer syscall.CloseHandle(handleLimited) // Get Start Time info.StartedAt = getProcessStartTime(handleLimited) // Get Exe Path via QueryFullProcessImageName (Kernel32) exePath := getProcessImageName(handleLimited) info.Exe = exePath // Default CommandLine to Exe name if we can't read memory if exePath != "" { info.CommandLine = filepath.Base(exePath) } // Get PPID via Snapshot (since we can't query it from process handle easily without full rights/classes) ppid, _, _ := getInfoFromSnapshot(pid) info.PPID = ppid // Cwd and Env are unavailable without VM_READ info.Cwd = "" info.Env = []string{} return info, nil } func getFullProcessInfo(handle syscall.Handle, pid int, info *Win32ProcessInfo) error { info.StartedAt = getProcessStartTime(handle) var pbi processBasicInformation var returnLength uint32 status, _, _ := procNtQueryInfo.Call( uintptr(handle), 0, // ProcessBasicInformation uintptr(unsafe.Pointer(&pbi)), uintptr(unsafe.Sizeof(pbi)), uintptr(unsafe.Pointer(&returnLength)), ) if status != 0 { return fmt.Errorf("NtQueryInformationProcess failed with status %x", status) } info.PPID = int(pbi.InheritedFromUniqueProcessId) if pbi.PebBaseAddress == 0 { return fmt.Errorf("PEB Base Address is 0") } // Read PEB var pebPtr uintptr paramsOffset := uintptr(0x20) if unsafe.Sizeof(uintptr(0)) == 4 { paramsOffset = 0x10 } if !readProcessMemory(handle, pbi.PebBaseAddress+paramsOffset, unsafe.Pointer(&pebPtr), unsafe.Sizeof(pebPtr)) { return fmt.Errorf("failed to read PEB ProcessParameters address") } var params rtlUserProcessParameters if !readProcessMemory(handle, pebPtr, unsafe.Pointer(¶ms), unsafe.Sizeof(params)) { return fmt.Errorf("failed to read ProcessParameters struct") } info.Cwd = readUnicodeString(handle, params.CurrentDirectoryPath) info.CommandLine = readUnicodeString(handle, params.CommandLine) info.Exe = readUnicodeString(handle, params.ImagePathName) info.Env = []string{} return nil } func readProcessMemory(handle syscall.Handle, addr uintptr, dest unsafe.Pointer, size uintptr) bool { var read uint32 ret, _, _ := procReadProcessMem.Call( uintptr(handle), addr, uintptr(dest), size, uintptr(unsafe.Pointer(&read)), ) return ret != 0 } func readUnicodeString(handle syscall.Handle, us unicodeString) string { if us.Length == 0 { return "" } buf := make([]uint16, us.Length/2) if !readProcessMemory(handle, us.Buffer, unsafe.Pointer(&buf[0]), uintptr(us.Length)) { return "" } return syscall.UTF16ToString(buf) } func getProcessStartTime(handle syscall.Handle) time.Time { var creation, exit, kernel, user syscall.Filetime ret, _, _ := procGetProcessTimes.Call( uintptr(handle), uintptr(unsafe.Pointer(&creation)), uintptr(unsafe.Pointer(&exit)), uintptr(unsafe.Pointer(&kernel)), uintptr(unsafe.Pointer(&user)), ) if ret == 0 { return time.Time{} } return time.Unix(0, creation.Nanoseconds()) } func getProcessImageName(handle syscall.Handle) string { buf := make([]uint16, 1024) size := uint32(len(buf)) // QueryFullProcessImageNameW(hProcess, 0, lpExeName, lpdwSize) ret, _, _ := procQueryFullProcessImageName.Call( uintptr(handle), 0, uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)), ) if ret == 0 { return "" } return syscall.UTF16ToString(buf[:size]) } func getInfoFromSnapshot(pid int) (int, string, error) { // CreateToolhelp32Snapshot snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(TH32CS_SNAPPROCESS), 0) if syscall.Handle(snap) == syscall.InvalidHandle { return 0, "", fmt.Errorf("failed to create snapshot") } defer syscall.CloseHandle(syscall.Handle(snap)) var pe32 PROCESSENTRY32 pe32.Size = uint32(unsafe.Sizeof(pe32)) // Process32First ret, _, _ := procProcess32First.Call(snap, uintptr(unsafe.Pointer(&pe32))) if ret == 0 { return 0, "", fmt.Errorf("failed to get first process") } for { if int(pe32.ProcessID) == pid { exe := syscall.UTF16ToString(pe32.ExeFile[:]) return int(pe32.ParentProcessID), exe, nil } // Process32Next ret, _, _ = procProcess32Next.Call(snap, uintptr(unsafe.Pointer(&pe32))) if ret == 0 { break } } return 0, "", fmt.Errorf("process %d not found in snapshot", pid) } ================================================ FILE: internal/proc/process_darwin.go ================================================ //go:build darwin package proc import ( "fmt" "os" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) func ReadProcess(pid int) (model.Process, error) { pidStr := strconv.Itoa(pid) // Format: pid(0) ppid(1) uid(2) lstart(3-7) state(8) ucomm(9) pcpu(10) rss(11) args(12+) // args= MUST be last because it is variable-width. cmd := exec.Command("ps", "-p", pidStr, "-o", "pid=,ppid=,uid=,lstart=,state=,ucomm=,pcpu=,rss=,args=") cmd.Env = buildEnvForPS() out, err := cmd.Output() if err != nil { return model.Process{}, fmt.Errorf("process %d not found: %w", pid, err) } line := strings.TrimSpace(string(out)) if line == "" { return model.Process{}, fmt.Errorf("process %d not found", pid) } fields := strings.Fields(line) if len(fields) < 12 { return model.Process{}, fmt.Errorf("unexpected ps output format for pid %d", pid) } ppid, _ := strconv.Atoi(fields[1]) uid, _ := strconv.Atoi(fields[2]) lstartStr := strings.Join(fields[3:8], " ") startedAt, _ := time.Parse("Mon Jan 2 15:04:05 2006", lstartStr) if startedAt.IsZero() { startedAt = time.Now().UTC() } state := fields[8] comm := fields[9] cpuPct, _ := strconv.ParseFloat(fields[10], 64) rssKB, _ := strconv.ParseFloat(fields[11], 64) rawCmdline := "" if len(fields) > 12 { rawCmdline = strings.Join(fields[12:], " ") } cmdline := rawCmdline if cmdline == "" { cmdline = comm } env := getEnvironment(pid) cwd, binPath := getCwdAndBinaryPath(pid) health := "healthy" forked := "unknown" switch state { case "Z": health = "zombie" case "T": health = "stopped" } if cpuPct > 90 { health = "high-cpu" } rssMB := rssKB / 1024 if rssMB > 1024 { health = "high-mem" } if ppid != 1 && comm != "launchd" { forked = "forked" } else { forked = "not-forked" } user := readUserByUID(uid) container := detectContainerFromCmdline(cmdline) if comm == "docker-proxy" && container == "" { container = resolveDockerProxyContainer(cmdline) } service := detectLaunchdService(pid) gitRepo, gitBranch := detectGitInfo(cwd) inodes := socketsForPID(pid) var ports []int var addrs []string for _, inode := range inodes { addrPort := strings.SplitN(inode, ":", 2) if len(addrPort) < 2 { continue } port, _ := strconv.Atoi(addrPort[1]) ports = append(ports, port) addrs = append(addrs, addrPort[0]) } displayName := deriveDisplayCommand(comm, rawCmdline) if displayName == "" { displayName = comm } exeDeleted := false if binPath != "" { _, statErr := os.Stat(binPath) exeDeleted = os.IsNotExist(statErr) } return model.Process{ PID: pid, PPID: ppid, Command: displayName, Cmdline: cmdline, StartedAt: startedAt, User: user, WorkingDir: cwd, GitRepo: gitRepo, GitBranch: gitBranch, Container: container, Service: service, ListeningPorts: ports, BindAddresses: addrs, Health: health, Forked: forked, Env: env, ExeDeleted: exeDeleted, }, nil } // getCwdAndBinaryPath returns the working directory and executable path for a process. func getCwdAndBinaryPath(pid int) (cwd string, binPath string) { cwd = "unknown" out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd,txt", "-F", "fn").Output() if err != nil { return cwd, "" } // lsof -F fn output has lines like: // p // fcwd // n/path/to/cwd // ftxt // n/path/to/binary currentFD := "" for line := range strings.Lines(string(out)) { if len(line) < 2 { continue } switch line[0] { case 'f': currentFD = line[1:] case 'n': path := strings.TrimSpace(line[1:]) switch currentFD { case "cwd": cwd = path case "txt": if binPath == "" { binPath = path } } } } return cwd, binPath } func getEnvironment(pid int) []string { var env []string // On macOS, getting environment of another process requires elevated privileges // or using the proc_pidinfo syscall. For simplicity, we use ps -E when available // Note: This might not work for all processes due to SIP restrictions out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-E", "-o", "command=").Output() if err != nil { return env } // The -E output appends environment to the command // This is a simplified approach; full env parsing would need libproc output := string(out) // Look for common environment variable patterns for _, part := range strings.Fields(output) { if strings.Contains(part, "=") && !strings.HasPrefix(part, "-") { // Basic validation - should look like VAR=value eqIdx := strings.Index(part, "=") if eqIdx > 0 { varName := part[:eqIdx] // Check if it looks like an env var name (uppercase or common patterns) if isEnvVarName(varName) { env = append(env, part) } } } } return env } func isEnvVarName(name string) bool { if len(name) == 0 { return false } // Common env var patterns for _, c := range name { if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { return false } } return true } func detectLaunchdService(pid int) string { // Try to find the launchd service managing this process // Use launchctl blame on macOS 10.10+ out, err := exec.Command("launchctl", "blame", strconv.Itoa(pid)).Output() if err == nil { blame := strings.TrimSpace(string(out)) if blame != "" && !strings.Contains(blame, "unknown") { return blame } } // Fallback: check if process is a known launchd service // by looking at the parent chain or service database return "" } ================================================ FILE: internal/proc/process_darwin_test.go ================================================ //go:build darwin package proc import ( "fmt" "os" "path/filepath" "testing" ) func TestGetCwdAndBinaryPath(t *testing.T) { tmpDir := t.TempDir() binPath := filepath.Join(tmpDir, "fakebin") cwdPath := filepath.Join(tmpDir, "fakecwd") if err := os.WriteFile(binPath, []byte("ok"), 0o644); err != nil { t.Fatalf("write file: %v", err) } if err := os.Mkdir(cwdPath, 0o755); err != nil { t.Fatalf("mkdir cwd: %v", err) } // Create a fake lsof command that emits both cwd and txt entries. fakeBinDir := filepath.Join(tmpDir, "bin") if err := os.Mkdir(fakeBinDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } lsofScript := filepath.Join(fakeBinDir, "lsof") script := fmt.Sprintf("#!/bin/sh\nprintf 'p123\\nfcwd\\nn%s\\nftxt\\nn%s\\n'", cwdPath, binPath) if err := os.WriteFile(lsofScript, []byte(script), 0o755); err != nil { t.Fatalf("write lsof script: %v", err) } t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH")) cwd, bin := getCwdAndBinaryPath(123) if cwd != cwdPath { t.Fatalf("getCwdAndBinaryPath() cwd = %q, want %q", cwd, cwdPath) } if bin != binPath { t.Fatalf("getCwdAndBinaryPath() binPath = %q, want %q", bin, binPath) } // Binary exists — not deleted _, err := os.Stat(bin) if os.IsNotExist(err) { t.Fatalf("expected binary to exist") } // Delete binary, verify stat detects it if err := os.Remove(binPath); err != nil { t.Fatalf("rm: %v", err) } _, err = os.Stat(bin) if !os.IsNotExist(err) { t.Fatalf("expected binary to be detected as deleted") } } ================================================ FILE: internal/proc/process_freebsd.go ================================================ //go:build freebsd package proc import ( "fmt" "os" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) func ReadProcess(pid int) (model.Process, error) { pidStr := strconv.Itoa(pid) // Format: pid(0) ppid(1) uid(2) jid(3) state(4) pcpu(5) rss(6) lstart(7-11) comm(12) args(13+) // args= MUST be last because it is variable-width. cmd := exec.Command("ps", "-p", pidStr, "-o", "pid=", "-o", "ppid=", "-o", "uid=", "-o", "jid=", "-o", "state=", "-o", "pcpu=", "-o", "rss=", "-o", "lstart=", "-o", "comm=", "-o", "args=") cmd.Env = buildEnvForPS() out, err := cmd.Output() if err != nil { return model.Process{}, fmt.Errorf("process %d not found: %w", pid, err) } line := strings.TrimSpace(string(out)) if line == "" { return model.Process{}, fmt.Errorf("process %d not found", pid) } fields := strings.Fields(line) if len(fields) < 13 { return model.Process{}, fmt.Errorf("unexpected ps output format for pid %d: got %d fields in %q", pid, len(fields), line) } ppid, _ := strconv.Atoi(fields[1]) uid, _ := strconv.Atoi(fields[2]) jid := fields[3] state := fields[4] cpuPct, _ := strconv.ParseFloat(fields[5], 64) rssKB, _ := strconv.ParseFloat(fields[6], 64) lstartStr := strings.Join(fields[7:12], " ") startedAt := parseLstart(lstartStr) if startedAt.IsZero() { startedAt = time.Now().UTC() } comm := fields[12] cmdline := comm if len(fields) > 13 { cmdline = strings.Join(fields[13:], " ") } cwd, binPath := getCwdAndBinaryPath(pid) env := getEnvironment(pid) health := "healthy" forked := "unknown" // FreeBSD states can be multi-character like "Is", "Ss", "R", "Z", "T" if len(state) > 0 { switch state[0] { case 'Z': health = "zombie" case 'T': health = "stopped" } } if cpuPct > 90 { health = "high-cpu" } rssMB := rssKB / 1024 if rssMB > 1024 { health = "high-mem" } if ppid != 1 && comm != "init" { forked = "forked" } else { forked = "not-forked" } user := readUserByUID(uid) container := detectContainerFreeBSD(jid, cmdline) displayName := deriveDisplayCommand(comm, cmdline) if displayName == "" { displayName = comm } if comm == "docker-proxy" && container == "" { container = resolveDockerProxyContainer(cmdline) } service := detectRcService(pid) gitRepo, gitBranch := detectGitInfo(cwd) sockets, _ := readListeningSockets() inodes := socketsForPID(pid) var ports []int var addrs []string for _, inode := range inodes { if s, ok := sockets[inode]; ok { ports = append(ports, s.Port) addrs = append(addrs, s.Address) } } exeDeleted := false if binPath != "" { _, statErr := os.Stat(binPath) exeDeleted = os.IsNotExist(statErr) } return model.Process{ PID: pid, PPID: ppid, Command: displayName, Cmdline: cmdline, StartedAt: startedAt, User: user, WorkingDir: cwd, GitRepo: gitRepo, GitBranch: gitBranch, Container: container, Service: service, ListeningPorts: ports, BindAddresses: addrs, Health: health, Forked: forked, Env: env, ExeDeleted: exeDeleted, }, nil } // getCwdAndBinaryPath returns the working directory and executable path for a process. func getCwdAndBinaryPath(pid int) (cwd string, binPath string) { cwd = "unknown" out, err := exec.Command("procstat", "-f", strconv.Itoa(pid)).Output() if err != nil { return cwd, "" } // procstat -f output format: PID COMM FD TYPE FLAGS ... PATH for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) < 4 { continue } switch fields[2] { case "cwd": cwd = fields[len(fields)-1] case "text": if binPath == "" { binPath = fields[len(fields)-1] } } } return cwd, binPath } func parseLstart(lstartStr string) time.Time { if lstartStr == "" { return time.Time{} } // FreeBSD lstart format with LC_ALL=C: "Thu Jan 2 10:26:00 2025" // strings.Fields collapses double spaces, so try the standard format. formats := []string{ "Mon Jan 2 15:04:05 2006", "Mon Jan 2 15:04:05 2006", "Mon Jan 02 15:04:05 2006", } for _, format := range formats { if t, err := time.Parse(format, lstartStr); err == nil { return t } } return time.Time{} } func getEnvironment(pid int) []string { var env []string // Use procstat -e to get environment variables // procstat does not require procfs to be mounted out, err := exec.Command("procstat", "-e", strconv.Itoa(pid)).Output() if err != nil { return env } // Parse procstat -e output // Format: PID COMM ENVVAR=VALUE ... for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) < 3 { continue } // Skip header and PID/COMM columns for _, field := range fields[2:] { if strings.Contains(field, "=") { env = append(env, field) } } } return env } // detectContainerFreeBSD checks for jail membership first, then falls back // to cmdline-based container detection shared with other platforms. func detectContainerFreeBSD(jid, cmdline string) string { if jid != "" && jid != "0" { if name := resolveJailName(jid); name != "" { return "jail: " + name } return "jail (" + jid + ")" } return detectContainerFromCmdline(cmdline) } func detectRcService(pid int) string { // FreeBSD uses rc.d for service management // Try to find the service by checking /var/run/*.pid files pidStr := strconv.Itoa(pid) entries, err := os.ReadDir("/var/run") if err != nil { return "" } for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".pid") { continue } pidFile := "/var/run/" + entry.Name() content, err := os.ReadFile(pidFile) if err != nil { continue } if strings.TrimSpace(string(content)) == pidStr { // Found matching PID file serviceName := strings.TrimSuffix(entry.Name(), ".pid") return serviceName } } return "" } func resolveJailName(jid string) string { out, err := exec.Command("jls", "-j", jid, "name").Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } ================================================ FILE: internal/proc/process_linux.go ================================================ //go:build linux package proc import ( "fmt" "os" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // isValidSymlinkTarget validates that a symlink target is safe and reasonable func isValidSymlinkTarget(target string) bool { return target != "" } func ReadProcess(pid int) (model.Process, error) { // Verify process still exists before reading if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); os.IsNotExist(err) { return model.Process{}, fmt.Errorf("process %d does not exist", pid) } // Read all proc files in a logical order to minimize TOCTOU issues // Start with stat file which is most likely to fail if process disappears statPath := fmt.Sprintf("/proc/%d/stat", pid) stat, err := os.ReadFile(statPath) if err != nil { return model.Process{}, fmt.Errorf("process %d disappeared during read", pid) } // Read environment variables env := []string{} envBytes, errEnv := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid)) if errEnv == nil { for _, e := range strings.Split(string(envBytes), "\x00") { if e != "" { env = append(env, e) } } } // Health status health := "healthy" // Working directory var cwd, cwdErr = os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) if cwdErr != nil { cwd = "unknown" } else { // Validate symlink target is reasonable if !isValidSymlinkTarget(cwd) { cwd = "invalid" } } // Container detection container := "" cgroupFile := fmt.Sprintf("/proc/%d/cgroup", pid) if cgroupData, err := os.ReadFile(cgroupFile); err == nil { cgroupStr := string(cgroupData) var containerID string switch { case strings.Contains(cgroupStr, "docker"): container = "docker" containerID = extractContainerID(cgroupStr, "docker-", "docker/") if containerID != "" { if name := resolveContainerName(containerID, "docker"); name != "" { container = name } else { container = "docker (" + shortID(containerID) + ")" } } case strings.Contains(cgroupStr, "podman"), strings.Contains(cgroupStr, "libpod"): container = "podman" containerID = extractContainerID(cgroupStr, "libpod-", "libpod/") if containerID != "" { if name := resolveContainerName(containerID, "podman"); name != "" { container = name } else { container = "podman (" + shortID(containerID) + ")" } } case strings.Contains(cgroupStr, "kubepods"): container = "kubernetes" if id := findLongHexID(cgroupStr); id != "" { containerID = id if name := resolveContainerName(containerID, "crictl"); name != "" { container = "k8s: " + name } else { container = "k8s (" + shortID(containerID) + ")" } } case strings.Contains(cgroupStr, "containerd"): container = "containerd" if id := findLongHexID(cgroupStr); id != "" { containerID = id if name := resolveContainerName(containerID, "nerdctl"); name != "" { container = "containerd: " + name } else { container = "containerd (" + shortID(containerID) + ")" } } case strings.Contains(cgroupStr, "colima"): container = "colima" if idx := strings.Index(cgroupStr, "colima-"); idx != -1 { rest := cgroupStr[idx+7:] if dot := strings.Index(rest, ".scope"); dot != -1 { container = "colima: " + rest[:dot] } } else if strings.Contains(cgroupStr, "colima") { container = "colima: default" } } } // Snap/Flatpak sandbox detection via environment variables if container == "" { for _, e := range env { if strings.HasPrefix(e, "SNAP_NAME=") { container = "snap: " + e[len("SNAP_NAME="):] break } if strings.HasPrefix(e, "FLATPAK_ID=") { container = "flatpak: " + e[len("FLATPAK_ID="):] break } } } // Service detection (try systemctl show for this PID) service := "" svcOut, err := exec.Command("systemctl", "status", fmt.Sprintf("%d", pid)).CombinedOutput() if err == nil && strings.Contains(string(svcOut), "Loaded: loaded") { // Try to extract service name from output for line := range strings.Lines(string(svcOut)) { if strings.HasPrefix(line, "Loaded:") && strings.Contains(line, ".service") { parts := strings.Fields(line) for _, part := range parts { if strings.HasSuffix(part, ".service") { service = part break } } } } } gitRepo, gitBranch := detectGitInfo(cwd) // stat format is evil, command is inside () raw := string(stat) open := strings.Index(raw, "(") close := strings.LastIndex(raw, ")") if open == -1 || close == -1 || close+2 >= len(raw) { return model.Process{}, fmt.Errorf("invalid stat format for pid %d", pid) } comm := raw[open+1 : close] fields := strings.Fields(raw[close+2:]) // /proc/[pid]/stat has 52 fields after comm; we need at least index 21 (rss) if len(fields) < 22 { return model.Process{}, fmt.Errorf("unexpected stat format for pid %d: got %d fields", pid, len(fields)) } ppid, _ := strconv.Atoi(fields[1]) state := processState(fields) startTicks, _ := strconv.ParseInt(fields[19], 10, 64) // Fork detection: if ppid != 1 and not systemd, likely forked; also check for vfork/fork/clone flags if possible var forked string if ppid != 1 && comm != "systemd" { forked = "forked" } else { forked = "not-forked" } startedAt := bootTime().Add(time.Duration(startTicks) * time.Second / time.Duration(ticksPerSecond())) // Health: zombie/stopped switch state { case "Z": health = "zombie" case "T": health = "stopped" } // High CPU/memory (simple: >80% of total) utime, _ := strconv.ParseFloat(fields[11], 64) stime, _ := strconv.ParseFloat(fields[12], 64) rssPages, _ := strconv.ParseFloat(fields[21], 64) clkTck := float64(ticksPerSecond()) totalCPU := (utime + stime) / clkTck if totalCPU > 60*60*2 { // >2h CPU time health = "high-cpu" } pageSize := float64(os.Getpagesize()) memBytes := rssPages * pageSize memMB := memBytes / (1024 * 1024) if memMB > 1024 { health = "high-mem" } user := readUser(pid) sockets, _ := readSocketsCached() inodes := socketsForPID(pid) var ports []int var addrs []string // Check for IPv4 listeners first to avoid duplicates when synthesizing ipv4Listeners := make(map[int]bool) for _, inode := range inodes { if s, ok := sockets[inode]; ok { // Only consider listening sockets for this summary if s.State != "LISTEN" { continue } if s.Address == "0.0.0.0" { ipv4Listeners[s.Port] = true } } } dualStackAllowed := isDualStackEnabled() for _, inode := range inodes { if s, ok := sockets[inode]; ok { ports = append(ports, s.Port) addrs = append(addrs, s.Address) // Heuristic: If system allows dual-stack, we see ::, and there is NO explicit 0.0.0.0 listener, // assume implicit dual-stack and show it. if dualStackAllowed && s.Address == "::" && !ipv4Listeners[s.Port] { ports = append(ports, s.Port) addrs = append(addrs, "0.0.0.0") } } } // Full command line cmdline := "" cmdlineBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) if err == nil { cmd := strings.ReplaceAll(string(cmdlineBytes), "\x00", " ") cmdline = strings.TrimSpace(cmd) } // Recover full process name when kernel comm field is truncated displayName := deriveDisplayCommand(comm, cmdline) if displayName == "" { displayName = comm } if comm == "docker-proxy" && container == "" { container = resolveDockerProxyContainer(cmdline) } return model.Process{ PID: pid, PPID: ppid, Command: displayName, Cmdline: cmdline, StartedAt: startedAt, User: user, WorkingDir: cwd, GitRepo: gitRepo, GitBranch: gitBranch, Container: container, Service: service, ListeningPorts: ports, BindAddresses: addrs, Health: health, Forked: forked, Env: env, ExeDeleted: isBinaryDeleted(pid), Capabilities: ReadCapabilities(pid), }, nil } func isBinaryDeleted(pid int) bool { exePath, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) if err != nil { return false } return strings.HasSuffix(exePath, " (deleted)") } // The kernel emits the state immediately after the command, so fields[0] always carries it. func processState(fields []string) string { if len(fields) == 0 { return "" } state := fields[0] if len(state) == 0 { return "" } return state[:1] } // isDualStackEnabled checks if /proc/sys/net/ipv6/bindv6only is 0 (or missing), // which implies that IPv6 sockets can handle IPv4 traffic by default. func isDualStackEnabled() bool { data, err := os.ReadFile("/proc/sys/net/ipv6/bindv6only") if err != nil { return true } return strings.TrimSpace(string(data)) == "0" } func extractContainerID(cgroup, dashPrefix, slashPrefix string) string { // Pattern 1: .../prefix-.scope if idx := strings.Index(cgroup, dashPrefix); idx != -1 { rest := cgroup[idx+len(dashPrefix):] if dot := strings.Index(rest, ".scope"); dot != -1 { return rest[:dot] } } // Pattern 2: .../prefix/ if idx := strings.Index(cgroup, slashPrefix); idx != -1 { rest := cgroup[idx+len(slashPrefix):] if len(rest) >= 64 { return rest[:64] } } return "" } ================================================ FILE: internal/proc/process_list_darwin.go ================================================ //go:build darwin package proc import ( "fmt" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // ListProcesses returns a list of all running processes with basic details (PID, Command, State). // This is used by the TUI to display the process list. func ListProcesses() ([]model.Process, error) { // Use ps to fetch rich information efficiently: pid, ppid, user, lstart, %cpu, rss, %mem, comm, args out, err := exec.Command("ps", "-axo", "pid,ppid,user,lstart,%cpu,rss,%mem,comm,args").Output() if err != nil { // Fallback to fast snapshot if ps fails return ListProcessSnapshot() } lines := strings.Split(strings.TrimSpace(string(out)), "\n") // Skip header if len(lines) > 0 { lines = lines[1:] } processes := make([]model.Process, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) // Expected minimum fields: pid(1) + ppid(1) + user(1) + lstart(5) + cpu(1) + rss(1) + mem(1) + comm(1) = 12 if len(fields) < 12 { continue } pid, err := strconv.Atoi(fields[0]) if err != nil { continue } ppid, err := strconv.Atoi(fields[1]) if err != nil { continue } user := fields[2] // lstart format: "Mon Jan 1 12:00:00 2024" (5 fields) timeStr := strings.Join(fields[3:8], " ") started, _ := time.Parse("Mon Jan 2 15:04:05 2006", timeStr) cpu, _ := strconv.ParseFloat(fields[8], 64) rss, _ := strconv.ParseUint(fields[9], 10, 64) rss *= 1024 mem, _ := strconv.ParseFloat(fields[10], 64) comm := fields[11] cmdline := comm if len(fields) > 12 { cmdline = strings.Join(fields[12:], " ") } // Recover full process name when kernel comm field is truncated displayName := deriveDisplayCommand(comm, cmdline) if displayName == "" { displayName = comm } processes = append(processes, model.Process{ PID: pid, PPID: ppid, Command: displayName, User: user, StartedAt: started, CPUPercent: cpu, MemoryRSS: rss, MemoryPercent: mem, Cmdline: cmdline, }) } return processes, nil } // ListProcessSnapshot collects a lightweight view of running processes // for child/descendant discovery. We avoid full ReadProcess calls to keep // this path fast and to reduce permission-sensitive reads. func ListProcessSnapshot() ([]model.Process, error) { out, err := exec.Command("ps", "-axo", "pid=,ppid=,comm=").Output() if err != nil { return nil, fmt.Errorf("ps process list: %w", err) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") processes := make([]model.Process, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } pid, err := strconv.Atoi(fields[0]) if err != nil { continue } ppid, err := strconv.Atoi(fields[1]) if err != nil { continue } command := strings.Join(fields[2:], " ") processes = append(processes, model.Process{ PID: pid, PPID: ppid, Command: command, }) } return processes, nil } ================================================ FILE: internal/proc/process_list_freebsd.go ================================================ //go:build freebsd package proc import ( "fmt" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // ListProcesses returns a list of all running processes with basic details (PID, Command, State). // This is used by the TUI to display the process list. func ListProcesses() ([]model.Process, error) { // Use ps to fetch rich information efficiently: pid, ppid, user, lstart, %cpu, rss, %mem, comm, args out, err := exec.Command("ps", "-axo", "pid,ppid,user,lstart,%cpu,rss,%mem,comm,args").Output() if err != nil { // Fallback to fast snapshot if ps fails return ListProcessSnapshot() } lines := strings.Split(strings.TrimSpace(string(out)), "\n") // Skip header if len(lines) > 0 { lines = lines[1:] } processes := make([]model.Process, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) // Expected minimum fields: pid(1) + ppid(1) + user(1) + lstart(5) + cpu(1) + rss(1) + mem(1) + comm(1) = 12 if len(fields) < 12 { continue } pid, err := strconv.Atoi(fields[0]) if err != nil { continue } ppid, err := strconv.Atoi(fields[1]) if err != nil { continue } user := fields[2] // lstart format: "Mon Jan 1 12:00:00 2024" (5 fields) timeStr := strings.Join(fields[3:8], " ") started, _ := time.Parse("Mon Jan 2 15:04:05 2006", timeStr) cpu, _ := strconv.ParseFloat(fields[8], 64) rss, _ := strconv.ParseUint(fields[9], 10, 64) rss *= 1024 mem, _ := strconv.ParseFloat(fields[10], 64) comm := fields[11] cmdline := comm if len(fields) > 12 { cmdline = strings.Join(fields[12:], " ") } // Recover full process name when kernel comm field is truncated displayName := deriveDisplayCommand(comm, cmdline) if displayName == "" { displayName = comm } processes = append(processes, model.Process{ PID: pid, PPID: ppid, Command: displayName, User: user, StartedAt: started, CPUPercent: cpu, MemoryRSS: rss, MemoryPercent: mem, Cmdline: cmdline, }) } return processes, nil } // ListProcessSnapshot collects a lightweight view of running processes // for child/descendant discovery. We use ps on FreeBSD similar to Darwin. func ListProcessSnapshot() ([]model.Process, error) { out, err := exec.Command("ps", "-axo", "pid=,ppid=,comm=").Output() if err != nil { return nil, fmt.Errorf("ps process list: %w", err) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") processes := make([]model.Process, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } pid, err := strconv.Atoi(fields[0]) if err != nil { continue } ppid, err := strconv.Atoi(fields[1]) if err != nil { continue } command := strings.Join(fields[2:], " ") processes = append(processes, model.Process{ PID: pid, PPID: ppid, Command: command, }) } return processes, nil } ================================================ FILE: internal/proc/process_list_linux.go ================================================ //go:build linux package proc import ( "fmt" "os" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // ListProcesses returns a list of all running processes with basic details (PID, Command, State). // This is used by the TUI to display the process list. func ListProcesses() ([]model.Process, error) { // Use ps to fetch rich information efficiently: pid, ppid, user, lstart, %cpu, rss, %mem, comm, args out, err := exec.Command("ps", "-axo", "pid,ppid,user,lstart,%cpu,rss,%mem,comm,args").Output() if err != nil { // Fallback to fast snapshot if ps fails return ListProcessSnapshot() } lines := strings.Split(strings.TrimSpace(string(out)), "\n") // Skip header if len(lines) > 0 { lines = lines[1:] } processes := make([]model.Process, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) // Expected minimum fields: pid(1) + ppid(1) + user(1) + lstart(5) + cpu(1) + rss(1) + mem(1) + comm(1) = 12 if len(fields) < 12 { continue } pid, err := strconv.Atoi(fields[0]) if err != nil { continue } ppid, err := strconv.Atoi(fields[1]) if err != nil { continue } user := fields[2] // lstart format: "Mon Jan 1 12:00:00 2024" (5 fields) timeStr := strings.Join(fields[3:8], " ") started, _ := time.Parse("Mon Jan 2 15:04:05 2006", timeStr) cpu, _ := strconv.ParseFloat(fields[8], 64) rss, _ := strconv.ParseUint(fields[9], 10, 64) rss *= 1024 mem, _ := strconv.ParseFloat(fields[10], 64) comm := fields[11] cmdline := comm if len(fields) > 12 { cmdline = strings.Join(fields[12:], " ") } // Recover full process name when kernel comm field is truncated displayName := deriveDisplayCommand(comm, cmdline) if displayName == "" { displayName = comm } processes = append(processes, model.Process{ PID: pid, PPID: ppid, Command: displayName, User: user, StartedAt: started, CPUPercent: cpu, MemoryRSS: rss, MemoryPercent: mem, Cmdline: cmdline, }) } return processes, nil } // ListProcessSnapshot collects a lightweight view of running processes // for child/descendant discovery. We avoid full ReadProcess calls to keep // this path fast and to reduce permission-sensitive reads. func ListProcessSnapshot() ([]model.Process, error) { entries, err := os.ReadDir("/proc") if err != nil { return nil, fmt.Errorf("read /proc: %w", err) } processes := make([]model.Process, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { continue } pid, err := strconv.Atoi(entry.Name()) if err != nil { continue } statPath := fmt.Sprintf("/proc/%d/stat", pid) stat, err := os.ReadFile(statPath) if err != nil { continue } proc, err := parseStatSnapshot(pid, stat) if err != nil { continue } processes = append(processes, proc) } return processes, nil } func parseStatSnapshot(pid int, stat []byte) (model.Process, error) { raw := string(stat) open := strings.Index(raw, "(") close := strings.LastIndex(raw, ")") if open == -1 || close == -1 || close <= open { return model.Process{}, fmt.Errorf("invalid stat format") } comm := raw[open+1 : close] fields := strings.Fields(raw[close+2:]) if len(fields) < 2 { return model.Process{}, fmt.Errorf("invalid stat format") } ppid, err := strconv.Atoi(fields[1]) if err != nil { return model.Process{}, fmt.Errorf("invalid ppid") } return model.Process{ PID: pid, PPID: ppid, Command: comm, }, nil } ================================================ FILE: internal/proc/process_list_windows.go ================================================ //go:build windows package proc import ( "encoding/csv" "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // ListProcesses returns a list of all running processes with basic details (PID, Command, State). // This is used by the TUI to display the process list. func ListProcesses() ([]model.Process, error) { // TODO: Enrich this with more data (User, Memory, CPU) for the TUI return ListProcessSnapshot() } // ListProcessSnapshot collects a lightweight view of running processes // for child/descendant discovery. func ListProcessSnapshot() ([]model.Process, error) { cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "Get-CimInstance -ClassName Win32_Process | Select-Object Name,ParentProcessId,ProcessId | ConvertTo-Csv -NoTypeInformation") out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("powershell process list: %w", err) } r := csv.NewReader(strings.NewReader(string(out))) records, err := r.ReadAll() if err != nil { return nil, fmt.Errorf("parse powershell output: %w", err) } if len(records) < 2 { return []model.Process{}, nil } headers := records[0] nameIdx := -1 ppidIdx := -1 pidIdx := -1 for i, h := range headers { switch h { case "Name": nameIdx = i case "ParentProcessId": ppidIdx = i case "ProcessId": pidIdx = i } } if nameIdx == -1 || ppidIdx == -1 || pidIdx == -1 { // Fallback to hardcoded indices if header parsing fails or is unexpected return nil, fmt.Errorf("invalid powershell output headers: %v", headers) } processes := make([]model.Process, 0, len(records)-1) for _, record := range records[1:] { if len(record) <= pidIdx || len(record) <= ppidIdx || len(record) <= nameIdx { continue } pid, err := strconv.Atoi(record[pidIdx]) if err != nil { continue } ppid, err := strconv.Atoi(record[ppidIdx]) if err != nil { continue } name := record[nameIdx] processes = append(processes, model.Process{ PID: pid, PPID: ppid, Command: name, }) } return processes, nil } ================================================ FILE: internal/proc/process_windows.go ================================================ //go:build windows package proc import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func ReadProcess(pid int) (model.Process, error) { info, err := GetProcessDetailedInfo(pid) if err != nil { return model.Process{}, err } name := "" if info.Exe != "" { name = filepath.Base(info.Exe) } ports, addrs := GetListeningPortsForPID(pid) serviceName := detectWindowsServiceSource(pid) container := detectContainerFromCmdline(info.CommandLine) gitRepo, gitBranch := detectGitInfo(info.Cwd) return model.Process{ PID: pid, PPID: info.PPID, Command: name, Cmdline: info.CommandLine, Exe: info.Exe, StartedAt: info.StartedAt, User: readUser(pid), WorkingDir: info.Cwd, GitRepo: gitRepo, GitBranch: gitBranch, ListeningPorts: ports, BindAddresses: addrs, Health: "healthy", Forked: "unknown", Env: info.Env, Service: serviceName, Container: container, ExeDeleted: isWindowsBinaryDeleted(info.Exe), }, nil } func isWindowsBinaryDeleted(path string) bool { if path == "" { return false } _, err := os.Stat(path) return os.IsNotExist(err) } // detectWindowsServiceSource checks if a PID belongs to a Windows Service via Get-CimInstance. // Keeping this as a fallback/auxiliary check for now. func detectWindowsServiceSource(pid int) string { psScript := fmt.Sprintf("Get-CimInstance -ClassName Win32_Service -Filter \"ProcessId=%d\" | Select-Object -ExpandProperty Name", pid) cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", psScript) out, err := cmd.Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } ================================================ FILE: internal/proc/psenv_unix.go ================================================ //go:build darwin || freebsd package proc import ( "os" "strings" ) func buildEnvForPS() []string { var env []string for _, e := range os.Environ() { if !strings.HasPrefix(e, "LC_ALL=") && !strings.HasPrefix(e, "TZ=") { env = append(env, e) } } env = append(env, "LC_ALL=C", "TZ=UTC") return env } ================================================ FILE: internal/proc/resource_darwin.go ================================================ //go:build darwin package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetResourceContext returns resource usage context for a process func GetResourceContext(pid int) *model.ResourceContext { ctx := &model.ResourceContext{} // Check if process is preventing sleep ctx.PreventsSleep = checkPreventsSleep(pid) // Get thermal state ctx.ThermalState = getThermalState() cpu, mem, err := getCPUAndMemoryUsage(pid) if err == nil { ctx.CPUUsage = cpu ctx.MemoryUsage = mem } // Only return if we have meaningful data if ctx.PreventsSleep || ctx.ThermalState != "" || err == nil { return ctx } return nil } // checkPreventsSleep checks if a process has sleep prevention assertions func checkPreventsSleep(pid int) bool { // pmset -g assertions shows all power assertions out, err := exec.Command("pmset", "-g", "assertions").Output() if err != nil { return false } pidStr := strconv.Itoa(pid) for line := range strings.Lines(string(out)) { if containsWholeWord(line, pidStr) { lower := strings.ToLower(line) if strings.Contains(lower, "preventsystemsleep") || strings.Contains(lower, "preventuseridledisplaysleep") || strings.Contains(lower, "preventuseridlesystemsleep") || strings.Contains(lower, "nosleep") { return true } } } return false } // getThermalState returns the current thermal pressure state func getThermalState() string { // pmset -g therm shows thermal conditions out, err := exec.Command("pmset", "-g", "therm").Output() if err != nil { return "" } output := string(out) // Parse thermal state from output // Look for "CPU_Speed_Limit" or thermal pressure indicators if strings.Contains(output, "CPU_Speed_Limit") { // Extract the speed limit percentage for line := range strings.Lines(output) { if strings.Contains(line, "CPU_Speed_Limit") { // Format: CPU_Speed_Limit = 100 parts := strings.Split(line, "=") if len(parts) >= 2 { limitStr := strings.TrimSpace(parts[1]) limit, err := strconv.Atoi(limitStr) if err == nil && limit < 100 { if limit < 50 { return "Heavy throttling" } else if limit < 80 { return "Moderate throttling" } else { return "Light throttling" } } } } } } // Check for thermal pressure level if strings.Contains(output, "Thermal_Level") { for line := range strings.Lines(output) { if strings.Contains(line, "Thermal_Level") { parts := strings.Split(line, "=") if len(parts) >= 2 { level := strings.TrimSpace(parts[1]) switch level { case "0": return "" // Normal, don't show case "1": return "Moderate thermal pressure" case "2": return "Heavy thermal pressure" default: return "Thermal pressure level " + level } } } } } return "" } // GetEnergyImpact attempts to get energy impact for a process // Note: This requires elevated privileges via powermetrics // Returns empty string if not available func GetEnergyImpact(pid int) string { // powermetrics requires root, so we can't easily get per-process energy // Instead, we rely on the prevents-sleep check as a proxy for high energy impact // A future enhancement could parse Activity Monitor's energy data via private APIs return "" } func getCPUAndMemoryUsage(pid int) (float64, uint64, error) { // Construct the command to execute out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "%cpu=,rss=").Output() if err != nil { return 0, 0, err } output := string(out) fields := strings.Fields(output) if len(fields) < 2 { return 0, 0, fmt.Errorf("could not read CPU and memory usage") } // Parse CPU usage cpuUsage, err := strconv.ParseFloat(fields[0], 64) if err != nil { return 0, 0, err } // Parse RSS (Resident Set Size) memory usage in kilobytes rssKilobytes, err := strconv.ParseUint(fields[1], 10, 64) if err != nil { return 0, 0, err } // Convert kilobytes to bytes memoryUsageBytes := rssKilobytes * 1024 return cpuUsage, memoryUsageBytes, nil } ================================================ FILE: internal/proc/resource_freebsd.go ================================================ //go:build freebsd package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetResourceContext returns resource usage context for a process // FreeBSD implementation - basic support func GetResourceContext(pid int) *model.ResourceContext { // FreeBSD doesn't have macOS-style power assertions or thermal monitoring // Could potentially check CPU temperature via sysctl dev.cpu.*.temperature // but this is not process-specific ctx := &model.ResourceContext{} out, err := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "%cpu,rss").Output() if err == nil { lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) > 0 { fields := strings.Fields(lines[len(lines)-1]) if len(fields) >= 2 { if cpu, err := strconv.ParseFloat(fields[0], 64); err == nil { ctx.CPUUsage = cpu } if rssKB, err := strconv.ParseUint(fields[1], 10, 64); err == nil { ctx.MemoryUsage = rssKB * 1024 } } } } if ctx.CPUUsage > 0 || ctx.MemoryUsage > 0 { return ctx } return nil } ================================================ FILE: internal/proc/resource_linux.go ================================================ //go:build linux package proc import ( "context" "fmt" "os" "os/exec" "strconv" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // GetResourceContext returns resource usage context for a process func GetResourceContext(pid int) *model.ResourceContext { ctx := &model.ResourceContext{} ctx.PreventsSleep = checkPreventsSleep(pid) ctx.ThermalState = getThermalState() ctx.AppNapped = getAppNapped(pid) if cpu, err := GetCPUPercent(pid, true); err == nil { ctx.CPUUsage = cpu } ctx.EnergyImpact = GetEnergyImpact(pid) return ctx } // thermal zone info from /sys/class/thermal func getThermalState() string { path := "/sys/class/thermal/thermal_zone0/temp" if _, err := os.Stat(path); os.IsNotExist(err) { return "" } readText, err := os.ReadFile(path) if err != nil { return "" } tempstr := strings.TrimSpace(string(readText)) temp, err := strconv.Atoi(tempstr) if err != nil { return "" } tempC := temp / 1000 switch { case tempC > 90: return fmt.Sprintf("Critical thermal pressure %d", tempC) case tempC > 70: return fmt.Sprintf("High thermal pressure %d", tempC) case tempC > 60: return fmt.Sprintf("Warm thermal state %d", tempC) default: return fmt.Sprintf("Normal thermal state %d", tempC) } } // checkPreventsSleep checks if a process has sleep prevention assertions func checkPreventsSleep(pid int) bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "systemd-inhibit", "--list").Output() if err != nil { return false } pidStr := strconv.Itoa(pid) lines := strings.Split(string(out), "\n") for _, line := range lines { if !containsWholeWord(line, pidStr) { continue } lower := strings.ToLower(line) if strings.Contains(lower, "sleep") || strings.Contains(lower, "idle") || strings.Contains(lower, "shutdown") { return true } } return false } // detect if process is in a stopped/suspended state func getAppNapped(pid int) bool { statFile := fmt.Sprintf("/proc/%d/stat", pid) data, err := os.ReadFile(statFile) if err != nil { return false } dataStr := string(data) lastParenIndex := strings.LastIndex(dataStr, ")") if lastParenIndex == -1 || lastParenIndex+2 >= len(dataStr) { return false } rest := dataStr[lastParenIndex+2:] fields := strings.Fields(rest) if len(fields) < 1 { return false } state := fields[0] return state == "T" || state == "t" } func GetEnergyImpact(pid int, usePs ...bool) string { cpu, err := GetCPUPercent(pid, usePs...) if err != nil { return "" } switch { case cpu > 50: return "Very High" case cpu > 25: return "High" case cpu > 10: return "Medium" case cpu > 2: return "Low" case cpu > 0: return "Very Low" default: return "" } } func GetCPUPercent(pid int, usePs ...bool) (float64, error) { var cpu float64 shouldUsePs := len(usePs) > 0 && usePs[0] if shouldUsePs { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "pcpu=").Output() if err != nil { return 0, err } cpuStr := strings.TrimSpace(string(out)) if cpuStr == "" { return 0, fmt.Errorf("empty ps output") } cpu, err = strconv.ParseFloat(cpuStr, 64) if err != nil { return 0, err } } else { // Use top (default) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "top", "-b", "-n", "1", "-p", strconv.Itoa(pid)).Output() if err != nil { return 0, err } lines := strings.Split(string(out), "\n") found := false for _, line := range lines { fields := strings.Fields(line) if len(fields) >= 9 && fields[0] == strconv.Itoa(pid) { cpuStr := strings.TrimSuffix(fields[8], "%") cpu, err = strconv.ParseFloat(cpuStr, 64) if err == nil { found = true break } } } if !found { return 0, fmt.Errorf("process not found in top output") } } return cpu, nil } ================================================ FILE: internal/proc/resource_windows.go ================================================ //go:build windows package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func GetResourceContext(pid int) *model.ResourceContext { // powershell Get-CimInstance Win32_PerfFormattedData_PerfProc_Process psScript := fmt.Sprintf("Get-CimInstance -ClassName Win32_PerfFormattedData_PerfProc_Process -Filter \"IDProcess=%d\" | ForEach-Object { 'PercentProcessorTime=' + $_.PercentProcessorTime; 'WorkingSetPrivate=' + $_.WorkingSetPrivate }", pid) cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", psScript) out, err := cmd.Output() if err != nil { return nil } var cpu float64 var mem uint64 lines := strings.Split(string(out), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if strings.HasPrefix(line, "PercentProcessorTime=") { val := strings.TrimPrefix(line, "PercentProcessorTime=") c, _ := strconv.ParseFloat(val, 64) cpu = c } else if strings.HasPrefix(line, "WorkingSetPrivate=") { val := strings.TrimPrefix(line, "WorkingSetPrivate=") m, _ := strconv.ParseUint(val, 10, 64) mem = m } } return &model.ResourceContext{ CPUUsage: cpu, MemoryUsage: mem, } } ================================================ FILE: internal/proc/socketstate_darwin.go ================================================ //go:build darwin package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetSocketStates returns all socket states for a given port func GetSocketStates(port int) ([]model.SocketInfo, error) { var sockets []model.SocketInfo // Use netstat to get all socket states (not just LISTEN) // netstat -an -p tcp shows all TCP connections with states out, err := exec.Command("netstat", "-an", "-p", "tcp").Output() if err != nil { return nil, fmt.Errorf("failed to get socket states: %w", err) } portSuffix := fmt.Sprintf(".%d", port) portColonSuffix := fmt.Sprintf(":%d", port) for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) < 6 { continue } // Check if this line mentions our port localAddr := fields[3] if !strings.HasSuffix(localAddr, portSuffix) && !strings.HasSuffix(localAddr, portColonSuffix) { continue } // Parse the state (field 5) state := fields[5] remoteAddr := fields[4] // Parse local address address, _ := parseNetstatAddr(localAddr) info := model.SocketInfo{ Port: port, State: state, LocalAddr: address, RemoteAddr: remoteAddr, } // Add explanation and workaround based on state addStateExplanation(&info) sockets = append(sockets, info) } return sockets, nil } // GetSocketStateForPort returns the most relevant socket state for a port // Prioritizes non-LISTEN states that explain why a port might be unavailable func GetSocketStateForPort(port int) *model.SocketInfo { states, err := GetSocketStates(port) if err != nil || len(states) == 0 { return nil } // Prioritize problematic states for _, s := range states { if s.State == "TIME_WAIT" || s.State == "CLOSE_WAIT" || s.State == "FIN_WAIT_1" || s.State == "FIN_WAIT_2" { return &s } } // Return LISTEN if that's all we have for _, s := range states { if s.State == "LISTEN" { return &s } } // Return first state found if len(states) > 0 { return &states[0] } return nil } // addStateExplanation adds human-readable explanation for socket states func addStateExplanation(info *model.SocketInfo) { switch info.State { case "LISTEN": info.Explanation = "Actively listening for connections" case "TIME_WAIT": info.Explanation = "Connection closed, waiting for delayed packets (default 60s on macOS)" info.Workaround = "Wait for timeout to expire, or use SO_REUSEADDR in your server" case "CLOSE_WAIT": info.Explanation = "Remote side closed connection, local side has not closed yet" info.Workaround = "The application should call close() on the socket" case "FIN_WAIT_1": info.Explanation = "Local side initiated close, waiting for acknowledgment" case "FIN_WAIT_2": info.Explanation = "Local close acknowledged, waiting for remote close" case "ESTABLISHED": info.Explanation = "Active connection" case "SYN_SENT": info.Explanation = "Connection request sent, waiting for response" case "SYN_RECEIVED": info.Explanation = "Connection request received, sending acknowledgment" case "CLOSING": info.Explanation = "Both sides initiated close simultaneously" case "LAST_ACK": info.Explanation = "Waiting for final acknowledgment of close" default: info.Explanation = "Socket in " + info.State + " state" } } // GetTIMEWAITRemaining estimates remaining TIME_WAIT duration // macOS default MSL is 30 seconds, so TIME_WAIT is 60 seconds func GetTIMEWAITRemaining() string { // We can't easily determine when TIME_WAIT started without additional tracking // Return a general estimate return "up to 60s remaining (macOS default)" } // CountSocketsByState returns a count of sockets by state for a port func CountSocketsByState(port int) map[string]int { counts := make(map[string]int) states, err := GetSocketStates(port) if err != nil { return counts } for _, s := range states { counts[s.State]++ } return counts } // GetMSLDuration returns the Maximum Segment Lifetime setting // This determines TIME_WAIT duration (2 * MSL) func GetMSLDuration() int { // Try to read from sysctl out, err := exec.Command("sysctl", "-n", "net.inet.tcp.msl").Output() if err != nil { return 30000 // Default 30 seconds in milliseconds } msl, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil { return 30000 } return msl } ================================================ FILE: internal/proc/socketstate_freebsd.go ================================================ //go:build freebsd package proc import ( "fmt" "os/exec" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetSocketStates returns all socket states for a given port func GetSocketStates(port int) ([]model.SocketInfo, error) { var sockets []model.SocketInfo // Use netstat to get all socket states (not just LISTEN) // netstat -an -p tcp shows all TCP connections with states out, err := exec.Command("netstat", "-an", "-p", "tcp").Output() if err != nil { return nil, fmt.Errorf("failed to get socket states: %w", err) } portSuffix := fmt.Sprintf(".%d", port) portColonSuffix := fmt.Sprintf(":%d", port) for line := range strings.Lines(string(out)) { fields := strings.Fields(line) if len(fields) < 6 { continue } // Check if this line mentions our port localAddr := fields[3] if !strings.HasSuffix(localAddr, portSuffix) && !strings.HasSuffix(localAddr, portColonSuffix) { continue } // Parse the state (field 5) state := fields[5] remoteAddr := fields[4] // Parse local address proto := fields[0] // tcp4 or tcp6 address, _ := parseSockstatAddr(localAddr, proto) info := model.SocketInfo{ Port: port, State: state, LocalAddr: address, RemoteAddr: remoteAddr, } // Add explanation and workaround based on state addStateExplanation(&info) sockets = append(sockets, info) } return sockets, nil } // GetSocketStateForPort returns the most relevant socket state for a port // Prioritizes non-LISTEN states that explain why a port might be unavailable func GetSocketStateForPort(port int) *model.SocketInfo { states, err := GetSocketStates(port) if err != nil || len(states) == 0 { return nil } // Prioritize problematic states for _, s := range states { if s.State == "TIME_WAIT" || s.State == "CLOSE_WAIT" || s.State == "FIN_WAIT_1" || s.State == "FIN_WAIT_2" { return &s } } // Return LISTEN if that's all we have for _, s := range states { if s.State == "LISTEN" { return &s } } // Return first state found if len(states) > 0 { return &states[0] } return nil } // addStateExplanation adds human-readable explanation for socket states func addStateExplanation(info *model.SocketInfo) { switch info.State { case "LISTEN": info.Explanation = "Actively listening for connections" case "TIME_WAIT": info.Explanation = "Connection closed, waiting for delayed packets (default 60s on FreeBSD)" info.Workaround = "Wait for timeout to expire, or use SO_REUSEADDR in your server" case "CLOSE_WAIT": info.Explanation = "Remote side closed connection, local side has not closed yet" info.Workaround = "The application should call close() on the socket" case "FIN_WAIT_1": info.Explanation = "Local side initiated close, waiting for acknowledgment" case "FIN_WAIT_2": info.Explanation = "Local close acknowledged, waiting for remote close" case "ESTABLISHED": info.Explanation = "Active connection" case "SYN_SENT": info.Explanation = "Connection request sent, waiting for response" case "SYN_RECEIVED": info.Explanation = "Connection request received, sending acknowledgment" case "CLOSING": info.Explanation = "Both sides initiated close simultaneously" case "LAST_ACK": info.Explanation = "Waiting for final acknowledgment of close" default: info.Explanation = "Socket in " + info.State + " state" } } // GetMSLDuration returns the Maximum Segment Lifetime setting // This determines TIME_WAIT duration (2 * MSL) func GetMSLDuration() int { // Try to read from sysctl out, err := exec.Command("sysctl", "-n", "net.inet.tcp.msl").Output() if err != nil { return 30000 // Default 30 seconds in milliseconds } msl, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil { return 30000 } return msl } ================================================ FILE: internal/proc/socketstate_linux.go ================================================ //go:build linux package proc import ( "bufio" "fmt" "os" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // GetSocketStateForPort returns the socket state for a port // Linux implementation using /proc/net/tcp and /proc/net/tcp6 func GetSocketStateForPort(port int) *model.SocketInfo { // Check both IPv4 and IPv6 files := []string{"/proc/net/tcp", "/proc/net/tcp6"} var states []model.SocketInfo for _, file := range files { isIPv6 := strings.HasSuffix(file, "tcp6") func() { f, err := os.Open(file) if err != nil { return } defer f.Close() scanner := bufio.NewScanner(f) scanner.Scan() for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 10 { continue } localAddrHex := fields[1] localIP, localPort := parseAddr(localAddrHex, isIPv6) if localPort != port { continue } remoteAddrHex := fields[2] remoteIP, _ := parseAddr(remoteAddrHex, isIPv6) stateHex := fields[3] stateVal, _ := strconv.ParseInt(stateHex, 16, 0) stateStr := mapTCPState(int(stateVal)) info := model.SocketInfo{ Port: port, State: stateStr, LocalAddr: localIP, RemoteAddr: remoteIP, } addStateExplanation(&info) states = append(states, info) } }() } if len(states) == 0 { return nil } // Prioritize problematic states just like the Darwin implementation for _, s := range states { if isProblematicState(s.State) { return &s } } // Then prioritize LISTEN for _, s := range states { if s.State == "LISTEN" { return &s } } // Default to first found return &states[0] } // mapTCPState maps Linux kernel TCP states (from include/net/tcp_states.h) to strings func mapTCPState(state int) string { switch state { case 1: return "ESTABLISHED" case 2: return "SYN_SENT" case 3: return "SYN_RECV" case 4: return "FIN_WAIT_1" case 5: return "FIN_WAIT_2" case 6: return "TIME_WAIT" case 7: return "CLOSE" case 8: return "CLOSE_WAIT" case 9: return "LAST_ACK" case 10: return "LISTEN" case 11: return "CLOSING" default: return fmt.Sprintf("UNKNOWN (%02X)", state) } } func isProblematicState(state string) bool { switch state { case "TIME_WAIT", "CLOSE_WAIT", "FIN_WAIT_1", "FIN_WAIT_2": return true } return false } func addStateExplanation(info *model.SocketInfo) { switch info.State { case "LISTEN": info.Explanation = "Actively listening for connections" case "TIME_WAIT": info.Explanation = "Connection closed, waiting for delayed packets" info.Workaround = "Wait for timeout (usually 60s) or use SO_REUSEADDR" case "CLOSE_WAIT": info.Explanation = "Remote side closed connection, local side has not closed yet" info.Workaround = "The application should call close() on the socket" case "FIN_WAIT_1": info.Explanation = "Local side initiated close, waiting for acknowledgment" case "FIN_WAIT_2": info.Explanation = "Local close acknowledged, waiting for remote close" case "ESTABLISHED": info.Explanation = "Active connection" case "SYN_SENT": info.Explanation = "Connection request sent, waiting for response" case "SYN_RECEIVED": info.Explanation = "Connection request received, sending acknowledgment" case "CLOSING": info.Explanation = "Both sides initiated close simultaneously" case "LAST_ACK": info.Explanation = "Waiting for final acknowledgment of close" default: info.Explanation = "Socket in " + info.State + " state" } } ================================================ FILE: internal/proc/socketstate_windows.go ================================================ //go:build windows package proc import ( "fmt" "os/exec" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func GetSocketStateForPort(port int) *model.SocketInfo { // netstat -ano out, err := exec.Command("netstat", "-ano").Output() if err != nil { return nil } lines := strings.Split(string(out), "\n") portStr := fmt.Sprintf(":%d", port) var states []model.SocketInfo for _, line := range lines { if strings.Contains(line, portStr) { fields := strings.Fields(line) if len(fields) < 4 { continue } // Proto Local Address Foreign Address State PID // TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 888 localAddr := fields[1] if !strings.HasSuffix(localAddr, portStr) { continue } state := fields[3] remoteAddr := fields[2] info := model.SocketInfo{ Port: port, State: state, LocalAddr: localAddr, RemoteAddr: remoteAddr, } addStateExplanation(&info) states = append(states, info) } } if len(states) == 0 { return nil } // Prioritize problematic states for _, s := range states { if s.State == "TIME_WAIT" || s.State == "CLOSE_WAIT" || s.State == "FIN_WAIT_1" || s.State == "FIN_WAIT_2" { return &s } } // Return LISTEN for _, s := range states { if s.State == "LISTENING" { // Windows uses LISTENING return &s } } return &states[0] } func addStateExplanation(info *model.SocketInfo) { switch info.State { case "LISTENING": info.Explanation = "Actively listening for connections" case "TIME_WAIT": info.Explanation = "Connection closed, waiting for delayed packets" info.Workaround = "Wait for timeout (usually 60-240s) or reuse port" case "CLOSE_WAIT": info.Explanation = "Remote side closed connection, local side still has it open" info.Workaround = "Check if application is leaking connections or hanging" case "ESTABLISHED": info.Explanation = "Active connection established" case "SYN_SENT": info.Explanation = "Attempting to establish connection" info.Workaround = "Check firewall or if remote host is up" case "SYN_RCVD": info.Explanation = "Received connection request, sending ack" } } ================================================ FILE: internal/proc/sort.go ================================================ package proc import ( "sort" "github.com/pranshuparmar/witr/pkg/model" ) func sortProcesses(processes []model.Process) { sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID }) } ================================================ FILE: internal/proc/systemd_linux.go ================================================ //go:build linux package proc import ( "bytes" "fmt" "os/exec" "strconv" "strings" ) func GetSystemdRestartCount(unitName string) (int, error) { if _, err := exec.LookPath("systemctl"); err != nil { return 0, fmt.Errorf("systemctl not found") } cmd := exec.Command("systemctl", "show", "--property=NRestarts", "--value", unitName) var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return 0, err } restartsStr := strings.TrimSpace(out.String()) if restartsStr == "" { return 0, fmt.Errorf("empty output from systemctl") } restarts, err := strconv.Atoi(restartsStr) if err != nil { return 0, fmt.Errorf("failed to parse restart count: %w", err) } return restarts, nil } // ResolveSystemdService attempts to find the systemd service name associated with a port. // It uses `systemctl list-sockets` to find the socket unit and then maps it to the service unit. func ResolveSystemdService(port int) (string, error) { // check if systemctl is available if _, err := exec.LookPath("systemctl"); err != nil { return "", fmt.Errorf("systemctl not found") } cmd := exec.Command("systemctl", "list-sockets", "--no-legend", "--full") var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return "", err } portStr := fmt.Sprintf(":%d", port) lines := strings.Split(out.String(), "\n") for _, line := range lines { fields := strings.Fields(line) if len(fields) < 3 { continue } if strings.HasSuffix(fields[0], portStr) { return fields[2], nil } } return "", fmt.Errorf("no systemd service found for port %d", port) } ================================================ FILE: internal/proc/systemd_stub.go ================================================ //go:build !linux package proc import "fmt" func ResolveSystemdService(port int) (string, error) { return "", fmt.Errorf("systemd is only supported on Linux") } func GetSystemdRestartCount(unitName string) (int, error) { return 0, fmt.Errorf("systemd is only supported on Linux") } ================================================ FILE: internal/proc/user_darwin.go ================================================ //go:build darwin package proc import ( "os/user" "strconv" ) func readUser(pid int) string { // On macOS, we get the UID from ps in ReadProcess and resolve it here // This function is a fallback that just returns unknown return "unknown" } func readUserByUID(uid int) string { return resolveUID(uid) } func resolveUID(uid int) string { if uid == 0 { return "root" } // Try to resolve username using os/user package (works on macOS) u, err := user.LookupId(strconv.Itoa(uid)) if err == nil { return u.Username } // Fallback to UID as string return strconv.Itoa(uid) } ================================================ FILE: internal/proc/user_freebsd.go ================================================ //go:build freebsd package proc import ( "os/user" "strconv" ) func readUser(pid int) string { // On FreeBSD, we get the UID from ps in ReadProcess and resolve it here // This function is a fallback that just returns unknown return "unknown" } func readUserByUID(uid int) string { return resolveUID(uid) } func resolveUID(uid int) string { if uid == 0 { return "root" } // Try to resolve username using os/user package (works on FreeBSD) u, err := user.LookupId(strconv.Itoa(uid)) if err == nil { return u.Username } // Fallback to UID as string return strconv.Itoa(uid) } ================================================ FILE: internal/proc/user_linux.go ================================================ //go:build linux package proc import ( "os" "strconv" "strings" "sync" "syscall" ) var ( userCache map[int]string userCacheOnce sync.Once ) func loadUserCache() map[int]string { cache := make(map[int]string) cache[0] = "root" data, err := os.ReadFile("/etc/passwd") if err != nil { return cache } for line := range strings.Lines(string(data)) { fields := strings.Split(line, ":") if len(fields) > 2 { if uid, err := strconv.Atoi(fields[2]); err == nil { cache[uid] = fields[0] } } } return cache } func readUser(pid int) string { path := "/proc/" + strconv.Itoa(pid) info, err := os.Stat(path) if err != nil { return "unknown" } stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return "unknown" } uid := int(stat.Uid) userCacheOnce.Do(func() { userCache = loadUserCache() }) if name, ok := userCache[uid]; ok { return name } return strconv.Itoa(uid) } ================================================ FILE: internal/proc/user_windows.go ================================================ //go:build windows package proc import ( "fmt" "syscall" ) func readUser(pid int) string { // 1. Open Process // PROCESS_QUERY_LIMITED_INFORMATION (0x1000) is enough for Token and allows better access coverage // than PROCESS_QUERY_INFORMATION (0x0400). const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 hProcess, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { // Fallback to PROCESS_QUERY_INFORMATION if LIMITED not available or failed? // Usually 0x400 is stricter. // Try standard if fails? hProcess, err = syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) if err != nil { return "unknown" } } defer syscall.CloseHandle(hProcess) // 2. Open Process Token var token syscall.Token err = syscall.OpenProcessToken(hProcess, syscall.TOKEN_QUERY, &token) if err != nil { return "unknown" } defer token.Close() // 3. Get Token User (SID) tokenUser, err := token.GetTokenUser() if err != nil { return "unknown" } // 4. Lookup Account SID // tokenUser.User.Sid is *syscall.SID user, domain, _, err := tokenUser.User.Sid.LookupAccount("") if err != nil { return "unknown" } if domain == "" { return user } return fmt.Sprintf("%s\\%s", domain, user) } // Helpers if needed, but syscall.Token has GetTokenUser and lookup methods in standard library (windows) ================================================ FILE: internal/source/bsdrc_darwin.go ================================================ //go:build darwin package source import "github.com/pranshuparmar/witr/pkg/model" func detectBsdRc(_ []model.Process) *model.Source { // macOS doesn't use FreeBSD rc.d return nil } ================================================ FILE: internal/source/bsdrc_freebsd.go ================================================ //go:build freebsd package source import ( "os" "path/filepath" "strings" "sync" "github.com/pranshuparmar/witr/pkg/model" ) var ( shellCache map[string]bool shellCacheOnce sync.Once ) // loadShellsFromEtc reads /etc/shells and returns a map of valid shells func loadShellsFromEtc() map[string]bool { shells := make(map[string]bool) // Fallback list in case /etc/shells is not readable fallback := []string{"sh", "bash", "zsh", "csh", "tcsh", "ksh", "fish", "dash"} for _, s := range fallback { shells[s] = true } data, err := os.ReadFile("/etc/shells") if err != nil { return shells } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } shellName := filepath.Base(line) shells[shellName] = true } return shells } func getShells() map[string]bool { shellCacheOnce.Do(func() { shellCache = loadShellsFromEtc() }) return shellCache } func detectBsdRc(ancestry []model.Process) *model.Source { // Priority 1: Check for explicit service detection via /var/run/*.pid for _, p := range ancestry { if p.Service != "" { src := &model.Source{ Type: model.SourceBsdRc, Name: p.Service, Details: map[string]string{ "service": p.Service, }, } if path := resolveRcScript(p.Service); path != "" { src.UnitFile = path src.Description = readRcDescription(path) } return src } } // Priority 2: Check if target process is a direct child of init // without any shell in the ancestry (likely an rc.d service) if len(ancestry) >= 2 { target := ancestry[len(ancestry)-1] shells := getShells() hasShell := false for i := 0; i < len(ancestry)-1; i++ { if shells[filepath.Base(ancestry[i].Command)] { hasShell = true break } } if target.PPID == 1 && !hasShell { // Try to guess service name from command if not explicitly set name := target.Command path := resolveRcScript(name) src := &model.Source{ Type: model.SourceBsdRc, Name: name, } if path != "" { src.UnitFile = path src.Description = readRcDescription(path) } return src } } return nil } func readRcDescription(path string) string { if path == "" { return "" } f, err := os.Open(path) if err != nil { return "" } defer f.Close() // Simple heuristic: look for "# description:" or similar buf := make([]byte, 2048) n, err := f.Read(buf) if err != nil && n == 0 { return "" } content := string(buf[:n]) lines := strings.Split(content, "\n") for _, line := range lines { line = strings.TrimSpace(line) lower := strings.ToLower(line) if strings.HasPrefix(lower, "# description:") { return strings.TrimSpace(line[14:]) // len("# description:") is 14 } if strings.HasPrefix(lower, "# desc:") { return strings.TrimSpace(line[7:]) } } return "" } func resolveRcScript(serviceName string) string { paths := []string{ "/etc/rc.d/" + serviceName, "/usr/local/etc/rc.d/" + serviceName, } for _, path := range paths { if _, err := os.Stat(path); err == nil { return path } } return "" } ================================================ FILE: internal/source/bsdrc_linux.go ================================================ //go:build linux package source import "github.com/pranshuparmar/witr/pkg/model" func detectBsdRc(_ []model.Process) *model.Source { // Linux doesn't use FreeBSD rc.d return nil } ================================================ FILE: internal/source/bsdrc_windows.go ================================================ //go:build windows package source import "github.com/pranshuparmar/witr/pkg/model" func detectBsdRc(_ []model.Process) *model.Source { // windows doesn't use FreeBSD rc.d return nil } ================================================ FILE: internal/source/container.go ================================================ package source import ( "os" "strconv" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func detectContainer(ancestry []model.Process) *model.Source { for _, p := range ancestry { data, err := os.ReadFile("/proc/" + itoa(p.PID) + "/cgroup") if err != nil { continue } content := string(data) switch { case strings.Contains(content, "docker"): return &model.Source{ Type: model.SourceContainer, Name: "docker", } case strings.Contains(content, "podman"), strings.Contains(content, "libpod"): return &model.Source{ Type: model.SourceContainer, Name: "podman", } case strings.Contains(content, "kubepods"): return &model.Source{ Type: model.SourceContainer, Name: "kubernetes", } case strings.Contains(content, "colima"): return &model.Source{ Type: model.SourceContainer, Name: "colima", } case strings.Contains(content, "containerd"): return &model.Source{ Type: model.SourceContainer, Name: "containerd", } } } // Snap/Flatpak sandbox detection via environment variables if len(ancestry) > 0 { target := ancestry[len(ancestry)-1] for _, e := range target.Env { if strings.HasPrefix(e, "SNAP_NAME=") { return &model.Source{ Type: model.SourceContainer, Name: "snap", } } if strings.HasPrefix(e, "FLATPAK_ID=") { return &model.Source{ Type: model.SourceContainer, Name: "flatpak", } } } } return nil } func itoa(n int) string { return strconv.Itoa(n) } ================================================ FILE: internal/source/cron.go ================================================ package source import ( "path/filepath" "github.com/pranshuparmar/witr/pkg/model" ) func detectCron(ancestry []model.Process) *model.Source { for _, p := range ancestry { base := filepath.Base(p.Command) if base == "cron" || base == "crond" { return &model.Source{ Type: model.SourceCron, Name: "cron", } } } return nil } ================================================ FILE: internal/source/detect.go ================================================ package source import ( "sort" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) var suspiciousDirs = map[string]bool{"/": true, "/tmp": true, "/var/tmp": true} var dangerousCapabilities = map[string]bool{ "CAP_SYS_ADMIN": true, "CAP_SYS_PTRACE": true, "CAP_NET_RAW": true, "CAP_DAC_OVERRIDE": true, "CAP_DAC_READ_SEARCH": true, "CAP_FOWNER": true, "CAP_SYS_MODULE": true, "CAP_SYS_RAWIO": true, } func isDangerousCapability(cap string) bool { return dangerousCapabilities[cap] } type envSuspiciousRule struct { pattern string match func(key, pattern string) bool warning string includeKeys bool } var ( envVarRules = []envSuspiciousRule{ { pattern: "LD_PRELOAD", match: func(key, pattern string) bool { return key == pattern }, warning: "Process sets LD_PRELOAD (potential library injection)", }, { pattern: "DYLD_", match: strings.HasPrefix, warning: "Process sets DYLD_* variables (potential library injection)", includeKeys: true, }, } ) func Detect(ancestry []model.Process) model.Source { // Detection order prioritizes platform-specific init systems // over generic supervisor detection to avoid false positives if src := detectContainer(ancestry); src != nil { return *src } if src := detectSSH(ancestry); src != nil { return *src } if src := detectShell(ancestry); src != nil { return *src } if src := detectSystemd(ancestry); src != nil { return *src } if src := detectLaunchd(ancestry); src != nil { return *src } if src := detectBsdRc(ancestry); src != nil { return *src } if src := detectSupervisor(ancestry); src != nil { return *src } if src := detectCron(ancestry); src != nil { return *src } if src := detectWindowsService(ancestry); src != nil { return *src } if src := detectInit(ancestry); src != nil { return *src } return model.Source{ Type: model.SourceUnknown, } } // env suspicious warnings returns warnings for known env based library injection patterns func envSuspiciousWarnings(env []string) []string { matched := make([]bool, len(envVarRules)) matchedKeys := make([]map[string]struct{}, len(envVarRules)) // init per rule key capture only for rules that include keys for i, rule := range envVarRules { if rule.includeKeys { matchedKeys[i] = map[string]struct{}{} } } // scan env entries and record which rules match for _, entry := range env { key, value, ok := strings.Cut(entry, "=") if !ok || value == "" { continue } // check this key against each configured rule for i, rule := range envVarRules { if !rule.match(key, rule.pattern) { continue } matched[i] = true if rule.includeKeys { matchedKeys[i][key] = struct{}{} } } } var warnings []string // emit warnings in the same order as envVarRules for i, rule := range envVarRules { if !matched[i] { continue } if !rule.includeKeys { warnings = append(warnings, rule.warning) continue } keys := make([]string, 0, len(matchedKeys[i])) // collect all matched keys for this rule for key := range matchedKeys[i] { keys = append(keys, key) } sort.Strings(keys) warnings = append(warnings, rule.warning+": "+strings.Join(keys, ", ")) } return warnings } func Warnings(p []model.Process, srcType ...model.SourceType) []string { if len(p) == 0 { return nil } var w []string last := p[len(p)-1] // Restart count detection (count consecutive same-command entries) restartCount := 0 lastCmd := "" for _, proc := range p { if proc.Command == lastCmd { restartCount++ } lastCmd = proc.Command } if restartCount > 5 { w = append(w, "Process or ancestor restarted more than 5 times") } // Health warnings switch last.Health { case "zombie": w = append(w, "Process is a zombie (defunct)") case "stopped": w = append(w, "Process is stopped (T state)") case "high-cpu": w = append(w, "Process is using high CPU (>2h total)") case "high-mem": w = append(w, "Process is using high memory (>1GB RSS)") } if IsPublicBind(last.BindAddresses) { w = append(w, "Process is listening on a public interface") } if last.User == "root" { w = append(w, "Process is running as root") } else if len(last.Capabilities) > 0 { var dangerous []string for _, cap := range last.Capabilities { if isDangerousCapability(cap) { dangerous = append(dangerous, cap) } } if len(dangerous) > 0 { w = append(w, "Process has dangerous capabilities: "+strings.Join(dangerous, ", ")) } } st := model.SourceUnknown if len(srcType) > 0 { st = srcType[0] } else { st = Detect(p).Type } if st == model.SourceUnknown { w = append(w, "No known supervisor or service manager detected") } // Warn if process is very old (>90 days) if time.Since(last.StartedAt).Hours() > 90*24 { w = append(w, "Process has been running for over 90 days") } if suspiciousDirs[last.WorkingDir] { w = append(w, "Process is running from a suspicious working directory: "+last.WorkingDir) } // Warn if container and no healthcheck (skip for snap/flatpak which don't use healthchecks) if last.Container != "" && !strings.HasPrefix(last.Container, "snap:") && !strings.HasPrefix(last.Container, "flatpak:") { w = append(w, "No healthcheck detected for container (best effort)") } // Warn if service name and process name are genuinely unrelated if last.Service != "" && last.Command != "" { svcCore := last.Service for _, suffix := range []string{".service", ".socket", ".timer", ".scope", ".slice", ".plist"} { svcCore = strings.TrimSuffix(svcCore, suffix) } svcCore = strings.ToLower(svcCore) cmdBase := strings.ToLower(last.Command) if !strings.Contains(svcCore, cmdBase) && !strings.Contains(cmdBase, svcCore) { w = append(w, "Service name and process name do not match") } } // Warn if binary is deleted if last.ExeDeleted { w = append(w, "Process is running from a deleted binary (potential library injection or pending update)") } // Include warnings based on suspicious env variables w = append(w, envSuspiciousWarnings(last.Env)...) return w } // EnrichSocketInfo provides human-readable explanations and workarounds for socket states func EnrichSocketInfo(si *model.SocketInfo) { if si == nil { return } switch si.State { case "TIME_WAIT": si.Explanation = "The local OS is holding the port in a protocol-wait state to ensure all packets are received." si.Workaround = "Wait ~60s for the OS to release it, or enable SO_REUSEADDR in your code." case "CLOSE_WAIT": si.Explanation = "The remote end has closed the connection, but the local application hasn't responded." si.Workaround = "This usually indicates a resource leak in the application. Restart the process." case "FIN_WAIT_1", "FIN_WAIT_2": si.Explanation = "The connection is in the process of being closed." case "ESTABLISHED": si.Explanation = "The connection is active and data can be transferred." case "LISTEN": si.Explanation = "The process is actively waiting for incoming connections." } } ================================================ FILE: internal/source/detect_test.go ================================================ package source import ( "slices" "strings" "testing" "time" "github.com/pranshuparmar/witr/pkg/model" ) func TestWarningsDetectsLDPreload(t *testing.T) { p := []model.Process{ {PID: 999999, Command: "pm2", Cmdline: "pm2"}, { PID: 123, Command: "bash", StartedAt: time.Now(), User: "bob", WorkingDir: "/home/bob", Env: []string{"LD_PRELOAD=/tmp/libhack.so"}, }, } warnings := Warnings(p) if !slices.Contains(warnings, "Process sets LD_PRELOAD (potential library injection)") { t.Fatalf("expected LD_PRELOAD warning, got: %v", warnings) } } func TestWarningsDetectsDYLDVars(t *testing.T) { p := []model.Process{ {PID: 999999, Command: "pm2", Cmdline: "pm2"}, { PID: 123, Command: "zsh", StartedAt: time.Now(), User: "bob", WorkingDir: "/home/bob", Env: []string{ "DYLD_LIBRARY_PATH=/tmp", "DYLD_INSERT_LIBRARIES=/tmp/inject.dylib", }, }, } warnings := Warnings(p) want := "Process sets DYLD_* variables (potential library injection): DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH" if !slices.Contains(warnings, want) { t.Fatalf("expected DYLD warning %q, got: %v", want, warnings) } } func TestWarningsIgnoresEmptyPreloadVars(t *testing.T) { p := []model.Process{ {PID: 999999, Command: "pm2", Cmdline: "pm2"}, { PID: 123, Command: "zsh", StartedAt: time.Now(), User: "bob", WorkingDir: "/home/bob", Env: []string{ "LD_PRELOAD=", "DYLD_INSERT_LIBRARIES=", }, }, } warnings := Warnings(p) if slices.Contains(warnings, "Process sets LD_PRELOAD (potential library injection)") { t.Fatalf("did not expect LD_PRELOAD warning, got: %v", warnings) } if slices.Contains(warnings, "Process sets DYLD_* variables (potential library injection): DYLD_INSERT_LIBRARIES") { t.Fatalf("did not expect DYLD warning, got: %v", warnings) } } // checks if the order of env vars warnings are deterministic func FuzzEnvSuspiciousWarningsDeterministic(f *testing.F) { f.Add("LD_PRELOAD=/tmp/lib.so") f.Add("DYLD_LIBRARY_PATH=/tmp\nDYLD_INSERT_LIBRARIES=/tmp/inject.dylib") f.Add("DYLD_LIBRARY_PATH=\nLD_PRELOAD=") f.Add("") f.Fuzz(func(t *testing.T, input string) { parts := strings.Split(input, "\n") if len(parts) > 50 { parts = parts[:50] } for i := range parts { if len(parts[i]) > 200 { parts[i] = parts[i][:200] } } w1 := envSuspiciousWarnings(parts) w2 := envSuspiciousWarnings(parts) if !slices.Equal(w1, w2) { t.Fatalf("expected deterministic output, got %v vs %v", w1, w2) } }) } func TestEnvSuspiciousWarnings(t *testing.T) { tests := []struct { name string env []string want []string }{ { name: "LD_PRELOAD", env: []string{"LD_PRELOAD=/tmp/libhack.so"}, want: []string{"Process sets LD_PRELOAD (potential library injection)"}, }, { name: "DYLD keys sorted and deduped", env: []string{ "DYLD_LIBRARY_PATH=/tmp", "DYLD_INSERT_LIBRARIES=/tmp/inject.dylib", "DYLD_LIBRARY_PATH=/tmp", // dup }, want: []string{ "Process sets DYLD_* variables (potential library injection): DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH", }, }, { name: "ignores empty values (current behavior)", env: []string{"LD_PRELOAD=", "DYLD_INSERT_LIBRARIES="}, want: nil, }, { name: "value with '=' still counts", env: []string{"LD_PRELOAD=a=b"}, want: []string{"Process sets LD_PRELOAD (potential library injection)"}, }, { name: "no '=' ignored", env: []string{"LD_PRELOAD"}, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := envSuspiciousWarnings(tt.env) if !slices.Equal(got, tt.want) { t.Fatalf("got %v, want %v", got, tt.want) } }) } } // attempts to cause a panic when checking for env vars func FuzzWarningsNoPanic(f *testing.F) { f.Add("LD_PRELOAD=/tmp/lib.so") f.Add("DYLD_INSERT_LIBRARIES=/tmp/inject.dylib") f.Add("NOT_AN_ENV") f.Fuzz(func(t *testing.T, entry string) { if len(entry) > 2000 { entry = entry[:2000] } p := []model.Process{ { PID: 123, Command: "test", Cmdline: "test", StartedAt: time.Now(), User: "bob", WorkingDir: "/home/bob", Env: []string{entry}, }, } _ = Warnings(p) }) } func TestWarningsDetectsDeletedExecutable(t *testing.T) { p := []model.Process{ { PID: 123, Command: "nginx", StartedAt: time.Now(), ExeDeleted: true, }, } warnings := Warnings(p) want := "Process is running from a deleted binary (potential library injection or pending update)" if !slices.Contains(warnings, want) { t.Fatalf("expected deleted binary warning, got: %v", warnings) } } func TestEnrichSocketInfo(t *testing.T) { tests := []struct { state string wantExplanation string }{ {"TIME_WAIT", "The local OS is holding the port in a protocol-wait state to ensure all packets are received."}, {"CLOSE_WAIT", "The remote end has closed the connection, but the local application hasn't responded."}, {"LISTEN", "The process is actively waiting for incoming connections."}, } for _, tt := range tests { si := &model.SocketInfo{State: tt.state} EnrichSocketInfo(si) if si.Explanation != tt.wantExplanation { t.Errorf("state %s: got explanation %q, want %q", tt.state, si.Explanation, tt.wantExplanation) } } } ================================================ FILE: internal/source/init.go ================================================ package source import ( "path/filepath" "strings" "github.com/pranshuparmar/witr/pkg/model" ) // detectInit checks if the process is a direct descendant of the init process (PID 1) // effectively acting as a catch-all for SysVinit, OpenRC, or other init systems. func detectInit(ancestry []model.Process) *model.Source { if len(ancestry) == 0 { return nil } root := ancestry[0] if root.PID != 1 { return nil } // Check if there's any shell in the chain between root and target. // If there is a shell, it's likely a manual command run by a user or script, not a pure service. hasShell := false for i := 1; i < len(ancestry)-1; i++ { name := strings.ToLower(filepath.Base(ancestry[i].Command)) if isShell(name) { hasShell = true break } } if !hasShell { // Use the actual PID 1 command name (e.g., "openrc-init", "runit-init", // "init", "systemd") instead of always reporting "init". initName := root.Command if initName == "" { initName = "init" } return &model.Source{ Type: model.SourceInit, Name: initName, Details: map[string]string{ "pid": "1", "comm": root.Command, }, } } return nil } func isShell(name string) bool { switch name { case "sh", "bash", "zsh", "dash", "ash", "csh", "tcsh", "fish", "powershell.exe", "pwsh.exe", "cmd.exe": return true } return false } ================================================ FILE: internal/source/launchd_darwin.go ================================================ //go:build darwin package source import ( "strings" "github.com/pranshuparmar/witr/internal/launchd" "github.com/pranshuparmar/witr/pkg/model" ) func detectLaunchd(ancestry []model.Process) *model.Source { // Check if the ancestry includes launchd (PID 1) hasLaunchd := false for _, p := range ancestry { if p.PID == 1 && p.Command == "launchd" { hasLaunchd = true break } } if !hasLaunchd { return nil } // Get the target process (last in ancestry) if len(ancestry) == 0 { return nil } target := ancestry[len(ancestry)-1] // Try to get detailed launchd info for the target process info, err := launchd.GetLaunchdInfo(target.PID) if err != nil { // Fall back to basic launchd detection return &model.Source{ Type: model.SourceLaunchd, Name: "launchd", } } // Build the source with details source := &model.Source{ Type: model.SourceLaunchd, Name: info.Label, Description: info.Comment, Details: make(map[string]string), } // Add domain description (Launch Agent vs Launch Daemon) source.Details["type"] = info.DomainDescription() // Add plist path if found if info.PlistPath != "" { source.UnitFile = info.PlistPath source.Details["plist"] = info.PlistPath } // Separate schedule triggers from non-schedule triggers var scheduleParts, triggerParts []string for _, t := range info.FormatTriggers() { if strings.HasPrefix(t, "StartInterval") || strings.HasPrefix(t, "StartCalendarInterval") { scheduleParts = append(scheduleParts, t) } else { triggerParts = append(triggerParts, t) } } if len(scheduleParts) > 0 { source.Details["schedule"] = strings.Join(scheduleParts, "; ") } if len(triggerParts) > 0 { source.Details["triggers"] = strings.Join(triggerParts, "; ") } // Add KeepAlive status if info.KeepAlive { source.Details["keepalive"] = "Yes (restarts if killed)" } return source } ================================================ FILE: internal/source/launchd_freebsd.go ================================================ //go:build freebsd package source import "github.com/pranshuparmar/witr/pkg/model" func detectLaunchd(_ []model.Process) *model.Source { // FreeBSD doesn't use launchd return nil } ================================================ FILE: internal/source/launchd_linux.go ================================================ //go:build linux package source import "github.com/pranshuparmar/witr/pkg/model" func detectLaunchd(_ []model.Process) *model.Source { return nil } ================================================ FILE: internal/source/launchd_windows.go ================================================ //go:build windows package source import "github.com/pranshuparmar/witr/pkg/model" func detectLaunchd(ancestry []model.Process) *model.Source { return nil } ================================================ FILE: internal/source/network.go ================================================ package source func IsPublicBind(addrs []string) bool { for _, a := range addrs { if a == "0.0.0.0" || a == "::" { return true } } return false } ================================================ FILE: internal/source/service_other.go ================================================ //go:build !windows package source import "github.com/pranshuparmar/witr/pkg/model" func detectWindowsService(ancestry []model.Process) *model.Source { return nil } ================================================ FILE: internal/source/service_windows.go ================================================ //go:build windows package source import ( "os/exec" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func detectWindowsService(ancestry []model.Process) *model.Source { // 1. Check for explicit service name in process metadata (prioritize target) for i := len(ancestry) - 1; i >= 0; i-- { p := ancestry[i] if p.Service != "" { registryKey := `HKLM\SYSTEM\CurrentControlSet\Services\` + p.Service description := resolveWindowsServiceDescription(p.Service) return &model.Source{ Type: model.SourceWindowsService, Name: p.Service, Description: description, UnitFile: registryKey, Details: map[string]string{ "manager": "services.exe", "service": p.Service, }, } } } // 2. Fallback: Check if services.exe is in ancestry without explicit service name for _, p := range ancestry { if strings.ToLower(p.Command) == "services.exe" { return &model.Source{ Type: model.SourceWindowsService, Name: "Service Control Manager", Details: map[string]string{ "manager": "services.exe", }, } } } // 3. Check for children of services.exe where valid service name wasn't found if len(ancestry) >= 2 { parent := ancestry[len(ancestry)-2] target := ancestry[len(ancestry)-1] if strings.ToLower(parent.Command) == "services.exe" { name := strings.TrimSuffix(target.Command, ".exe") registryKey := `HKLM\SYSTEM\CurrentControlSet\Services\` + name description := resolveWindowsServiceDescription(name) return &model.Source{ Type: model.SourceWindowsService, Name: name, Description: description, UnitFile: registryKey, Details: map[string]string{ "manager": "services.exe", }, } } } return nil } func resolveWindowsServiceDescription(serviceName string) string { if _, err := exec.LookPath("sc"); err != nil { return "" } cmd := exec.Command("sc", "GetDisplayName", serviceName) out, _ := cmd.Output() output := string(out) if idx := strings.Index(output, "Name = "); idx != -1 { return strings.TrimSpace(output[idx+7:]) } return "" } ================================================ FILE: internal/source/shell.go ================================================ package source import ( "fmt" "path/filepath" "strings" "github.com/pranshuparmar/witr/pkg/model" ) var shells = map[string]bool{ "bash": true, "zsh": true, "sh": true, "fish": true, "csh": true, "tcsh": true, "ksh": true, "dash": true, "cmd.exe": true, "powershell.exe": true, "pwsh.exe": true, "explorer.exe": true, } var userTools = map[string]bool{ // Runtimes "python": true, "python3": true, "node": true, "ruby": true, "perl": true, "php": true, "go": true, "java": true, "cargo": true, "npm": true, "yarn": true, "make": true, // Editors / IDEs "code": true, "cursor": true, "vim": true, "nvim": true, "emacs": true, "nano": true, // Terminals "gnome-terminal-": true, "kitty": true, "alacritty": true, "wezterm": true, "konsole": true, } func detectShell(ancestry []model.Process) *model.Source { // Scan from the end (target) backwards to find the closest shell OR user tool // This ensures we get the direct parent rather than an ancestor for i := len(ancestry) - 2; i >= 0; i-- { cmd := ancestry[i].Command base := filepath.Base(cmd) if shells[base] { src := &model.Source{ Type: model.SourceShell, Name: base, } enrichMultiplexer(src, ancestry) return src } // Normalize for Windows by stripping common executable extensions for the map lookup lookupName := base lowerBase := strings.ToLower(base) for _, ext := range []string{".exe", ".cmd", ".bat", ".com"} { if strings.HasSuffix(lowerBase, ext) { lookupName = strings.TrimSuffix(lowerBase, ext) break } } if userTools[lookupName] { src := &model.Source{ Type: model.SourceShell, Name: base, } enrichMultiplexer(src, ancestry) return src } // Prefix matches for interpreters with versions or paths if strings.HasPrefix(base, "python") || strings.HasPrefix(base, "node") { src := &model.Source{ Type: model.SourceShell, Name: base, } enrichMultiplexer(src, ancestry) return src } } return nil } // enrichMultiplexer checks if tmux or screen is in the ancestry and adds // session details to the source description. func enrichMultiplexer(src *model.Source, ancestry []model.Process) { for i := 0; i < len(ancestry)-1; i++ { base := filepath.Base(ancestry[i].Command) if base == "tmux" || strings.HasPrefix(base, "tmux:") { session := findEnvVar(ancestry, "TMUX") desc := "tmux session" if session != "" { // TMUX env var format: /tmp/tmux-1000/default,12345,0 // The session name is between the last "/" and the first "," if parts := strings.Split(session, ","); len(parts) >= 1 { path := parts[0] if idx := strings.LastIndex(path, "/"); idx >= 0 { desc = fmt.Sprintf("tmux session '%s'", path[idx+1:]) } } } src.Description = desc return } if base == "screen" || strings.HasPrefix(base, "SCREEN") { session := findEnvVar(ancestry, "STY") desc := "screen session" if session != "" { desc = fmt.Sprintf("screen session '%s'", session) } src.Description = desc return } } } // findEnvVar searches the ancestry chain (target first) for an environment variable. func findEnvVar(ancestry []model.Process, key string) string { for i := len(ancestry) - 1; i >= 0; i-- { for _, entry := range ancestry[i].Env { k, v, ok := strings.Cut(entry, "=") if ok && k == key { return v } } } return "" } ================================================ FILE: internal/source/ssh.go ================================================ package source import ( "fmt" "path/filepath" "strings" "github.com/pranshuparmar/witr/pkg/model" ) func detectSSH(ancestry []model.Process) *model.Source { if len(ancestry) < 2 { return nil } // Look for sshd in the ancestry chain (excluding the target itself) hasSSHD := false for i := 0; i < len(ancestry)-1; i++ { base := filepath.Base(ancestry[i].Command) if base == "sshd" || base == "sshd.exe" || strings.HasPrefix(base, "sshd:") { hasSSHD = true break } } if !hasSSHD { return nil } // Extract SSH connection details from environment variables. // Check all processes in the chain (target first, then ancestors) because // su/sudo create clean login shells that don't inherit SSH_* vars. target := ancestry[len(ancestry)-1] var remoteIP, tty string for i := len(ancestry) - 1; i >= 0 && remoteIP == ""; i-- { for _, entry := range ancestry[i].Env { key, val, ok := strings.Cut(entry, "=") if !ok { continue } switch key { case "SSH_CLIENT": if fields := strings.Fields(val); len(fields) >= 1 { remoteIP = fields[0] } case "SSH_CONNECTION": if remoteIP == "" { if fields := strings.Fields(val); len(fields) >= 1 { remoteIP = fields[0] } } case "SSH_TTY": if tty == "" { tty = val } } } } desc := "SSH session" if remoteIP != "" { if tty != "" { desc = fmt.Sprintf("SSH session from %s (%s@%s)", remoteIP, target.User, strings.TrimPrefix(tty, "/dev/")) } else if target.User != "" { desc = fmt.Sprintf("SSH session from %s (%s)", remoteIP, target.User) } else { desc = fmt.Sprintf("SSH session from %s", remoteIP) } } return &model.Source{ Type: model.SourceSSH, Name: "sshd", Description: desc, } } ================================================ FILE: internal/source/supervisor.go ================================================ package source import ( "path/filepath" "strings" "github.com/pranshuparmar/witr/pkg/model" ) var knownSupervisors = map[string]string{ "pm2": "pm2", "supervisord": "supervisord", "supervisor": "supervisord", "gunicorn": "gunicorn", "uwsgi": "uwsgi", "s6-supervise": "s6", "s6": "s6", "s6-svscan": "s6", "runsv": "runit", "runit": "runit", "runit-init": "runit", "openrc": "openrc", "openrc-init": "openrc", "monit": "monit", "circusd": "circus", "circus": "circus", "systemd": "systemd service", "systemctl": "systemd service", "daemontools": "daemontools", "initctl": "upstart", "tini": "tini", "docker-init": "docker-init", "podman-init": "podman-init", "smf": "smf", "launchd": "launchd", "god": "god", "forever": "forever", "nssm": "nssm", } func detectSupervisor(ancestry []model.Process) *model.Source { // Check if there's a shell in the ancestry hasShell := false for _, p := range ancestry { if shells[filepath.Base(p.Command)] { hasShell = true break } } for _, p := range ancestry { base := filepath.Base(p.Command) if base == "init" { if !hasShell { return &model.Source{ Type: model.SourceSupervisor, Name: "init", } } } if label, ok := knownSupervisors[strings.ToLower(base)]; ok { if label == "init" && hasShell { continue } return &model.Source{ Type: model.SourceSupervisor, Name: label, } } // Match individual tokens from the cmdline against supervisor keys if label := matchCmdlineTokens(p.Cmdline, hasShell); label != "" { return &model.Source{ Type: model.SourceSupervisor, Name: label, } } } return nil } // matchCmdlineTokens extracts the executable basename and each argument token // from a command line, then looks up each against knownSupervisors by exact match. func matchCmdlineTokens(cmdline string, hasShell bool) string { for _, token := range strings.Fields(strings.ToLower(cmdline)) { // Skip flags and env assignments if strings.HasPrefix(token, "-") || strings.Contains(token, "=") { continue } base := filepath.Base(token) if label, ok := knownSupervisors[base]; ok { if label == "init" && hasShell { continue } return label } } return "" } ================================================ FILE: internal/source/systemd_darwin.go ================================================ //go:build darwin package source import "github.com/pranshuparmar/witr/pkg/model" func detectSystemd(_ []model.Process) *model.Source { return nil } // IsSystemdRunning always returns false on macOS. func IsSystemdRunning() bool { return false } ================================================ FILE: internal/source/systemd_freebsd.go ================================================ //go:build freebsd package source import "github.com/pranshuparmar/witr/pkg/model" func detectSystemd(_ []model.Process) *model.Source { // FreeBSD doesn't use systemd return nil } // IsSystemdRunning always returns false on FreeBSD. func IsSystemdRunning() bool { return false } ================================================ FILE: internal/source/systemd_linux.go ================================================ //go:build linux package source import ( "fmt" "os" "os/exec" "strings" "time" "github.com/pranshuparmar/witr/pkg/model" ) // IsSystemdRunning checks whether systemd is actually the running init system. // This is the canonical check used by sd_booted() in libsystemd. func IsSystemdRunning() bool { _, err := os.Stat("/run/systemd/system") return err == nil } func detectSystemd(ancestry []model.Process) *model.Source { // Verify systemd is actually the init system, not just that PID 1 // happens to be named "init" (which could be SysVinit, OpenRC, runit, etc.) if !IsSystemdRunning() { return nil } hasPID1 := false for _, p := range ancestry { if p.PID == 1 { hasPID1 = true break } } if !hasPID1 { return nil } targetProc := ancestry[len(ancestry)-1] props := resolveSystemdProperties(targetProc.PID) // Keep only supplemental details (top-level fields already hold name/desc/unitfile) details := map[string]string{} if v := props["NRestarts"]; v != "" { details["NRestarts"] = v } src := &model.Source{ Type: model.SourceSystemd, Name: props["UnitName"], Description: props["Description"], UnitFile: props["UnitFile"], Details: details, } // Check if the service is triggered by a systemd timer if unitName := props["UnitName"]; strings.HasSuffix(unitName, ".service") { timerUnit := strings.TrimSuffix(unitName, ".service") + ".timer" if schedule := resolveTimerSchedule(timerUnit); schedule != "" { src.Details["schedule"] = schedule } } return src } // resolveSystemdProperties fetches Description, FragmentPath/SourcePath, and NRestarts // in a single systemctl call to avoid spawning multiple processes. func resolveSystemdProperties(pid int) map[string]string { result := map[string]string{} if _, err := exec.LookPath("systemctl"); err != nil { return result } unitName := getUnitNameFromCgroup(pid) if unitName != "" { result["UnitName"] = unitName } // Try cgroup-resolved unit name first, fall back to PID-based lookup targets := []string{} if unitName != "" { targets = append(targets, unitName) } targets = append(targets, fmt.Sprintf("%d", pid)) props := []string{"Description", "FragmentPath", "SourcePath", "NRestarts"} for _, target := range targets { values := querySystemdProperties(props, target) if result["Description"] == "" && values["Description"] != "" { result["Description"] = values["Description"] } if result["UnitFile"] == "" { if values["FragmentPath"] != "" { result["UnitFile"] = values["FragmentPath"] } else if values["SourcePath"] != "" { result["UnitFile"] = values["SourcePath"] } } if result["NRestarts"] == "" && values["NRestarts"] != "" { result["NRestarts"] = values["NRestarts"] } // Stop once we have all the info we need if result["Description"] != "" && result["UnitFile"] != "" && result["NRestarts"] != "" { break } } return result } // querySystemdProperties fetches multiple properties in a single systemctl invocation. func querySystemdProperties(props []string, target string) map[string]string { args := []string{"show"} for _, p := range props { args = append(args, "-p", p) } args = append(args, "--", target) cmd := exec.Command("systemctl", args...) out, err := cmd.Output() if err != nil { return nil } result := make(map[string]string, len(props)) for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { k, v, ok := strings.Cut(line, "=") if !ok { continue } v = strings.TrimSpace(v) if v == "" || strings.Contains(v, "not set") { continue } result[k] = v } return result } // resolveTimerSchedule checks if a .timer unit exists and extracts schedule info. func resolveTimerSchedule(timerUnit string) string { props := querySystemdProperties( []string{"TimersCalendar", "TimersMonotonic", "LastTriggerUSec", "NextElapseUSecRealtime"}, timerUnit, ) if props == nil { return "" } // Extract schedule spec from calendar or monotonic timer schedule := extractTimerSpec(props["TimersCalendar"]) if schedule == "" { schedule = extractTimerSpec(props["TimersMonotonic"]) } if schedule == "" { return "" } var parts []string parts = append(parts, schedule) if last := props["LastTriggerUSec"]; last != "" && last != "n/a" { if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", last); err == nil { parts = append(parts, "last: "+formatRelativeTime(t)) } } if next := props["NextElapseUSecRealtime"]; next != "" && next != "n/a" { if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", next); err == nil { parts = append(parts, "next: "+formatRelativeTime(t)) } } return strings.Join(parts, ", ") } // extractTimerSpec parses the value from systemd's TimersCalendar or TimersMonotonic format. // Format examples: // // "{ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=... }" // "{ OnUnitActiveUSec=1d ; next_elapse=... }" // "{ OnBootUSec=15min ; next_elapse=... }" func extractTimerSpec(raw string) string { if raw == "" { return "" } // Look for the first key=value pair inside braces for _, prefix := range []string{"OnCalendar=", "OnUnitActiveUSec=", "OnBootUSec=", "OnUnitInactiveUSec="} { idx := strings.Index(raw, prefix) if idx == -1 { continue } after := raw[idx+len(prefix):] // Trim at the next semicolon or closing brace if semi := strings.IndexAny(after, ";}"); semi != -1 { after = after[:semi] } after = strings.TrimSpace(after) if after == "" { continue } // Prefix monotonic timers with the trigger type for clarity switch { case strings.HasPrefix(prefix, "OnBoot"): return "every boot + " + after case strings.HasPrefix(prefix, "OnUnitActive"): return "every " + after case strings.HasPrefix(prefix, "OnUnitInactive"): return "every " + after + " after idle" default: return after } } return "" } // formatRelativeTime returns a human-friendly relative time string. func formatRelativeTime(t time.Time) string { d := time.Since(t) if d < 0 { d = -d switch { case d < time.Minute: return "in <1 min" case d < time.Hour: return fmt.Sprintf("in %d min", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("in %dh", int(d.Hours())) default: return fmt.Sprintf("in %dd", int(d.Hours()/24)) } } switch { case d < time.Minute: return "<1 min ago" case d < time.Hour: return fmt.Sprintf("%d min ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh ago", int(d.Hours())) default: return fmt.Sprintf("%dd ago", int(d.Hours()/24)) } } func getUnitNameFromCgroup(pid int) string { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)) if err != nil { return "" } lines := strings.Split(string(data), "\n") for _, line := range lines { parts := strings.SplitN(line, ":", 3) if len(parts) < 3 { continue } controllers := parts[1] path := parts[2] if controllers == "" || strings.Contains(controllers, "systemd") { path = strings.TrimSpace(path) pathParts := strings.Split(path, "/") for i := len(pathParts) - 1; i >= 0; i-- { part := pathParts[i] if strings.HasSuffix(part, ".service") || strings.HasSuffix(part, ".scope") { return part } } } } return "" } ================================================ FILE: internal/source/systemd_windows.go ================================================ //go:build windows package source import "github.com/pranshuparmar/witr/pkg/model" func detectSystemd(ancestry []model.Process) *model.Source { return nil } // IsSystemdRunning always returns false on Windows. func IsSystemdRunning() bool { return false } ================================================ FILE: internal/target/file_darwin.go ================================================ package target import ( "fmt" "os/exec" "strconv" "strings" ) func ResolveFile(path string) ([]int, error) { // Use lsof -F p to get PIDs cmd := exec.Command("lsof", "-F", "p", path) out, err := cmd.Output() if err != nil { if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 { return nil, fmt.Errorf("no process found holding file: %s", path) } return nil, fmt.Errorf("lsof failed: %w", err) } var pids []int lines := strings.Split(string(out), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "p") { pidStr := line[1:] pid, err := strconv.Atoi(pidStr) if err == nil { pids = append(pids, pid) } } } if len(pids) == 0 { return nil, fmt.Errorf("no process found holding file: %s", path) } return pids, nil } ================================================ FILE: internal/target/file_freebsd.go ================================================ package target import ( "fmt" "os/exec" "strconv" "strings" ) func ResolveFile(path string) ([]int, error) { // fstat cmd := exec.Command("fstat", path) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("fstat failed: %w", err) } var pids []int lines := strings.Split(string(out), "\n") for i, line := range lines { if i == 0 { continue } fields := strings.Fields(line) if len(fields) >= 3 { pidStr := fields[2] pid, err := strconv.Atoi(pidStr) if err == nil { pids = append(pids, pid) } } } if len(pids) == 0 { return nil, fmt.Errorf("no process found holding file: %s", path) } return pids, nil } ================================================ FILE: internal/target/file_linux.go ================================================ package target import ( "fmt" "os" "path/filepath" "strconv" ) // ResolveFile finds processes holding a lock on the given file path func ResolveFile(path string) ([]int, error) { absPath, err := filepath.Abs(path) if err != nil { return nil, err } realPath, err := filepath.EvalSymlinks(absPath) if err != nil { realPath = absPath } var pids []int procDirs, err := os.ReadDir("/proc") if err != nil { return nil, fmt.Errorf("failed to read /proc: %w", err) } for _, d := range procDirs { if !d.IsDir() { continue } pid, err := strconv.Atoi(d.Name()) if err != nil { continue } fdDir := filepath.Join("/proc", d.Name(), "fd") fds, err := os.ReadDir(fdDir) if err != nil { continue } for _, fd := range fds { linkPath, err := os.Readlink(filepath.Join(fdDir, fd.Name())) if err != nil { continue } if linkPath == realPath || linkPath == absPath { pids = append(pids, pid) break } } } if len(pids) == 0 { return nil, fmt.Errorf("no process found holding file: %s", absPath) } return pids, nil } ================================================ FILE: internal/target/file_windows.go ================================================ package target import ( "fmt" ) func ResolveFile(path string) ([]int, error) { return nil, fmt.Errorf("finding process by file is not supported on Windows") } ================================================ FILE: internal/target/name_darwin.go ================================================ //go:build darwin package target import ( "fmt" "os" "os/exec" "regexp" "sort" "strconv" "strings" procpkg "github.com/pranshuparmar/witr/internal/proc" ) // isValidServiceLabel validates that a launchd service label contains only // safe characters to prevent command injection. Valid labels contain only // alphanumeric characters, dots, hyphens, and underscores. var validServiceLabelRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) func isValidServiceLabel(label string) bool { if len(label) == 0 || len(label) > 256 { return false } return validServiceLabelRegex.MatchString(label) } func ResolveName(name string, exact bool) ([]int, error) { var procPIDs []int lowerName := strings.ToLower(name) selfPid := os.Getpid() // Resolve own ancestry to exclude parents (sudo, shell, etc.) from matching ignoredPids := make(map[int]bool) ignoredPids[selfPid] = true if ancestry, err := procpkg.ResolveAncestry(selfPid); err == nil { for _, p := range ancestry { ignoredPids[p.PID] = true } } // Use ps to list all processes on macOS // ps -axo pid=,comm=,args= out, err := exec.Command("ps", "-axo", "pid=,comm=,args=").Output() if err != nil { return nil, fmt.Errorf("failed to list processes: %w", err) } for line := range strings.Lines(string(out)) { line = strings.TrimSpace(line) if line == "" { continue } fields := strings.Fields(line) if len(fields) < 2 { continue } pid, err := strconv.Atoi(fields[0]) if err != nil { continue } // Prevent matching the PID itself as a name if lowerName == strconv.Itoa(pid) { continue } // Exclude self and ancestry (parent, witr, sudo, etc.) if ignoredPids[pid] { continue } comm := strings.ToLower(fields[1]) args := "" if len(fields) > 2 { args = strings.ToLower(strings.Join(fields[2:], " ")) } // Match against command name var match bool if exact { match = comm == lowerName } else { match = strings.Contains(comm, lowerName) } if match { // Exclude grep-like processes if !strings.Contains(comm, "grep") { procPIDs = append(procPIDs, pid) continue } } // Match against full command line if exact { match = matchesExactToken(args, lowerName) if match && !strings.Contains(args, "grep") { procPIDs = append(procPIDs, pid) } } else { if strings.Contains(args, lowerName) && !strings.Contains(args, "grep") { procPIDs = append(procPIDs, pid) } } } // Service detection (launchd) servicePID, _ := resolveLaunchdServicePID(name) // Merge and dedupe matches, keeping service PID first. seen := map[int]bool{} var procUnique []int for _, pid := range procPIDs { if pid == servicePID || seen[pid] { continue } seen[pid] = true procUnique = append(procUnique, pid) } sort.Ints(procUnique) var pids []int if servicePID > 0 { pids = append(pids, servicePID) } pids = append(pids, procUnique...) if len(pids) == 0 { return nil, fmt.Errorf("no running process or service named %q", name) } return pids, nil } // resolveLaunchdServicePID tries to resolve a launchd service and returns its PID if running. func resolveLaunchdServicePID(name string) (int, error) { // Validate input before using in command if !isValidServiceLabel(name) { return 0, fmt.Errorf("invalid service name %q", name) } // Try common launchd service label patterns labels := []string{ name, "com.apple." + name, "org." + name, "io." + name, } for _, label := range labels { // All labels are derived from validated name, so they're safe // launchctl print system/