commit 36b3af2e3d97f4953281f3b811227e83ec5ed272 Author: wehub-resource-sync Date: Mon Jul 13 12:22:06 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.agent/workflows/validate-pr-check.md b/.agent/workflows/validate-pr-check.md new file mode 100644 index 0000000..3c0e610 --- /dev/null +++ b/.agent/workflows/validate-pr-check.md @@ -0,0 +1,47 @@ +--- +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 +``` diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..02f6563 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: pranshuparmar diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d1765cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3eeacec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,28 @@ +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. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f18ddaf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + +## 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! diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6d8cce6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# 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: + - "*" diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..f746a23 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,153 @@ +name: PR Check + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + push: + branches: + - main + - staging + +permissions: read-all + +jobs: + + lint: + name: "Code Quality: Format" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + - 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 + + golangci-lint: + name: "Code Quality: Lint (${{ matrix.goos }})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + goos: [linux, darwin, windows, freebsd] + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + # Lint each platform's build tags in turn. Without this, _windows/_darwin/ + # _freebsd files are skipped under the default GOOS=linux and never analyzed. + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + env: + GOOS: ${{ matrix.goos }} + + govulncheck: + name: "Security: Vulnerability Scan" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + - name: Run govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + vendor: + name: "Code Quality: Vendor" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + - name: Verify vendor is in sync + run: | + go mod vendor + git diff --exit-code -- vendor go.mod go.sum + + test: + name: "Tests: Unit (${{ matrix.os }})" + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + race: true + - os: ubuntu-24.04-arm + race: true + - os: macos-latest + race: true + - os: windows-latest + race: false + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + - name: Run tests + shell: bash + run: | + if [ "${{ matrix.race }}" = "true" ]; then + go test -race ./... + else + go test ./... + fi + + coverage: + name: "Code Quality: Coverage" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + # Reports total coverage of the linux build; informational, no threshold gate. + - name: Measure coverage + run: | + go test -covermode=atomic -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + { + echo "### Test coverage (linux build)" + echo '```' + go tool cover -func=coverage.out | tail -1 + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..fc31f91 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,21 @@ +name: PR Title + +# Runs only on pull_request events (never on push), so the semantic-title check +# always runs for PRs and never shows up as a confusing skipped "(push)" check. +# It is blocking: a non-conforming title fails the check. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + semantic-pull-request: + name: "Compliance: Check PR Title" + runs-on: ubuntu-latest + steps: + - name: Check PR title + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d0544ce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +# .github/workflows/release.yml +name: Release + +on: + push: + tags: + - 'v*' + +# Default to read-only; the release job escalates to contents: write so +# GoReleaser can create the release and upload assets. +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +env: + # Track the latest 1.25.x patch (with check-latest below) so release binaries + # pick up stdlib security fixes. + GO_VERSION: '1.25' + PROJECT_NAME: witr + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + + - 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 }} diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml new file mode 100644 index 0000000..347e3a8 --- /dev/null +++ b/.github/workflows/smoke-tests.yml @@ -0,0 +1,64 @@ +name: Smoke Tests + +on: + pull_request: + branches: [ "main", "staging" ] + +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-latest + - os: windows + arch: amd64 + runner: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + check-latest: true + + - 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@v7 + - 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 + go test ./... diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml new file mode 100644 index 0000000..777bda8 --- /dev/null +++ b/.github/workflows/update-docs.yml @@ -0,0 +1,68 @@ +name: Update Documentation +on: + push: + branches: + - main + workflow_dispatch: + + + +# Default to read-only; the publishing job escalates to contents: write. +permissions: + contents: read + +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 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + + - 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 }} + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65632d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# 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/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..25de21d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,49 @@ +version: "2" + +run: + timeout: 5m + +# Linters are kept to a high-signal, low-false-positive core. The `unused` +# linter is intentionally omitted: witr has per-platform files (_linux/_darwin/ +# _freebsd/_windows), and unused analysis runs under a single GOOS in CI, so it +# reports cross-platform helpers (used only on other OSes) as dead. Dead code is +# instead caught by the cross-platform build matrix and review. +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - misspell + settings: + errcheck: + # These return errors that are conventionally ignored: writes to an + # already-failing stdout/stderr, best-effort scanf parses guarded by + # downstream checks, and closing read-only files. + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + - fmt.Sscanf + - (*os.File).Close + - (net.Listener).Close + # Windows syscall cleanup: Close/handle-release errors are conventionally + # ignored, and *LazyProc.Call returns the errno (checked via the r1 return + # value, not this error). + - syscall.CloseHandle + - (syscall.Token).Close + - (*syscall.LazyProc).Call + # Best-effort plist (XML) parsing on macOS: a decode failure means we skip + # that field rather than abort, so the error is intentionally ignored. + - (*encoding/xml.Decoder).DecodeElement + exclusions: + generated: lax + # QF* are opt-in "quickfix" style refactors (tagged switches, De Morgan's + # law), not correctness issues — drop those two nags while keeping every + # high-value SA analyzer in staticcheck's default set. + rules: + - linters: [staticcheck] + text: "QF1001" + - linters: [staticcheck] + text: "QF1003" diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..ced8bc4 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,67 @@ +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 + flags: + - -trimpath + 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 + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..18c9147 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3bb0c47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,134 @@ +# 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) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) + - [Improving Documentation](#improving-documentation) +- [Styleguides](#styleguides) + - [Commit Messages](#commit-messages) + +## 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 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 as described in the Code of Conduct. + +## 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5228010 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..188a9a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.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: + @test -z "$$(find . -name '*.go' -not -path './vendor/*' | xargs gofmt -l)" || { echo 'Unformatted Go files (run gofmt -w):'; find . -name '*.go' -not -path './vendor/*' | xargs gofmt -l; exit 1; } + 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b67e9a9 --- /dev/null +++ b/README.md @@ -0,0 +1,935 @@ +
+ +# 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) [![CodeFactor](https://www.codefactor.io/repository/github/pranshuparmar/witr/badge?style=flat-square)](https://www.codefactor.io/repository/github/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)](#8-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) • [**Core Concept**](#5-core-concept) • [**Examples**](#6-example-outputs) +
+[**Output Behavior**](#7-output-behavior) • [**Platforms**](#8-platform-support) • [**Success Criteria**](#9-success-criteria) • [**Sponsors**](#10-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 + +
+APT (Debian, Ubuntu & Derivatives) Debian +
+ + +You can install **witr** from the official Debian and Ubuntu repositories (Ubuntu 26.04+, Debian sid and later), as well as derivative distributions like Kali Linux, Devuan, and Raspbian: + +```bash +sudo apt install witr +``` + +> Note: The apt-shipped version may lag the latest GitHub release. For the newest features, use the install script or another installation method. +
+ +
+Homebrew (macOS & Linux) Homebrew +
+ + +You can install **witr** using [Homebrew](https://brew.sh/) on macOS or Linux: + +```bash +brew install witr +``` +
+ +
+MacPorts (macOS) MacPorts +
+ + +You can install **witr** using [MacPorts](https://www.macports.org/) on macOS: + +```bash +sudo port 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 +``` +
+ +
+Mise (macOS, Linux & Windows) Mise +
+ +You can install **witr** using [mise](https://mise.jdx.dev/): + +```bash +mise use github:pranshuparmar/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 with four tabs for exploring processes, ports, containers, and file locks. + +### Key Features: +- **Processes Tab**: Live, sortable, filterable list of all running processes with a side panel showing the ancestry tree of the highlighted process. +- **Ports Tab**: Open/listening ports with the owning processes attached in a side panel. Toggle between LISTEN-only and ALL with `a`. +- **Containers Tab**: All running containers across Docker, Podman, nerdctl, K8s/crictl, Incus, LXC, LXD, and FreeBSD jails in one list - name, image, status, ports, command, plus a per-container detail view with mounts, networks, and compose project metadata. +- **Locks Tab**: System-wide file locks (POSIX/FLOCK on Linux, lsof-derived on macOS/FreeBSD). Press `a` to switch into "all open files" mode, where locked entries are merged with every interesting open fd; type into `/` to search across the merged set. +- **Process Details**: Deep-dive into a process to see its full ancestry tree, child processes, environment variables, working directory, sockets, file context, and more. +- **Process Actions**: Send signals (Kill, Terminate, Pause, Resume) or Renice processes directly from the UI (Unix only). +- **Mouse Support**: Navigate, sort columns, and click rows using your mouse. +- **Adaptive Theme**: Colors adapt automatically to light and dark terminal backgrounds. +- **Auto-Refresh**: The process, port, container, and lock lists refresh automatically on an adaptive cadence (starts at 3 seconds, backing off under load). + +--- + +## 4. Flags & Options + +``` + -c, --container strings container(s) to look up (repeatable) + --env show environment variables for the process + -x, --exact use exact name matching (no substring search) + -f, --file strings file(s) held open by a process (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`, `--container`) 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 `--container` flag searches across Docker, Podman, nerdctl, K8s/crictl, Incus, LXC, LXD, and FreeBSD jails, and matches against container name, image, command, and compose project/service labels. + +The TUI is launched if no arguments or relevant flags (`--pid`, `--port`, `--file`, `--container`) are provided, or if the `--interactive` flag is explicitly used. + +--- + +## 5. 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? + +--- + +## 6. Example Outputs + +### 6.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) + +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) +Sockets : 127.0.0.1:5001 (TCP | LISTENING) +``` + +--- + +### 6.2 Short Output + +```bash +witr --port 5000 --short +``` + +``` +systemd (pid 1) → PM2 v5.3.1: God (pid 1481580) → python (pid 1482060) +``` + +--- + +### 6.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._ + +--- + +### 6.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 +``` + +--- + +### 6.5 File Based Query + +```bash +witr --file /var/lib/dpkg/lock +``` + +Explains the process holding a file open. + +--- + +### 6.6 Container Based Query + +```bash +witr --container redis +``` + +Looks up a container by name, image, command, or compose project/service across every detected runtime (Docker, Podman, nerdctl, K8s/crictl, Incus, LXC, LXD, FreeBSD jails). Pass `--verbose` to include mounts, networks, and compose metadata in the output. + +--- + +### 6.7 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. + +--- + +## 7. Output Behavior + +### 7.1 Output Principles + +- Single screen by default (best effort) +- Deterministic ordering +- Narrative-style explanation +- Best-effort detection with explicit uncertainty + +--- + +### 7.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 | +| 5 | Internal error: an unexpected failure occurred | + +#### 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" ;; + 5) echo "Internal error" ;; +esac +``` + +--- + +### 7.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_*) + +--- + +## 8. Platform Support + +- **Linux** (x86_64, arm64) - Full feature support (`/proc`). +- **macOS** (x86_64, arm64) - Uses `ps`, `lsof`, `sysctl`, `pgrep`. +- **Windows** (x86_64, arm64) - Native Win32 APIs (ToolHelp32, PSAPI, Service Control Manager). No PowerShell or WMI dependency. +- **FreeBSD** (x86_64, arm64) - Uses `procstat`, `ps`, `lsof`. + +--- + +### 8.1 Feature Compatibility Matrix + +| Feature | Linux | macOS | Windows | FreeBSD | Notes | +|---------|:-----:|:-----:|:-------:|:-------:|-------| +| **Process Selection** | +| By Name | ✅ | ✅ | ✅ | ✅ | | +| By PID | ✅ | ✅ | ✅ | ✅ | | +| By Port | ✅ | ✅ | ✅ | ✅ | | +| By File | ✅ | ✅ | ✅ | ✅ | | +| By Container | ✅ | ✅ | ✅ | ✅ | Requires the runtime CLI on PATH (docker/podman/nerdctl/crictl/incus/lxc/lxc-ls/jls). | +| Multiple/mixed inputs | ✅ | ✅ | ✅ | ✅ | Repeatable flags, mixed types. | +| Exact Match | ✅ | ✅ | ✅ | ✅ | | +| Full command line | ✅ | ✅ | ✅ | ✅ | | +| Process start time | ✅ | ✅ | ✅ | ✅ | | +| Working directory | ✅ | ✅ | ✅ | ✅ | | +| Environment variables | ✅ | ⚠️ | ⚠️ | ✅ | macOS: SIP restrictions; Windows: protected processes inaccessible. | +| **Network** | +| Listening ports | ✅ | ✅ | ✅ | ✅ | | +| Bind addresses | ✅ | ✅ | ✅ | ✅ | | +| Port → PID resolution | ✅ | ✅ | ✅ | ✅ | | +| Port → Container fallback | ✅ | ✅ | ✅ | ✅ | Used when the port is owned by PID 1 via systemd socket activation or a container runtime. | +| **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, nerdctl, K8s (Kubepods/crictl), Containerd. Colima on macOS/Linux. Incus/LXC/LXD on 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. | +| File Locks | ✅ | ✅ | ❌ | ✅ | Linux: `/proc/locks`; macOS/FreeBSD: derived from `lsof`/`fstat`. | +| 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)** | +| Processes Tab | ✅ | ✅ | ✅ | ✅ | | +| Ports Tab | ✅ | ✅ | ✅ | ✅ | | +| Containers Tab | ✅ | ✅ | ✅ | ✅ | | +| Locks Tab | ✅ | ✅ | ❌ | ✅ | Toggle (`a`) shows all open files. | +| Process Details | ✅ | ✅ | ✅ | ✅ | | +| Process Actions | ✅ | ✅ | ❌ | ✅ | | + +**Legend:** ✅ Full support | ⚠️ Partial/limited support | ❌ Not available + +--- + +### 8.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 talks directly to Win32 APIs (ToolHelp32, PSAPI, Service Control Manager) rather than spawning PowerShell or WMI, startup is fast and there's no `Get-CimInstance` hang. 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] +``` + +--- + +## 9. 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 + +--- + +## 10. Sponsors + +Special thanks to the people supporting **witr** ❤️ + +

+ + + + + + +

diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..087cd9f --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`pranshuparmar/witr` +- 原始仓库:https://github.com/pranshuparmar/witr +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0196f5f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# 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 | :white_check_mark: | +| FreeBSD | :white_check_mark: | + +See our [Feature Compatibility Matrix](README.md#81-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. diff --git a/VERSION b/VERSION new file mode 120000 index 0000000..3b65c8f --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +internal/version/VERSION \ No newline at end of file diff --git a/cmd/witr/main.go b/cmd/witr/main.go new file mode 100644 index 0000000..2371273 --- /dev/null +++ b/cmd/witr/main.go @@ -0,0 +1,19 @@ +//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() +} diff --git a/cmd/witr/unsupported.go b/cmd/witr/unsupported.go new file mode 100644 index 0000000..174cc6a --- /dev/null +++ b/cmd/witr/unsupported.go @@ -0,0 +1,16 @@ +//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) +} diff --git a/docs/cli/witr.1 b/docs/cli/witr.1 new file mode 100644 index 0000000..cab69c1 --- /dev/null +++ b/docs/cli/witr.1 @@ -0,0 +1,126 @@ +.nh +.TH "WITR" "1" "Jul 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-c\fP, \fB--container\fP=[] + container(s) to look up (repeatable) + +.PP +\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(s) held open by a process (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 file open + witr --file /var/lib/dpkg/lock + + # Inspect a container by name + witr --container redis + + # 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 diff --git a/docs/cli/witr.md b/docs/cli/witr.md new file mode 100644 index 0000000..1d08e35 --- /dev/null +++ b/docs/cli/witr.md @@ -0,0 +1,87 @@ +## 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 file open + witr --file /var/lib/dpkg/lock + + # Inspect a container by name + witr --container redis + + # 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 + +``` + -c, --container strings container(s) to look up (repeatable) + --env show environment variables for the process + -x, --exact use exact name matching (no substring search) + -f, --file strings file(s) held open by a process (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 +``` + diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fe22ed6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1767047869, + "narHash": "sha256-tzYsEzXEVa7op1LTnrLSiPGrcCY6948iD0EcNLWcmzo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "89dbf01df72eb5ebe3b24a86334b12c27d68016a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..bb683a0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,79 @@ +{ + 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"; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f09d10b --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module github.com/pranshuparmar/witr + +go 1.25 + +toolchain go1.25.10 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/coreos/go-systemd/v22 v22.7.0 + github.com/godbus/dbus/v5 v5.1.0 + github.com/mattn/go-isatty v0.0.20 + github.com/muesli/reflow v0.3.1-0.20230316100924-83f637991171 + github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.38.0 +) + +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-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/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a9ba37 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +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/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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= diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..58edd5c --- /dev/null +++ b/install.ps1 @@ -0,0 +1,83 @@ +$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" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e3f43a2 --- /dev/null +++ b/install.sh @@ -0,0 +1,116 @@ +#!/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" diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..bdc58df --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,826 @@ +//go:build linux || darwin || freebsd || windows + +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "runtime" + "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 file open + witr --file /var/lib/dpkg/lock + + # Inspect a container by name + witr --container redis + + # 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 + ExitNotFound = 2 + ExitPermission = 3 + ExitInvalidInput = 4 + // ExitInternalError is distinct from ExitWarnings so scripts can tell an + // unexpected witr failure apart from "process found, has warnings". + ExitInternalError = 5 +) + +// 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(s) held open by a process (repeatable)") + rootCmd.Flags().StringSliceP("container", "c", nil, "container(s) to look up (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") + containerFlags, _ := cmd.Flags().GetStringSlice("container") + + if !envFlag && len(pidFlags) == 0 && len(portFlags) == 0 && len(fileFlags) == 0 && len(containerFlags) == 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, flagTakesValue(cmd)) + + if len(targets) == 0 { + return withExitCode(ExitInvalidInput, fmt.Errorf("must specify --pid, --port, --file, --container, or a process name")) + } + + outw := cmd.OutOrStdout() + outp := output.NewPrinter(outw) + multiMode := len(targets) > 1 + colorEnabled := useColor(flags, outw) + + // 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 +} + +// flagTakesValue reports whether a raw argv token names a non-boolean flag +// whose value is the following token (e.g. "--config foo"). It lets the +// order-preserving parser take flag-arity from cobra's flag set instead of +// assuming every non-target flag is boolean — so a future string-valued flag +// won't have its value mistaken for a target. +func flagTakesValue(cmd *cobra.Command) func(string) bool { + return func(arg string) bool { + if strings.Contains(arg, "=") { + return false // value is attached: --flag=value + } + if name, ok := strings.CutPrefix(arg, "--"); ok { + if f := cmd.Flags().Lookup(name); f != nil { + return f.NoOptDefVal == "" + } + } else if sh, ok := strings.CutPrefix(arg, "-"); ok && len(sh) == 1 { + if f := cmd.Flags().ShorthandLookup(sh); f != nil { + return f.NoOptDefVal == "" + } + } + return false + } +} + +// collectTargetsInOrder walks the raw command-line arguments to build a target +// list that preserves the order the user typed them in. takesValue reports +// whether a non-target flag consumes the following token as its value. +func collectTargetsInOrder(rawArgs []string, positionalArgs []string, takesValue func(string) bool) []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, + "-c": model.TargetContainer, "--container": model.TargetContainer, + } + + // 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] + + // "--" ends option parsing (POSIX): everything after it is positional, + // matching how cobra fills positionalArgs. + if arg == "--" { + break + } + + // 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 + } + + // Non-target flag. If it's a value-taking flag in space form + // (--flag value, not --flag=value), skip its value too so the value + // isn't mistaken for a positional target. + if strings.HasPrefix(arg, "-") { + if takesValue(arg) && i+1 < len(rawArgs) { + i++ // consume the flag's value + } + 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) + case model.TargetContainer: + return fmt.Sprintf("container: %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 := useColor(flags, outw) + + if flags.env { + return processEnvTarget(outw, outp, t, flags, multiMode, jsonResults) + } + + if t.Type == model.TargetContainer { + return processContainerTarget(cmd, 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 := useColor(flags, outw) + + 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 := useColor(flags, outw) + + // Platform-unsupported target (e.g. -f on Windows). Don't tack on the + // generic "try a different name/port/PID" suffix — the operation isn't a + // failed lookup, it's unavailable on this OS. + if errors.Is(err, target.ErrUnsupported) || strings.Contains(errStr, "not supported on") { + if multiMode { + if flags.json { + *jsonResults = append(*jsonResults, jsonErrorEntry(t, errStr)) + } else { + outp.Printf("Error: %v\n", err) + } + } else { + cmd.PrintErrln(errStr) + } + return ExitInvalidInput + } + + if errors.Is(err, target.ErrSocketOwnerUnknown) || 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 { + label := "port " + t.Value + if flags.json { + jsonStr, jsonErr := output.ContainerFallbackToJSON(label, 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.RenderContainerFallbackShort(outw, label, match, colorEnabled) + } else { + output.RenderContainerFallback(outw, label, match, colorEnabled, flags.verbose) + } + 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) + if t.Type == model.TargetFile && runtime.GOOS != "windows" && os.Geteuid() != 0 { + errorMsg += "\n\nIf the file is held by another user's process, retry with sudo:\n sudo " + strings.Join(os.Args, " ") + } + 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 := useColor(flags, outw) + + 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.Printf("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.Printf("\nRe-run with:\n") + outp.Printf(" %s\n", hint) +} + +func printContainerMultiMatch(outp output.Printer, matches []*model.ContainerMatch, colorEnabled bool) { + outp.Printf("Multiple matching containers found:\n\n") + for i, m := range matches { + name := output.SanitizeTerminal(m.Name) + image := output.SanitizeTerminal(m.Image) + status := output.SanitizeTerminal(m.Status) + ports := output.SanitizeTerminal(m.Ports) + runtime := output.SanitizeTerminal(m.Runtime) + if colorEnabled { + outp.Printf("[%d] %s%s%s (%s%s%s)\n", + i+1, output.ColorGreen, name, output.ColorReset, + output.ColorDim, runtime, output.ColorReset) + } else { + outp.Printf("[%d] %s (%s)\n", i+1, name, runtime) + } + detail := "image: " + image + if status != "" { + detail += ", status: " + status + } + if ports != "" { + detail += ", ports: " + ports + } + outp.Printf(" %s\n", detail) + } + outp.Printf("\nRe-run with the exact container name to disambiguate:\n") + outp.Println(" witr -c --exact") +} + +// 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 + } +} + +// processContainerTarget handles `-c/--container` lookups. Resolves against +// every available container runtime, dispatches to the normal pipeline if +// the container's main process is host-visible, otherwise renders the +// runtime-side metadata via the container fallback view. +func processContainerTarget(cmd *cobra.Command, outw io.Writer, outp output.Printer, t model.Target, flags appFlags, multiMode bool, jsonResults *[]string) int { + colorEnabled := useColor(flags, outw) + + matches := procpkg.ResolveContainer(t.Value, flags.exact) + if len(matches) == 0 { + err := fmt.Errorf("no container found matching %q", t.Value) + return handleResolveError(cmd, outw, outp, t, err, flags, multiMode, jsonResults) + } + + if len(matches) > 1 { + if multiMode && flags.json { + *jsonResults = append(*jsonResults, jsonErrorEntry(t, fmt.Sprintf("multiple containers matched (%d results)", len(matches)))) + } else { + printContainerMultiMatch(outp, matches, colorEnabled) + } + return ExitInvalidInput + } + + match := matches[0] + procpkg.EnrichContainer(match) + pid := procpkg.ResolveContainerHostPID(match.Runtime, match.ID) + if pid > 0 && procpkg.PIDBelongsToContainer(pid, match.ID) { + res, err := pipeline.AnalyzePID(pipeline.AnalyzeConfig{ + PID: pid, + Verbose: flags.verbose, + Tree: flags.tree, + Target: t, + }) + if err != nil { + outp.Printf("Error: %v\n", err) + return classifyError(err) + } + res.Process.Container = output.FormatContainerLine(match) + if len(res.Ancestry) > 0 { + res.Ancestry[len(res.Ancestry)-1].Container = res.Process.Container + } + renderResult(outw, res, flags, multiMode, jsonResults) + if len(res.Warnings) > 0 { + return ExitWarnings + } + return ExitOK + } + + label := "container " + match.Name + switch { + case flags.json: + jsonStr, err := output.ContainerFallbackToJSON(label, match) + 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) + } + case flags.short: + output.RenderContainerFallbackShort(outw, label, match, colorEnabled) + case flags.tree: + output.RenderContainerFallbackTree(outw, match, colorEnabled) + case flags.warn: + output.RenderContainerFallbackWarnings(outw, match, colorEnabled) + default: + output.RenderContainerFallback(outw, label, match, colorEnabled, flags.verbose) + } + return ExitOK +} + +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 +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..0ad3753 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "errors" + "testing" +) + +// The mappings here drive script and CI integrations, so any +// regression in classification is a breaking change for users. +func TestClassifyError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg string + want int + }{ + {"permission denied → 3", "open /proc/1/environ: permission denied", ExitPermission}, + {"operation not permitted → 3", "kill: operation not permitted", ExitPermission}, + {"insufficient permissions → 3", "insufficient permissions to read process info", ExitPermission}, + + {"no matching → 2", "no matching process found", ExitNotFound}, + {"no running process → 2", "no running process with that name", ExitNotFound}, + {"not found → 2", "process 999999 not found", ExitNotFound}, + {"no process → 2", "no process listening on port 80", ExitNotFound}, + + {"invalid → 4", "invalid pid", ExitInvalidInput}, + {"must specify → 4", "must specify --pid, --port, --file, or a process name", ExitInvalidInput}, + + {"unknown error → internal", "something exploded", ExitInternalError}, + {"empty message → internal", "", ExitInternalError}, + + {"case-insensitive (uppercase)", "PERMISSION DENIED", ExitPermission}, + {"case-insensitive (mixed)", "No Matching Process", ExitNotFound}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := classifyError(errors.New(tt.msg)) + if got != tt.want { + t.Errorf("classifyError(%q) = %d, want %d", tt.msg, got, tt.want) + } + }) + } +} + +// An internal error must be distinguishable from "process has warnings" so +// scripts gating on exit 1 don't conflate the two. +func TestExitCodesDistinct(t *testing.T) { + t.Parallel() + if ExitInternalError == ExitWarnings { + t.Errorf("ExitInternalError (%d) must differ from ExitWarnings (%d)", ExitInternalError, ExitWarnings) + } + if ExitInternalError != 5 { + t.Errorf("ExitInternalError = %d, want 5 (documented)", ExitInternalError) + } +} diff --git a/internal/app/collect_test.go b/internal/app/collect_test.go new file mode 100644 index 0000000..a65fcd8 --- /dev/null +++ b/internal/app/collect_test.go @@ -0,0 +1,197 @@ +package app + +import ( + "reflect" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func tgt(tp model.TargetType, v string) model.Target { return model.Target{Type: tp, Value: v} } + +// collectTargetsInOrder is the order-preserving CLI target parser. It duplicates +// some of cobra's flag semantics (it reads raw argv to keep the user's typed +// order), so these cases pin that behavior down before any future refactor. +func TestCollectTargetsInOrder(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawArgs []string + positional []string + valueFlags []string // non-target flags that consume the next token + want []model.Target + }{ + { + name: "single name", + rawArgs: []string{"nginx"}, + positional: []string{"nginx"}, + want: []model.Target{tgt(model.TargetName, "nginx")}, + }, + { + name: "pid long flag, space form", + rawArgs: []string{"--pid", "1234"}, + want: []model.Target{tgt(model.TargetPID, "1234")}, + }, + { + name: "pid short flag", + rawArgs: []string{"-p", "1234"}, + want: []model.Target{tgt(model.TargetPID, "1234")}, + }, + { + name: "equals form", + rawArgs: []string{"--port=8080"}, + want: []model.Target{tgt(model.TargetPort, "8080")}, + }, + { + name: "comma split, space form", + rawArgs: []string{"--port", "80,443"}, + want: []model.Target{tgt(model.TargetPort, "80"), tgt(model.TargetPort, "443")}, + }, + { + name: "comma split, equals form, blanks trimmed", + rawArgs: []string{"--pid=1, ,2"}, + want: []model.Target{tgt(model.TargetPID, "1"), tgt(model.TargetPID, "2")}, + }, + { + name: "interleaved order preserved", + rawArgs: []string{"nginx", "--pid", "1234", "node"}, + positional: []string{"nginx", "node"}, + want: []model.Target{ + tgt(model.TargetName, "nginx"), + tgt(model.TargetPID, "1234"), + tgt(model.TargetName, "node"), + }, + }, + { + name: "boolean flags skipped", + rawArgs: []string{"--json", "nginx", "--verbose", "-x"}, + positional: []string{"nginx"}, + want: []model.Target{tgt(model.TargetName, "nginx")}, + }, + { + name: "mixed flag types", + rawArgs: []string{"--port", "8080", "redis", "-f", "/tmp/x", "-c", "web"}, + positional: []string{"redis"}, + want: []model.Target{ + tgt(model.TargetPort, "8080"), + tgt(model.TargetName, "redis"), + tgt(model.TargetFile, "/tmp/x"), + tgt(model.TargetContainer, "web"), + }, + }, + { + name: "remaining positionals appended", + rawArgs: []string{}, + positional: []string{"a", "b"}, + want: []model.Target{tgt(model.TargetName, "a"), tgt(model.TargetName, "b")}, + }, + { + // Regression for the latent argv bug: a space-form string-valued + // flag must consume its value, or that value steals a positional + // slot and corrupts the interleaved target order. Fails without the + // flag-arity fix (would yield alpha, beta, pid:5). + name: "value-flag value not mistaken for a target", + rawArgs: []string{"alpha", "--config", "nginx", "--pid", "5", "beta"}, + positional: []string{"alpha", "beta"}, + valueFlags: []string{"--config"}, + want: []model.Target{ + tgt(model.TargetName, "alpha"), + tgt(model.TargetPID, "5"), + tgt(model.TargetName, "beta"), + }, + }, + { + name: "value-flag in equals form needs no lookahead", + rawArgs: []string{"--config=app.yml", "nginx"}, + positional: []string{"nginx"}, + valueFlags: []string{"--config"}, + want: []model.Target{tgt(model.TargetName, "nginx")}, + }, + { + name: "short value-flag, space form", + rawArgs: []string{"-C", "app.yml", "redis"}, + positional: []string{"redis"}, + valueFlags: []string{"-C"}, + want: []model.Target{tgt(model.TargetName, "redis")}, + }, + { + name: "target flag without a value yields no target", + rawArgs: []string{"--pid"}, + want: nil, + }, + { + name: "empty value via equals yields no target", + rawArgs: []string{"--port="}, + want: nil, + }, + { + // "--" ends option parsing: following tokens are positional names even + // when they look like flags. + name: "double dash ends option parsing", + rawArgs: []string{"--", "nginx"}, + positional: []string{"nginx"}, + want: []model.Target{tgt(model.TargetName, "nginx")}, + }, + { + name: "double dash forces flaglike tokens to names", + rawArgs: []string{"--", "--pid", "5"}, + positional: []string{"--pid", "5"}, + want: []model.Target{tgt(model.TargetName, "--pid"), tgt(model.TargetName, "5")}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + takesValue := func(a string) bool { + for _, f := range tc.valueFlags { + if a == f { + return true + } + } + return false + } + got := collectTargetsInOrder(tc.rawArgs, tc.positional, takesValue) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("collectTargetsInOrder(%v, %v)\n got: %v\nwant: %v", tc.rawArgs, tc.positional, got, tc.want) + } + }) + } +} + +func TestTargetLabel(t *testing.T) { + t.Parallel() + + cases := []struct { + in model.Target + want string + }{ + {tgt(model.TargetPID, "1234"), "pid: 1234"}, + {tgt(model.TargetPort, "80"), "port: 80"}, + {tgt(model.TargetFile, "/x"), "file: /x"}, + {tgt(model.TargetContainer, "c"), "container: c"}, + {tgt(model.TargetName, "n"), "name: n"}, + } + for _, c := range cases { + if got := targetLabel(c.in); got != c.want { + t.Errorf("targetLabel(%+v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestJSONErrorEntry(t *testing.T) { + t.Parallel() + + s := jsonErrorEntry(tgt(model.TargetPort, "8080"), "boom") + for _, want := range []string{`"Error"`, "boom", "8080", "port"} { + if !strings.Contains(s, want) { + t.Errorf("jsonErrorEntry missing %q in:\n%s", want, s) + } + } +} diff --git a/internal/app/color.go b/internal/app/color.go new file mode 100644 index 0000000..95f06b0 --- /dev/null +++ b/internal/app/color.go @@ -0,0 +1,33 @@ +package app + +import ( + "io" + "os" + + "github.com/mattn/go-isatty" +) + +// useColor reports whether CLI output should be colorized for the given writer. +// Color is enabled only when the user hasn't disabled it (--no-color or the +// NO_COLOR convention) AND the destination is an interactive terminal — so +// piping or redirecting to a file yields clean text with no escape codes. When +// color is enabled it also ensures the terminal will interpret the sequences (a +// no-op outside Windows). The terminal setup is idempotent, so calling this on +// each render path is harmless. +func useColor(flags appFlags, w io.Writer) bool { + if flags.noColor || os.Getenv("NO_COLOR") != "" || !isTerminal(w) { + return false + } + enableVirtualTerminal(w) + return true +} + +// isTerminal reports whether w is an interactive terminal/console rather than a +// pipe or regular file. +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} diff --git a/internal/app/exitcode_test.go b/internal/app/exitcode_test.go new file mode 100644 index 0000000..3ff2b11 --- /dev/null +++ b/internal/app/exitcode_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// buildWitr compiles the real witr binary once so the process exit codes can be +// characterized end to end (Execute -> runApp -> os.Exit). The exit-code +// contract drives scripting and CI integrations, so it's asserted against the +// actual binary rather than internal helpers. +func buildWitr(t *testing.T) string { + t.Helper() + + gomod, err := exec.Command("go", "env", "GOMOD").Output() + if err != nil { + t.Fatalf("go env GOMOD: %v", err) + } + root := filepath.Dir(strings.TrimSpace(string(gomod))) + + bin := filepath.Join(t.TempDir(), "witr") + if runtime.GOOS == "windows" { + bin += ".exe" + } + build := exec.Command("go", "build", "-o", bin, "./cmd/witr") + build.Dir = root + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build witr: %v\n%s", err, out) + } + return bin +} + +func runExit(t *testing.T, bin string, args ...string) int { + t.Helper() + err := exec.Command(bin, args...).Run() + if err == nil { + return 0 + } + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + t.Fatalf("run witr %v: %v", args, err) + return -1 +} + +func TestExitCodes(t *testing.T) { + if testing.Short() { + t.Skip("builds the witr binary; skipped under -short") + } + bin := buildWitr(t) + + // PID 2147483646 is far above any real PID on Linux/macOS/Windows, so the + // "not found" path is deterministic on CI runners. + const ghostPID = "2147483646" + + tests := []struct { + name string + args []string + want int + }{ + {"invalid pid (non-numeric)", []string{"--pid", "notanumber"}, ExitInvalidInput}, + {"invalid pid (zero)", []string{"--pid", "0"}, ExitInvalidInput}, + {"invalid port (out of range)", []string{"--port", "70000"}, ExitInvalidInput}, + {"not found (ghost pid)", []string{"--pid", ghostPID}, ExitNotFound}, + // Multi-target exit code is the highest severity among targets, not the + // first or last — assert with both orderings of a not-found(2) and an + // invalid(4) target. + {"multi: not-found then invalid", []string{"--pid", ghostPID, "--port", "70000"}, ExitInvalidInput}, + {"multi: invalid then not-found", []string{"--port", "70000", "--pid", ghostPID}, ExitInvalidInput}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if got := runExit(t, bin, tc.args...); got != tc.want { + t.Errorf("witr %v exit = %d, want %d", tc.args, got, tc.want) + } + }) + } +} diff --git a/internal/app/helpers_test.go b/internal/app/helpers_test.go new file mode 100644 index 0000000..fe0a721 --- /dev/null +++ b/internal/app/helpers_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" +) + +func TestExitCodeError(t *testing.T) { + base := errors.New("boom") + err := withExitCode(ExitPermission, base) + + if err.Error() != "boom" { + t.Errorf("Error() = %q, want %q", err.Error(), "boom") + } + if !errors.Is(err, base) { + t.Error("Unwrap should expose the wrapped error") + } + var ece *exitCodeError + if !errors.As(err, &ece) || ece.code != ExitPermission { + t.Errorf("errors.As should recover the exit code; got %+v", ece) + } +} + +func TestBoolFlag(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", false, "") + + if boolFlag(cmd, "verbose") { + t.Error("verbose should default to false") + } + _ = cmd.Flags().Set("verbose", "true") + if !boolFlag(cmd, "verbose") { + t.Error("verbose should be true after Set") + } + if boolFlag(cmd, "does-not-exist") { + t.Error("an unknown flag should read as false") + } +} + +func TestFlagTakesValue(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().StringSliceP("pid", "p", nil, "") + cmd.Flags().BoolP("verbose", "v", false, "") + takes := flagTakesValue(cmd) + + cases := []struct { + arg string + want bool + }{ + {"--pid", true}, // value-taking flag + {"--verbose", false}, // boolean flag + {"--pid=5", false}, // value is attached + {"-p", true}, // value-taking shorthand + {"-v", false}, // boolean shorthand + {"notaflag", false}, // not a flag token at all + } + for _, tc := range cases { + if got := takes(tc.arg); got != tc.want { + t.Errorf("flagTakesValue()(%q) = %v, want %v", tc.arg, got, tc.want) + } + } +} + +func TestRootCommand(t *testing.T) { + root := Root() + if root == nil { + t.Fatal("Root() returned nil") + } + for _, name := range []string{"pid", "port", "file", "container", "json", "verbose", "interactive"} { + if root.Flags().Lookup(name) == nil { + t.Errorf("root command is missing the --%s flag", name) + } + } +} + +func TestSetVersion(t *testing.T) { + oldV, oldC, oldB := version, commit, buildDate + t.Cleanup(func() { SetVersion(oldV, oldC, oldB) }) + + SetVersion("v9.9.9", "abcdef1", "2026-01-02") + if version != "v9.9.9" || commit != "abcdef1" || buildDate != "2026-01-02" { + t.Errorf("package vars not updated: %q %q %q", version, commit, buildDate) + } + if Root().Version != "v9.9.9" { + t.Errorf("rootCmd.Version = %q, want v9.9.9", Root().Version) + } +} diff --git a/internal/app/misc_test.go b/internal/app/misc_test.go new file mode 100644 index 0000000..cdf1338 --- /dev/null +++ b/internal/app/misc_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "bytes" + "strings" + "testing" + + "github.com/pranshuparmar/witr/internal/output" + "github.com/pranshuparmar/witr/pkg/model" +) + +// TestUseColor pins the color-gating contract: color is enabled only for an +// interactive terminal and is suppressed when the user opts out. A bytes.Buffer +// is never a terminal, so it must always come back false — this is what keeps +// escape codes out of piped/redirected output. +func TestUseColor(t *testing.T) { + t.Parallel() + + if useColor(appFlags{}, &bytes.Buffer{}) { + t.Error("a bytes.Buffer is not a terminal; color should be disabled") + } + if useColor(appFlags{noColor: true}, &bytes.Buffer{}) { + t.Error("--no-color must disable color") + } +} + +// TestPrintDivider checks the multi-target divider carries the human-readable +// target label. +func TestPrintDivider(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + p := output.NewPrinter(&buf) + printDivider(p, model.Target{Type: model.TargetPort, Value: "8080"}, false, false) + + if got := buf.String(); !strings.Contains(got, "port: 8080") { + t.Errorf("divider missing target label; got %q", got) + } +} diff --git a/internal/app/multimatch_test.go b/internal/app/multimatch_test.go new file mode 100644 index 0000000..358820d --- /dev/null +++ b/internal/app/multimatch_test.go @@ -0,0 +1,45 @@ +package app + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/pranshuparmar/witr/internal/output" + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestPrintMultiMatch(t *testing.T) { + for _, color := range []bool{false, true} { + var buf bytes.Buffer + outp := output.NewPrinter(&buf) + // Anchor on our own PID so ReadProcess succeeds deterministically. + printMultiMatch(outp, []int{os.Getpid()}, color, "witr --pid 1234") + out := buf.String() + if !strings.Contains(out, "Multiple matching processes") || !strings.Contains(out, "witr --pid 1234") { + t.Errorf("color=%v: printMultiMatch output wrong:\n%s", color, out) + } + if strings.Contains(out, `\n`) { + t.Errorf("color=%v: layout newline escaped to a literal \\n:\n%s", color, out) + } + } +} + +func TestPrintContainerMultiMatch(t *testing.T) { + matches := []*model.ContainerMatch{ + {Name: "web", Image: "nginx:latest", Runtime: "docker", Status: "Up 3 min", Ports: "0.0.0.0:80->80/tcp"}, + } + for _, color := range []bool{false, true} { + var buf bytes.Buffer + outp := output.NewPrinter(&buf) + printContainerMultiMatch(outp, matches, color) + out := buf.String() + if !strings.Contains(out, "Multiple matching containers") || !strings.Contains(out, "web") || !strings.Contains(out, "nginx:latest") { + t.Errorf("color=%v: printContainerMultiMatch output wrong:\n%s", color, out) + } + if strings.Contains(out, `\n`) { + t.Errorf("color=%v: layout newline escaped to a literal \\n:\n%s", color, out) + } + } +} diff --git a/internal/app/render_test.go b/internal/app/render_test.go new file mode 100644 index 0000000..6118fe5 --- /dev/null +++ b/internal/app/render_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "bytes" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func sampleResult() model.Result { + return model.Result{ + Process: model.Process{PID: 1234, Command: "nginx", Cmdline: "nginx -g daemon off;"}, + Ancestry: []model.Process{{PID: 1, Command: "systemd"}, {PID: 1234, Command: "nginx"}}, + Source: model.Source{Type: model.SourceSystemd, Name: "nginx.service"}, + Warnings: []string{"Process is running as root"}, + } +} + +// TestRenderResultDispatch pins the output-mode routing in renderResult: which +// renderer each flag selects, and that multi-target JSON accumulates into the +// shared slice instead of writing to the output stream. +func TestRenderResultDispatch(t *testing.T) { + t.Parallel() + res := sampleResult() + + // Standard (no flags) writes a human report containing the process name. + var std bytes.Buffer + var jr []string + renderResult(&std, res, appFlags{}, false, &jr) + if !strings.Contains(std.String(), "nginx") { + t.Errorf("standard output missing process name:\n%s", std.String()) + } + + // JSON single-target writes serialized output containing the PID. + var js bytes.Buffer + renderResult(&js, res, appFlags{json: true}, false, &jr) + if !strings.Contains(js.String(), "1234") { + t.Errorf("json output missing pid:\n%s", js.String()) + } + + // JSON multi-target accumulates into jsonResults and does not write to outw. + jr = nil + var jm bytes.Buffer + renderResult(&jm, res, appFlags{json: true}, true, &jr) + if len(jr) != 1 { + t.Errorf("multi-target json: got %d accumulated results, want 1", len(jr)) + } + if jm.Len() != 0 { + t.Errorf("multi-target json must not write to outw, got: %s", jm.String()) + } + + // Each non-JSON mode produces some output. + modes := map[string]appFlags{ + "short": {short: true}, + "tree": {tree: true}, + "warnings": {warn: true}, + } + for name, f := range modes { + var b bytes.Buffer + renderResult(&b, res, f, false, &jr) + if b.Len() == 0 { + t.Errorf("%s mode produced no output", name) + } + } +} diff --git a/internal/app/resolve_error_test.go b/internal/app/resolve_error_test.go new file mode 100644 index 0000000..f47af9e --- /dev/null +++ b/internal/app/resolve_error_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "bytes" + "errors" + "testing" + + "github.com/pranshuparmar/witr/internal/output" + "github.com/pranshuparmar/witr/internal/target" + "github.com/pranshuparmar/witr/pkg/model" + "github.com/spf13/cobra" +) + +func TestHandleResolveError(t *testing.T) { + newCmd := func() *cobra.Command { + cmd := &cobra.Command{} + var errBuf bytes.Buffer + cmd.SetErr(&errBuf) + cmd.SetOut(&errBuf) + return cmd + } + + t.Run("generic not-found maps to ExitNotFound", func(t *testing.T) { + var outw bytes.Buffer + var jsonResults []string + code := handleResolveError(newCmd(), &outw, output.NewPrinter(&outw), + model.Target{Type: model.TargetName, Value: "ghost"}, + errors.New("no matching process found"), + appFlags{}, false, &jsonResults) + if code != ExitNotFound { + t.Errorf("code = %d, want %d (ExitNotFound)", code, ExitNotFound) + } + }) + + t.Run("unsupported target maps to ExitInvalidInput", func(t *testing.T) { + var outw bytes.Buffer + var jsonResults []string + code := handleResolveError(newCmd(), &outw, output.NewPrinter(&outw), + model.Target{Type: model.TargetFile, Value: "/x"}, + target.ErrUnsupported, + appFlags{}, false, &jsonResults) + if code != ExitInvalidInput { + t.Errorf("code = %d, want %d (ExitInvalidInput)", code, ExitInvalidInput) + } + }) + + t.Run("multi-mode JSON appends an error entry", func(t *testing.T) { + var outw bytes.Buffer + var jsonResults []string + handleResolveError(newCmd(), &outw, output.NewPrinter(&outw), + model.Target{Type: model.TargetName, Value: "ghost"}, + errors.New("no matching process found"), + appFlags{json: true}, true, &jsonResults) + if len(jsonResults) != 1 { + t.Errorf("expected 1 JSON error entry, got %d", len(jsonResults)) + } + }) +} diff --git a/internal/app/runapp_test.go b/internal/app/runapp_test.go new file mode 100644 index 0000000..4724b06 --- /dev/null +++ b/internal/app/runapp_test.go @@ -0,0 +1,40 @@ +package app + +import ( + "bytes" + "os" + "strconv" + "strings" + "testing" +) + +// TestRunAppRendersReportForSelf drives the full command in-process against our +// own PID. The existing exit-code tests run the built binary as a subprocess +// (so they don't show up in coverage) and only exercise error paths; this is +// the one in-process test of the *success* path: runApp -> processTarget -> +// renderResult producing a real report. +func TestRunAppRendersReportForSelf(t *testing.T) { + pid := strconv.Itoa(os.Getpid()) + + // runApp reads os.Args directly to preserve command-line target ordering, + // so it must agree with the args we hand cobra. + oldArgs := os.Args + os.Args = []string{"witr", "--pid", pid} + t.Cleanup(func() { os.Args = oldArgs }) + + cmd := Root() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--pid", pid}) + t.Cleanup(func() { cmd.SetArgs(nil) }) + + // Execute may return a non-nil error when the process carries warnings + // (exit 1) — that's fine, we're asserting the report rendered, not the code. + _ = cmd.Execute() + + report := out.String() + if !strings.Contains(report, "Process") || !strings.Contains(report, pid) { + t.Errorf("runApp did not render a report for self (pid %s):\n%s", pid, report) + } +} diff --git a/internal/app/vt_other.go b/internal/app/vt_other.go new file mode 100644 index 0000000..24a16df --- /dev/null +++ b/internal/app/vt_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package app + +import "io" + +// enableVirtualTerminal is a no-op: Unix terminals interpret ANSI escape +// sequences natively. +func enableVirtualTerminal(w io.Writer) {} diff --git a/internal/app/vt_windows.go b/internal/app/vt_windows.go new file mode 100644 index 0000000..1352e9c --- /dev/null +++ b/internal/app/vt_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package app + +import ( + "io" + "os" + + "golang.org/x/sys/windows" +) + +// enableVirtualTerminal turns on ANSI escape-sequence processing for a Windows +// console so colored output renders instead of printing raw escape codes (e.g. +// "←[0m"). Some console hosts and elevated launches start with it disabled. +// It is best-effort: if the destination isn't a console, the call is skipped. +func enableVirtualTerminal(w io.Writer) { + f, ok := w.(*os.File) + if !ok { + return + } + handle := windows.Handle(f.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + return + } + _ = windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +} diff --git a/internal/launchd/plist.go b/internal/launchd/plist.go new file mode 100644 index 0000000..12c987f --- /dev/null +++ b/internal/launchd/plist.go @@ -0,0 +1,512 @@ +//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" + } +} diff --git a/internal/launchd/plist_test.go b/internal/launchd/plist_test.go new file mode 100644 index 0000000..587fa3a --- /dev/null +++ b/internal/launchd/plist_test.go @@ -0,0 +1,237 @@ +//go:build darwin + +package launchd + +import ( + "reflect" + "testing" +) + +func TestParsePlistXML(t *testing.T) { + t.Parallel() + + const data = ` + + + + Label + com.example.agent + Comment + Example agent + Program + /usr/local/bin/agent + RunAtLoad + + KeepAlive + + StartInterval + 300 + ProgramArguments + + /usr/local/bin/agent + --serve + + WatchPaths + + /etc/agent.conf + + QueueDirectories + + /var/spool/agent + + +` + + info := &LaunchdInfo{} + if err := parsePlistXML([]byte(data), info); err != nil { + t.Fatalf("parsePlistXML returned error: %v", err) + } + + if info.Label != "com.example.agent" { + t.Errorf("Label = %q, want %q", info.Label, "com.example.agent") + } + if info.Comment != "Example agent" { + t.Errorf("Comment = %q, want %q", info.Comment, "Example agent") + } + if info.Program != "/usr/local/bin/agent" { + t.Errorf("Program = %q, want %q", info.Program, "/usr/local/bin/agent") + } + if !info.RunAtLoad { + t.Error("RunAtLoad = false, want true") + } + if !info.KeepAlive { + t.Error("KeepAlive = false, want true") + } + if info.StartInterval != 300 { + t.Errorf("StartInterval = %d, want 300", info.StartInterval) + } + if want := []string{"/usr/local/bin/agent", "--serve"}; !reflect.DeepEqual(info.ProgramArguments, want) { + t.Errorf("ProgramArguments = %v, want %v", info.ProgramArguments, want) + } + if want := []string{"/etc/agent.conf"}; !reflect.DeepEqual(info.WatchPaths, want) { + t.Errorf("WatchPaths = %v, want %v", info.WatchPaths, want) + } + if want := []string{"/var/spool/agent"}; !reflect.DeepEqual(info.QueueDirectories, want) { + t.Errorf("QueueDirectories = %v, want %v", info.QueueDirectories, want) + } +} + +func TestParsePlistXMLStartCalendarIntervalDict(t *testing.T) { + t.Parallel() + + const data = ` + + + Label + com.example.cron + StartCalendarInterval + + Weekday + 1 + Hour + 9 + Minute + 30 + + +` + + info := &LaunchdInfo{} + if err := parsePlistXML([]byte(data), info); err != nil { + t.Fatalf("parsePlistXML returned error: %v", err) + } + if info.Label != "com.example.cron" { + t.Errorf("Label = %q, want %q", info.Label, "com.example.cron") + } + if want := "Mon at 09:30"; info.StartCalendarInterval != want { + t.Errorf("StartCalendarInterval = %q, want %q", info.StartCalendarInterval, want) + } +} + +func TestParsePlistXMLStartCalendarIntervalArray(t *testing.T) { + t.Parallel() + + const data = ` + + + Label + com.example.multi + StartCalendarInterval + + + Hour + 8 + + + Hour + 20 + + + +` + + info := &LaunchdInfo{} + if err := parsePlistXML([]byte(data), info); err != nil { + t.Fatalf("parsePlistXML returned error: %v", err) + } + if want := "at 08:00; at 20:00"; info.StartCalendarInterval != want { + t.Errorf("StartCalendarInterval = %q, want %q", info.StartCalendarInterval, want) + } +} + +func TestFormatCalendarInterval(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cal map[string]int + want string + }{ + {"empty", map[string]int{}, ""}, + {"hour and minute", map[string]int{"Hour": 9, "Minute": 30}, "at 09:30"}, + {"hour only", map[string]int{"Hour": 14}, "at 14:00"}, + {"minute only", map[string]int{"Minute": 5}, "at *:05"}, + {"weekday only", map[string]int{"Weekday": 1}, "Mon"}, + {"weekday with time", map[string]int{"Weekday": 0, "Hour": 8, "Minute": 0}, "Sun at 08:00"}, + {"month and day", map[string]int{"Month": 6, "Day": 15}, "month 6 day 15"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := formatCalendarInterval(tc.cal); got != tc.want { + t.Errorf("formatCalendarInterval(%v) = %q, want %q", tc.cal, got, tc.want) + } + }) + } +} + +func TestFormatDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + seconds int + want string + }{ + {30, "30s"}, + {90, "1m"}, + {300, "5m"}, + {3600, "1h"}, + {7200, "2h"}, + {86400, "1d"}, + } + + for _, tc := range tests { + if got := formatDuration(tc.seconds); got != tc.want { + t.Errorf("formatDuration(%d) = %q, want %q", tc.seconds, got, tc.want) + } + } +} + +func TestFormatTriggers(t *testing.T) { + t.Parallel() + + info := &LaunchdInfo{ + RunAtLoad: true, + StartInterval: 300, + StartCalendarInterval: "Mon at 09:30", + WatchPaths: []string{"/etc/foo"}, + QueueDirectories: []string{"/var/q"}, + } + want := []string{ + "RunAtLoad (starts at login/boot)", + "StartInterval (every 5m)", + "StartCalendarInterval (Mon at 09:30)", + "WatchPaths: /etc/foo", + "QueueDirectories: /var/q", + } + if got := info.FormatTriggers(); !reflect.DeepEqual(got, want) { + t.Errorf("FormatTriggers() = %v, want %v", got, want) + } + + if got := (&LaunchdInfo{}).FormatTriggers(); len(got) != 0 { + t.Errorf("FormatTriggers() on empty info = %v, want no triggers", got) + } +} + +func TestDomainDescription(t *testing.T) { + t.Parallel() + + tests := []struct { + domain string + want string + }{ + {"system", "Launch Daemon"}, + {"gui/501", "Launch Agent"}, + {"user", "Launch Agent"}, + {"", "launchd service"}, + } + + for _, tc := range tests { + info := &LaunchdInfo{Domain: tc.domain} + if got := info.DomainDescription(); got != tc.want { + t.Errorf("DomainDescription(%q) = %q, want %q", tc.domain, got, tc.want) + } + } +} diff --git a/internal/output/children.go b/internal/output/children.go new file mode 100644 index 0000000..3b4a19e --- /dev/null +++ b/internal/output/children.go @@ -0,0 +1,68 @@ +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) + } + } +} diff --git a/internal/output/children_test.go b/internal/output/children_test.go new file mode 100644 index 0000000..3545d7b --- /dev/null +++ b/internal/output/children_test.go @@ -0,0 +1,58 @@ +package output + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestPrintChildren(t *testing.T) { + root := model.Process{PID: 1, Command: "parent"} + + t.Run("no children", func(t *testing.T) { + for _, color := range []bool{false, true} { + var buf bytes.Buffer + PrintChildren(&buf, root, nil, color) + if !strings.Contains(buf.String(), "No child processes found.") { + t.Errorf("color=%v: missing empty-state message", color) + } + } + }) + + t.Run("with children", func(t *testing.T) { + kids := []model.Process{{PID: 2, Command: "a"}, {PID: 3, Command: "b"}} + var plain bytes.Buffer + PrintChildren(&plain, root, kids, false) + if !strings.Contains(plain.String(), "Children of parent") || !strings.Contains(plain.String(), "(pid 2)") { + t.Errorf("plain children render wrong:\n%s", plain.String()) + } + var colored bytes.Buffer + PrintChildren(&colored, root, kids, true) + if !strings.Contains(colored.String(), "parent") || !strings.Contains(colored.String(), "pid 3") { + t.Errorf("colored children render wrong:\n%s", colored.String()) + } + }) + + t.Run("truncates beyond 10", func(t *testing.T) { + many := make([]model.Process, 15) + for i := range many { + many[i] = model.Process{PID: i + 10, Command: fmt.Sprintf("c%d", i)} + } + var buf bytes.Buffer + PrintChildren(&buf, root, many, false) + if !strings.Contains(buf.String(), "and 5 more") { + t.Errorf("expected truncation note; got:\n%s", buf.String()) + } + }) + + t.Run("falls back to unknown name", func(t *testing.T) { + var buf bytes.Buffer + PrintChildren(&buf, model.Process{PID: 9}, []model.Process{{PID: 10}}, false) + if !strings.Contains(buf.String(), "unknown") { + t.Errorf("expected unknown-name fallback; got:\n%s", buf.String()) + } + }) +} diff --git a/internal/output/colors.go b/internal/output/colors.go new file mode 100644 index 0000000..901a7b9 --- /dev/null +++ b/internal/output/colors.go @@ -0,0 +1,13 @@ +package output + +// Bright ANSI codes (90-97) replace standard (30-37) for contrast on dark themes. +var ( + ColorReset = ansiString("\033[0m") + ColorRed = ansiString("\033[91m") + ColorGreen = ansiString("\033[92m") + ColorBlue = ansiString("\033[94m") + ColorCyan = ansiString("\033[96m") + ColorMagenta = ansiString("\033[95m") + ColorDim = ansiString("\033[90m") + ColorDimYellow = ansiString("\033[93m") +) diff --git a/internal/output/docker.go b/internal/output/docker.go new file mode 100644 index 0000000..a2bf17e --- /dev/null +++ b/internal/output/docker.go @@ -0,0 +1,351 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func containerSourceLabel(match *model.ContainerMatch) string { + if match.Runtime == "docker" && match.ComposeProject != "" && match.ComposeService != "" { + return fmt.Sprintf("docker-compose: %s/%s", match.ComposeProject, match.ComposeService) + } + if match.Runtime != "" { + return match.Runtime + } + return "container" +} + +// containerChain returns the conceptual ancestry segments for a container: +// runtime → [compose project] → container. +func containerChain(match *model.ContainerMatch) []string { + runtime := match.Runtime + if runtime == "" { + runtime = "container" + } + segs := []string{runtime} + if match.ComposeProject != "" { + segs = append(segs, match.ComposeProject+" (docker-compose)") + } + segs = append(segs, match.Name) + return segs +} + +// containerStateTag returns the bracketed suffix for the Container line — +// mirrors the [zombie] / [stopped] convention used for processes. Returns "" +// when the container is healthy and running (no need to clutter the line). +func containerStateTag(match *model.ContainerMatch) string { + state := strings.ToLower(match.State) + health := strings.ToLower(match.Health) + + if health != "" && health != "healthy" { + return health + } + if health == "healthy" { + return "healthy" + } + if state != "" && state != "running" { + return state + } + return "" +} + +func ShortContainerID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + +// FormatContainerLine produces the value rendered after "Container : ", in +// the same shape used by both the fallback render and the standard process +// render for `-c` targets: ": (id ) [state-or-health]". +// Empty tags are omitted. +func FormatContainerLine(match *model.ContainerMatch) string { + if match == nil { + return "" + } + parts := match.Name + if match.Runtime != "" { + parts = match.Runtime + ": " + parts + } + if id := ShortContainerID(match.ID); id != "" { + parts += " (id " + id + ")" + } + if tag := containerStateTag(match); tag != "" { + parts += " [" + tag + "]" + } + return parts +} + +func RenderContainerFallback(w io.Writer, targetLabel string, match *model.ContainerMatch, colorEnabled bool, verbose bool) { + out := NewPrinter(w) + + name := SanitizeTerminalLine(match.Name) + image := SanitizeTerminalLine(match.Image) + command := SanitizeTerminal(match.Command) + id := SanitizeTerminal(ShortContainerID(match.ID)) + stateTag := SanitizeTerminal(containerStateTag(match)) + networks := SanitizeTerminal(match.Networks) + ports := SanitizeTerminal(match.Ports) + + if colorEnabled { + out.Printf("%sTarget%s : %s\n\n", ColorBlue, ColorReset, targetLabel) + } else { + out.Printf("Target : %s\n\n", targetLabel) + } + + // Container line: name (id ) [state-or-health] + if colorEnabled { + out.Printf("%sContainer%s : %s%s%s (%sid %s%s)", + ColorBlue, ColorReset, ColorGreen, ansiString(name), ColorReset, + ColorDim, id, ColorReset) + } else { + out.Printf("Container : %s (id %s)", name, id) + } + if stateTag != "" { + color := ColorRed + if stateTag == "healthy" { + color = ColorGreen + } + if colorEnabled { + out.Printf(" %s[%s]%s", color, stateTag, ColorReset) + } else { + out.Printf(" [%s]", stateTag) + } + } + out.Println("") + + if image != "" { + if colorEnabled { + out.Printf("%sImage%s : %s\n", ColorBlue, ColorReset, ansiString(image)) + } else { + out.Printf("Image : %s\n", image) + } + } + + if command != "" { + if colorEnabled { + out.Printf("%sCommand%s : %s\n", ColorBlue, ColorReset, command) + } else { + out.Printf("Command : %s\n", command) + } + } + + if !match.StartedAt.IsZero() { + rel, dtStr := FormatStartedAt(match.StartedAt) + if colorEnabled { + out.Printf("%sStarted%s : %s (%s)\n", ColorMagenta, ColorReset, rel, dtStr) + } else { + out.Printf("Started : %s (%s)\n", rel, dtStr) + } + } + if !match.CreatedAt.IsZero() && (match.StartedAt.IsZero() || !match.CreatedAt.Equal(match.StartedAt)) { + _, dtStr := FormatStartedAt(match.CreatedAt) + if colorEnabled { + out.Printf("%sCreated%s : %s\n", ColorBlue, ColorReset, dtStr) + } else { + out.Printf("Created : %s\n", dtStr) + } + } + + if networks != "" { + if colorEnabled { + out.Printf("%sNetwork%s : %s\n", ColorBlue, ColorReset, networks) + } else { + out.Printf("Network : %s\n", networks) + } + } + + if colorEnabled { + out.Printf("\n%sWhy It Exists%s :\n ", ColorMagenta, ColorReset) + } else { + out.Printf("\nWhy It Exists :\n ") + } + writeContainerChainInline(out, containerChain(match), colorEnabled) + out.Println("") + + sourceLabel := containerSourceLabel(match) + if colorEnabled { + out.Printf("\n%sSource%s : %s\n", ColorCyan, ColorReset, sourceLabel) + } else { + out.Printf("\nSource : %s\n", sourceLabel) + } + + if ports != "" { + printContainerSockets(out, match.Ports, colorEnabled) + } + + if verbose { + mounts := SanitizeTerminal(match.Mounts) + if mounts != "" { + if colorEnabled { + out.Printf("\n%sMounts%s : %s\n", ColorBlue, ColorReset, mounts) + } else { + out.Printf("\nMounts : %s\n", mounts) + } + } + if match.ComposeConfigFile != "" { + if colorEnabled { + out.Printf("%sCompose File%s: %s\n", ColorBlue, ColorReset, SanitizeTerminal(match.ComposeConfigFile)) + } else { + out.Printf("Compose File: %s\n", SanitizeTerminal(match.ComposeConfigFile)) + } + } + if match.ComposeWorkingDir != "" { + if colorEnabled { + out.Printf("%sCompose Dir%s : %s\n", ColorBlue, ColorReset, SanitizeTerminal(match.ComposeWorkingDir)) + } else { + out.Printf("Compose Dir : %s\n", SanitizeTerminal(match.ComposeWorkingDir)) + } + } + } + + if colorEnabled { + out.Printf("\n%sNote%s : The owning process is not visible in this environment.\n", ColorDimYellow, ColorReset) + } else { + out.Printf("\nNote : The owning process is not visible in this environment.\n") + } +} + +// printContainerSockets parses the comma-separated docker port mapping string +// into per-row entries under the standard Sockets label. +func printContainerSockets(out Printer, ports string, colorEnabled bool) { + entries := strings.Split(ports, ", ") + for i, e := range entries { + safe := SanitizeTerminal(strings.TrimSpace(e)) + switch { + case i == 0 && colorEnabled: + out.Printf("%sSockets%s : %s\n", ColorGreen, ColorReset, safe) + case i == 0: + out.Printf("Sockets : %s\n", safe) + default: + out.Printf(" %s\n", safe) + } + } +} + +func RenderContainerFallbackShort(w io.Writer, _ string, match *model.ContainerMatch, colorEnabled bool) { + out := NewPrinter(w) + writeContainerChainInline(out, containerChain(match), colorEnabled) + out.Println("") +} + +func RenderContainerFallbackTree(w io.Writer, match *model.ContainerMatch, colorEnabled bool) { + out := NewPrinter(w) + segs := containerChain(match) + for i, s := range segs { + s = SanitizeTerminal(s) + indent := strings.Repeat(" ", i) + switch { + case i == 0 && colorEnabled && len(segs) == 1: + out.Printf("%s%s%s\n", ColorGreen, s, ColorReset) + case i == 0: + out.Printf("%s\n", s) + case i == len(segs)-1 && colorEnabled: + out.Printf("%s%s└─ %s%s%s\n", indent, ColorMagenta, ColorGreen, s, ColorReset) + case colorEnabled: + out.Printf("%s%s└─ %s%s\n", indent, ColorMagenta, ColorReset, s) + default: + out.Printf("%s└─ %s\n", indent, s) + } + } +} + +func RenderContainerFallbackWarnings(w io.Writer, match *model.ContainerMatch, colorEnabled bool) { + out := NewPrinter(w) + name := SanitizeTerminalLine(match.Name) + if colorEnabled { + out.Printf("%sContainer%s : %s%s%s\n", ColorBlue, ColorReset, ColorGreen, ansiString(name), ColorReset) + out.Printf("%sWarnings%s : %sNo warnings (workload process not visible).%s\n", ColorRed, ColorReset, ColorGreen, ColorReset) + } else { + out.Printf("Container : %s\n", name) + out.Println("Warnings : No warnings (workload process not visible).") + } +} + +func writeContainerChainInline(out Printer, segs []string, colorEnabled bool) { + for i, s := range segs { + s = SanitizeTerminal(s) + if i > 0 { + if colorEnabled { + out.Printf(" %s→%s ", ColorMagenta, ColorReset) + } else { + out.Print(" → ") + } + } + if i == len(segs)-1 && colorEnabled { + out.Printf("%s%s%s", ColorGreen, s, ColorReset) + } else { + out.Print(s) + } + } +} + +func ContainerFallbackToJSON(targetLabel string, match *model.ContainerMatch) (string, error) { + type containerResult struct { + Target string + Runtime string + ContainerID string + ContainerName string + Image string + Command string `json:",omitempty"` + State string `json:",omitempty"` + Status string `json:",omitempty"` + Health string `json:",omitempty"` + CreatedAt string `json:",omitempty"` + StartedAt string `json:",omitempty"` + Networks string `json:",omitempty"` + Mounts string `json:",omitempty"` + Ports string `json:",omitempty"` + ComposeProject string `json:",omitempty"` + ComposeService string `json:",omitempty"` + ComposeConfigFile string `json:",omitempty"` + ComposeWorkingDir string `json:",omitempty"` + Source string + Chain []string + Note string + } + + created := "" + if !match.CreatedAt.IsZero() { + created = match.CreatedAt.Format("Mon 2006-01-02 15:04:05 -07:00") + } + started := "" + if !match.StartedAt.IsZero() { + started = match.StartedAt.Format("Mon 2006-01-02 15:04:05 -07:00") + } + + res := containerResult{ + Target: targetLabel, + Runtime: match.Runtime, + ContainerID: match.ID, + ContainerName: match.Name, + Image: match.Image, + Command: match.Command, + State: match.State, + Status: match.Status, + Health: match.Health, + CreatedAt: created, + StartedAt: started, + Networks: match.Networks, + Mounts: match.Mounts, + Ports: match.Ports, + ComposeProject: match.ComposeProject, + ComposeService: match.ComposeService, + ComposeConfigFile: match.ComposeConfigFile, + ComposeWorkingDir: match.ComposeWorkingDir, + Source: containerSourceLabel(match), + Chain: containerChain(match), + Note: "The owning process is not visible in this environment. This is common when the runtime runs in a separate namespace (e.g., Docker Desktop, WSL2 distro, macOS VM).", + } + + data, err := json.MarshalIndent(res, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/output/docker_colored_test.go b/internal/output/docker_colored_test.go new file mode 100644 index 0000000..533ca80 --- /dev/null +++ b/internal/output/docker_colored_test.go @@ -0,0 +1,41 @@ +package output + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestRenderContainerFallbackColoredVerbose(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123def456", + Name: "web", + Image: "nginx:latest", + Command: "nginx -g daemon off", + Networks: "bridge", + Ports: "0.0.0.0:8080->80/tcp", + StartedAt: time.Now().Add(-10 * time.Minute), + CreatedAt: time.Now().Add(-20 * time.Minute), + Mounts: "/host:/container", + ComposeConfigFile: "/app/docker-compose.yml", + ComposeWorkingDir: "/app", + } + + var buf bytes.Buffer + RenderContainerFallback(&buf, "port 8080", match, true, true) // colored + verbose + out := buf.String() + + for _, want := range []string{ + "web", "nginx:latest", "nginx -g daemon", "Started", "Created", + "Network", "bridge", "Why It Exists", "Source", "Mounts", "/host:/container", + "Compose File", "Compose Dir", "Note", + } { + if !strings.Contains(out, want) { + t.Errorf("colored+verbose container fallback missing %q\n---\n%s", want, out) + } + } +} diff --git a/internal/output/docker_test.go b/internal/output/docker_test.go new file mode 100644 index 0000000..66611c7 --- /dev/null +++ b/internal/output/docker_test.go @@ -0,0 +1,235 @@ +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestRenderContainerFallback(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123", + Name: "my-container", + Image: "nginx:latest", + Ports: "0.0.0.0:8080->80/tcp", + } + + var buf bytes.Buffer + RenderContainerFallback(&buf, "port 8080", match, false, false) + out := buf.String() + + expected := []string{ + "Target : port 8080", + "Container : my-container (id abc123)", + "Image : nginx:latest", + "Sockets : 0.0.0.0:8080->80/tcp", + "Why It Exists", + "Source : docker", + "Note", + } + for _, want := range expected { + if !strings.Contains(out, want) { + t.Errorf("RenderContainerFallback output missing %q\nGot:\n%s", want, out) + } + } +} + +func TestRenderContainerFallbackWithCompose(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + 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 + RenderContainerFallback(&buf, "port 5432", match, false, false) + out := buf.String() + + if !strings.Contains(out, "docker-compose: myapp/db") { + t.Errorf("expected compose source label, got:\n%s", out) + } +} + +func TestRenderContainerFallbackRuntimeLabel(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "podman", + ID: "def456", + Name: "rootless", + Image: "alpine:3", + } + + var buf bytes.Buffer + RenderContainerFallback(&buf, "container rootless", match, false, false) + out := buf.String() + + if !strings.Contains(out, "Source : podman") { + t.Errorf("expected podman source label, got:\n%s", out) + } +} + +func TestRenderContainerFallbackShort(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123", + Name: "my-container", + Image: "nginx:latest", + Ports: "0.0.0.0:8080->80/tcp", + } + + var buf bytes.Buffer + RenderContainerFallbackShort(&buf, "port 8080", match, false) + out := buf.String() + + want := "docker → my-container" + if !strings.Contains(out, want) { + t.Errorf("short output missing %q, got: %s", want, out) + } + if strings.Count(out, "\n") != 1 { + t.Errorf("short output should be single line, got: %s", out) + } +} + +func TestRenderContainerFallbackShortWithCompose(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123", + Name: "redis", + Image: "redis:7-alpine", + ComposeProject: "myapp", + ComposeService: "redis", + } + + var buf bytes.Buffer + RenderContainerFallbackShort(&buf, "container redis", match, false) + out := buf.String() + + want := "docker → myapp (docker-compose) → redis" + if !strings.Contains(out, want) { + t.Errorf("short output missing chain %q, got: %s", want, out) + } +} + +func TestRenderContainerFallbackTree(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123", + Name: "redis", + Image: "redis:7-alpine", + ComposeProject: "myapp", + ComposeService: "redis", + } + + var buf bytes.Buffer + RenderContainerFallbackTree(&buf, match, false) + out := buf.String() + + for _, want := range []string{"docker\n", "└─ myapp (docker-compose)", "└─ redis"} { + if !strings.Contains(out, want) { + t.Errorf("tree output missing %q, got:\n%s", want, out) + } + } +} + +func TestRenderContainerFallbackWarnings(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123", + Name: "redis", + Image: "redis:7-alpine", + } + + var buf bytes.Buffer + RenderContainerFallbackWarnings(&buf, match, false) + out := buf.String() + + for _, want := range []string{"Container : redis", "No warnings"} { + if !strings.Contains(out, want) { + t.Errorf("warnings output missing %q, got: %s", want, out) + } + } +} + +func TestContainerFallbackToJSON(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + 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", + } + + jsonStr, err := ContainerFallbackToJSON("port 5432", match) + if err != nil { + t.Fatalf("ContainerFallbackToJSON() 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["Runtime"] != "docker" { + t.Errorf("Runtime = %v, want %q", result["Runtime"], "docker") + } + if result["Source"] != "docker" { + t.Errorf("Source = %v, want %q", result["Source"], "docker") + } +} + +func TestContainerFallbackToJSONCompose(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "def456", + Name: "myapp-db-1", + Image: "postgres:16", + Ports: "0.0.0.0:5432->5432/tcp", + ComposeProject: "myapp", + ComposeService: "db", + } + + jsonStr, err := ContainerFallbackToJSON("port 5432", match) + if err != nil { + t.Fatalf("ContainerFallbackToJSON() 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 TestRenderContainerFallbackSanitizesOutput(t *testing.T) { + match := &model.ContainerMatch{ + Runtime: "docker", + ID: "abc123", + Name: "evil\x1b[31mcontainer", + Image: "evil\x1b[0mimage", + Ports: "0.0.0.0:80->80/tcp", + } + + var buf bytes.Buffer + RenderContainerFallback(&buf, "port 80", match, false, false) + out := buf.String() + + if strings.Contains(out, "\x1b") { + t.Errorf("output contains raw ANSI escape sequences, sanitization failed:\n%s", out) + } +} diff --git a/internal/output/envonly.go b/internal/output/envonly.go new file mode 100644 index 0000000..551f428 --- /dev/null +++ b/internal/output/envonly.go @@ -0,0 +1,46 @@ +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", colorBlueEnv, 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) + } +} diff --git a/internal/output/envonly_test.go b/internal/output/envonly_test.go new file mode 100644 index 0000000..e7b835f --- /dev/null +++ b/internal/output/envonly_test.go @@ -0,0 +1,39 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestRenderEnvOnly(t *testing.T) { + r := model.Result{ + Process: model.Process{PID: 1234, Command: "nginx", Cmdline: "/usr/sbin/nginx", Env: []string{"PATH=/usr/bin", "HOME=/root"}}, + Ancestry: []model.Process{{PID: 1, Command: "systemd"}, {PID: 1234, Command: "nginx"}}, + } + + t.Run("with env (plain and colored)", func(t *testing.T) { + var plain bytes.Buffer + RenderEnvOnly(&plain, r, false) + if !strings.Contains(plain.String(), "Environment") || !strings.Contains(plain.String(), "PATH=/usr/bin") { + t.Errorf("plain env render wrong:\n%s", plain.String()) + } + var colored bytes.Buffer + RenderEnvOnly(&colored, r, true) + if !strings.Contains(colored.String(), "HOME=/root") { + t.Errorf("colored env render missing a variable:\n%s", colored.String()) + } + }) + + t.Run("no env", func(t *testing.T) { + r2 := r + r2.Process.Env = nil + var buf bytes.Buffer + RenderEnvOnly(&buf, r2, false) + if !strings.Contains(buf.String(), "No environment variables found.") { + t.Errorf("expected empty-env message; got:\n%s", buf.String()) + } + }) +} diff --git a/internal/output/extra_test.go b/internal/output/extra_test.go new file mode 100644 index 0000000..a171c44 --- /dev/null +++ b/internal/output/extra_test.go @@ -0,0 +1,55 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestRenderWarnings(t *testing.T) { + r := model.Result{ + Process: model.Process{PID: 1234, Command: "nginx"}, + Ancestry: []model.Process{{PID: 1, Command: "systemd"}, {PID: 1234, Command: "nginx"}}, + Warnings: []string{"Process is running as root"}, + } + + for _, color := range []bool{false, true} { + var buf bytes.Buffer + RenderWarnings(&buf, r, color) + out := buf.String() + if !strings.Contains(out, "Warnings") || !strings.Contains(out, "running as root") { + t.Errorf("color=%v: warnings-only render wrong:\n%s", color, out) + } + } + + t.Run("no warnings", func(t *testing.T) { + r2 := r + r2.Warnings = nil + var buf bytes.Buffer + RenderWarnings(&buf, r2, false) + if !strings.Contains(buf.String(), "No warnings.") { + t.Errorf("expected the no-warnings message; got:\n%s", buf.String()) + } + }) +} + +func TestFormatContainerLine(t *testing.T) { + match := &model.ContainerMatch{Runtime: "docker", ID: "abc123def456", Name: "web", Image: "nginx:latest"} + line := FormatContainerLine(match) + if line == "" || !strings.Contains(line, "web") { + t.Errorf("FormatContainerLine = %q, want a non-empty line naming the container", line) + } +} + +func TestFormatDetailLabel(t *testing.T) { + // A known key maps to its padded display label. + if got := formatDetailLabel("type"); !strings.Contains(got, "Type") { + t.Errorf("formatDetailLabel(\"type\") = %q, want it to contain \"Type\"", got) + } + // An unknown key is padded and passed through verbatim. + if got := formatDetailLabel("custom-key"); !strings.Contains(got, "custom-key") { + t.Errorf("formatDetailLabel(\"custom-key\") = %q, want it to contain the key", got) + } +} diff --git a/internal/output/format_test.go b/internal/output/format_test.go new file mode 100644 index 0000000..ee33653 --- /dev/null +++ b/internal/output/format_test.go @@ -0,0 +1,19 @@ +package output + +import "testing" + +func TestFormatBytes(t *testing.T) { + cases := map[uint64]string{ + 0: "0 B", + 512: "512 B", + 1024: "1.0 KB", + 202006528: "192.6 MB", + 1610612736: "1.5 GB", + 109951162777600: "100.0 TB", + } + for n, want := range cases { + if got := formatBytes(n); got != want { + t.Errorf("formatBytes(%d) = %q, want %q", n, got, want) + } + } +} diff --git a/internal/output/json.go b/internal/output/json.go new file mode 100644 index 0000000..bef8f09 --- /dev/null +++ b/internal/output/json.go @@ -0,0 +1,128 @@ +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 +} diff --git a/internal/output/json_test.go b/internal/output/json_test.go new file mode 100644 index 0000000..901cfab --- /dev/null +++ b/internal/output/json_test.go @@ -0,0 +1,179 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func jsonFixture() model.Result { + target := model.Process{ + PID: 1234, + Command: "nginx", + Cmdline: "/usr/sbin/nginx -g daemon off;", + Env: []string{"FOO=bar", "PATH=/usr/bin"}, + } + return model.Result{ + Target: model.Target{Type: model.TargetName, Value: "nginx"}, + Process: target, + Ancestry: []model.Process{{PID: 1, Command: "systemd"}, target}, + Children: []model.Process{{PID: 5678, Command: "worker"}}, + Source: model.Source{Type: model.SourceSystemd, Name: "nginx.service"}, + Warnings: []string{"Process is listening on a public interface"}, + } +} + +// TestToJSONIsValidAndIncludesKeyFields verifies the full result serializes +// cleanly and includes the top-level fields downstream consumers depend on. +func TestToJSONIsValidAndIncludesKeyFields(t *testing.T) { + t.Parallel() + + s, err := ToJSON(jsonFixture()) + if err != nil { + t.Fatalf("ToJSON returned error: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal([]byte(s), &decoded); err != nil { + t.Fatalf("ToJSON output is not valid JSON: %v\n%s", err, s) + } + + for _, key := range []string{"Target", "Process", "Ancestry", "Source", "Warnings"} { + if _, ok := decoded[key]; !ok { + t.Errorf("ToJSON output missing top-level field %q", key) + } + } +} + +func TestToShortJSON(t *testing.T) { + t.Parallel() + + s, err := ToShortJSON(jsonFixture()) + if err != nil { + t.Fatalf("ToShortJSON: %v", err) + } + + var got []struct { + PID int + Command string + } + if err := json.Unmarshal([]byte(s), &got); err != nil { + t.Fatalf("ToShortJSON not parseable as []shortProcess: %v\n%s", err, s) + } + if len(got) != 2 { + t.Fatalf("ToShortJSON length = %d, want 2", len(got)) + } + if got[0].Command != "systemd" || got[1].Command != "nginx" { + t.Errorf("ToShortJSON unexpected ancestry: %+v", got) + } +} + +func TestToTreeJSONIncludesChildren(t *testing.T) { + t.Parallel() + + s, err := ToTreeJSON(jsonFixture()) + if err != nil { + t.Fatalf("ToTreeJSON: %v", err) + } + + var got struct { + Ancestry []struct{ PID int } + Children []struct { + PID int + Command string + } + } + if err := json.Unmarshal([]byte(s), &got); err != nil { + t.Fatalf("ToTreeJSON not parseable: %v\n%s", err, s) + } + if len(got.Children) != 1 || got.Children[0].PID != 5678 || got.Children[0].Command != "worker" { + t.Errorf("ToTreeJSON children = %+v, want one worker pid 5678", got.Children) + } +} + +// TestToTreeJSONOmitsEmptyChildren pins the `omitempty` contract — callers +// scripting against the JSON shouldn't need to special-case "Children":[]. +func TestToTreeJSONOmitsEmptyChildren(t *testing.T) { + t.Parallel() + + fx := jsonFixture() + fx.Children = nil + + s, err := ToTreeJSON(fx) + if err != nil { + t.Fatalf("ToTreeJSON: %v", err) + } + if strings.Contains(s, `"Children"`) { + t.Errorf("ToTreeJSON should omit Children when empty; got:\n%s", s) + } +} + +func TestToWarningsJSON(t *testing.T) { + t.Parallel() + + s, err := ToWarningsJSON(jsonFixture()) + if err != nil { + t.Fatalf("ToWarningsJSON: %v", err) + } + + var got struct { + PID int + Process string + Command string + Warnings []string + } + if err := json.Unmarshal([]byte(s), &got); err != nil { + t.Fatalf("ToWarningsJSON not parseable: %v\n%s", err, s) + } + if got.PID != 1234 || got.Process != "nginx" { + t.Errorf("ToWarningsJSON identity wrong: %+v", got) + } + if len(got.Warnings) != 1 || !strings.Contains(got.Warnings[0], "public interface") { + t.Errorf("ToWarningsJSON warnings = %v", got.Warnings) + } +} + +// TestToWarningsJSONNilWarningsBecomesEmptyArray ensures downstream +// consumers always see [] and never null — important for jq scripts that do +// `.Warnings | length`. +func TestToWarningsJSONNilWarningsBecomesEmptyArray(t *testing.T) { + t.Parallel() + + fx := jsonFixture() + fx.Warnings = nil + + s, err := ToWarningsJSON(fx) + if err != nil { + t.Fatalf("ToWarningsJSON: %v", err) + } + if !strings.Contains(s, `"Warnings": []`) { + t.Errorf("ToWarningsJSON should emit Warnings:[] when nil; got:\n%s", s) + } +} + +func TestToEnvJSON(t *testing.T) { + t.Parallel() + + s, err := ToEnvJSON(jsonFixture()) + if err != nil { + t.Fatalf("ToEnvJSON: %v", err) + } + + var got struct { + PID int + Process string + Command string + Env []string + } + if err := json.Unmarshal([]byte(s), &got); err != nil { + t.Fatalf("ToEnvJSON not parseable: %v\n%s", err, s) + } + if got.PID != 1234 { + t.Errorf("ToEnvJSON PID = %d, want 1234", got.PID) + } + if len(got.Env) != 2 || got.Env[0] != "FOO=bar" { + t.Errorf("ToEnvJSON env = %v", got.Env) + } +} diff --git a/internal/output/printer.go b/internal/output/printer.go new file mode 100644 index 0000000..d1217b0 --- /dev/null +++ b/internal/output/printer.go @@ -0,0 +1,57 @@ +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). Sanitization +// escapes embedded newlines/tabs so an untrusted value can't forge extra +// layout lines; the tool's own layout newlines must therefore go through +// Printf's format string (which is not sanitized), never a Print/Println arg. +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] = SanitizeTerminalLine(v) + case []byte: + out[i] = SanitizeTerminalLine(string(v)) + case error: + out[i] = SanitizeTerminalLine(v.Error()) + case fmt.Stringer: + out[i] = SanitizeTerminalLine(v.String()) + default: + out[i] = a + } + } + return out +} diff --git a/internal/output/printer_test.go b/internal/output/printer_test.go new file mode 100644 index 0000000..ad24d99 --- /dev/null +++ b/internal/output/printer_test.go @@ -0,0 +1,55 @@ +package output + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +type stubStringer struct{ s string } + +func (s stubStringer) String() string { return s.s } + +// TestPrinterSanitizesArgTypes covers the per-type sanitization branches: the +// printer must scrub control characters from string, []byte, error, and +// fmt.Stringer arguments alike. +func TestPrinterSanitizesArgTypes(t *testing.T) { + var buf bytes.Buffer + p := NewPrinter(&buf) + + p.Printf("a=%s b=%s c=%s d=%s\n", + []byte("by\x07te"), + errors.New("er\x07ror"), + stubStringer{"str\x07ing"}, + "pl\x07ain", + ) + + out := buf.String() + if strings.ContainsRune(out, '\x07') { + t.Errorf("a raw control character survived sanitization: %q", out) + } + // Each of the four argument types should have had its BEL escaped to a + // literal "\x07", so the escape appears once per argument. + if got := strings.Count(out, `\x07`); got != 4 { + t.Errorf("expected 4 escaped control chars (one per arg type), got %d: %q", got, out) + } +} + +// TestPrinterEscapesNewlineInArg ensures a string argument can't smuggle a line +// break into the output: the only newline emitted must be the one from the +// constant format string, so an attacker-controlled field cannot forge a row. +func TestPrinterEscapesNewlineInArg(t *testing.T) { + var buf bytes.Buffer + p := NewPrinter(&buf) + + p.Printf("Command : %s\n", "real\nWarnings : none") + + out := buf.String() + if strings.Count(out, "\n") != 1 { + t.Errorf("arg newline was not escaped, output spans extra lines: %q", out) + } + if !strings.Contains(out, `real\nWarnings`) { + t.Errorf("expected the embedded newline to render as a literal \\n: %q", out) + } +} diff --git a/internal/output/safe_writer.go b/internal/output/safe_writer.go new file mode 100644 index 0000000..0e82050 --- /dev/null +++ b/internal/output/safe_writer.go @@ -0,0 +1,25 @@ +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} +} diff --git a/internal/output/safe_writer_test.go b/internal/output/safe_writer_test.go new file mode 100644 index 0000000..32c3597 --- /dev/null +++ b/internal/output/safe_writer_test.go @@ -0,0 +1,35 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestSafeTerminalWriter(t *testing.T) { + var buf bytes.Buffer + w := NewSafeTerminalWriter(&buf) + + // An empty write is a no-op. + if n, err := w.Write(nil); n != 0 || err != nil { + t.Errorf("empty write = (%d, %v), want (0, nil)", n, err) + } + + // Control characters are sanitized, but Write reports the original length so + // callers (e.g. io.Copy) see all their bytes consumed. + in := []byte("hi\x07there") + n, err := w.Write(in) + if err != nil { + t.Fatalf("Write: %v", err) + } + if n != len(in) { + t.Errorf("Write returned n=%d, want %d (the original byte count)", n, len(in)) + } + out := buf.String() + if strings.ContainsRune(out, '\x07') { + t.Errorf("BEL should have been sanitized; got %q", out) + } + if !strings.Contains(out, "hi") || !strings.Contains(out, "there") { + t.Errorf("surrounding text should survive sanitizing; got %q", out) + } +} diff --git a/internal/output/sanitize.go b/internal/output/sanitize.go new file mode 100644 index 0000000..d2e0984 --- /dev/null +++ b/internal/output/sanitize.go @@ -0,0 +1,120 @@ +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() +} + +// SanitizeTerminalLine is SanitizeTerminal for values rendered as a single line +// or table cell (process names, command lines, env entries, paths). +// SanitizeTerminal intentionally preserves \n and \t for multi-line layout; in a +// single-line field those let an embedded value forge extra output lines or +// shift columns, so they are escaped here too. +func SanitizeTerminalLine(s string) string { + if strings.ContainsAny(s, "\n\t") { + s = strings.NewReplacer("\n", `\n`, "\t", `\t`).Replace(s) + } + return SanitizeTerminal(s) +} + +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]) +} diff --git a/internal/output/sanitize_test.go b/internal/output/sanitize_test.go new file mode 100644 index 0000000..e9fe7a8 --- /dev/null +++ b/internal/output/sanitize_test.go @@ -0,0 +1,80 @@ +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) + } + } + }) +} + +// TestSanitizeTerminalEscapesControlBytes pins the human-readable contract: a +// raw ESC renders as the visible escape \x1b (a single backslash), with the +// surrounding printable bytes left intact. +func TestSanitizeTerminalEscapesControlBytes(t *testing.T) { + in := "a\x1b[31mb" + want := `a\x1b[31mb` + if got := SanitizeTerminal(in); got != want { + t.Fatalf("SanitizeTerminal(%q) = %q, want %q", in, got, want) + } +} + +// TestSanitizeTerminalLineEscapesLineBreakers verifies the single-line variant +// neutralizes the two characters SanitizeTerminal preserves (newline and tab), +// so an attacker-controlled field can't forge extra lines or shift columns, +// while still escaping ESC like the base sanitizer. +func TestSanitizeTerminalLineEscapesLineBreakers(t *testing.T) { + cases := map[string]string{ + "cmd\nWarnings : none": `cmd\nWarnings : none`, + "a\tb": `a\tb`, + "x\x1b[2Jy": `x\x1b[2Jy`, + "plain": "plain", + } + for in, want := range cases { + if got := SanitizeTerminalLine(in); got != want { + t.Errorf("SanitizeTerminalLine(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/output/short.go b/internal/output/short.go new file mode 100644 index 0000000..a71cae3 --- /dev/null +++ b/internal/output/short.go @@ -0,0 +1,46 @@ +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, ChainName(proc), ColorReset, ColorDim, proc.PID, ColorReset) + } else { + p.Printf("%s (pid %d)", ChainName(proc), proc.PID) + } + } + p.Println() +} + +// ChainName returns a display name for an ancestry or child node, falling back +// to the command line and then a placeholder when the process name couldn't be +// read (e.g. a protected or already-exited Windows ancestor that exposes +// neither an image name nor a command line). +func ChainName(p model.Process) string { + if p.Command != "" { + return p.Command + } + if p.Cmdline != "" { + return p.Cmdline + } + return "(unknown)" +} diff --git a/internal/output/short_test.go b/internal/output/short_test.go new file mode 100644 index 0000000..39ccc4e --- /dev/null +++ b/internal/output/short_test.go @@ -0,0 +1,109 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// shortFixture exercises every branch of RenderShort: a first node (no +// separator), middle nodes (with arrow separator), and a final node (last +// element of the chain). +func shortFixture() model.Result { + return model.Result{ + Ancestry: []model.Process{ + {PID: 1, Command: "systemd"}, + {PID: 1234, Command: "bash"}, + {PID: 5678, Command: "nginx"}, + }, + } +} + +func TestRenderShort(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + RenderShort(&buf, shortFixture(), false) + got := strings.TrimSpace(buf.String()) + + want := "systemd (pid 1) → bash (pid 1234) → nginx (pid 5678)" + if got != want { + t.Errorf("RenderShort = %q, want %q", got, want) + } +} + +func TestRenderShortSingleProcess(t *testing.T) { + t.Parallel() + + r := model.Result{Ancestry: []model.Process{{PID: 1, Command: "init"}}} + + var buf bytes.Buffer + RenderShort(&buf, r, false) + got := strings.TrimSpace(buf.String()) + + want := "init (pid 1)" + if got != want { + t.Errorf("RenderShort single process = %q, want %q", got, want) + } +} + +// TestRenderShortUnknownAncestor covers the issue #205 case: a protected or +// already-exited ancestor (e.g. userinit.exe) exposes neither an image name nor +// a command line. It must render as "(unknown)" instead of a blank name. +func TestRenderShortUnknownAncestor(t *testing.T) { + t.Parallel() + + r := model.Result{ + Ancestry: []model.Process{ + {PID: 3540}, + {PID: 15116, Command: "Explorer.EXE"}, + {PID: 6324, Command: "Claude.exe"}, + }, + } + + var buf bytes.Buffer + RenderShort(&buf, r, false) + got := strings.TrimSpace(buf.String()) + + want := "(unknown) (pid 3540) → Explorer.EXE (pid 15116) → Claude.exe (pid 6324)" + if got != want { + t.Errorf("RenderShort with unreadable root = %q, want %q", got, want) + } +} + +func TestChainName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + p model.Process + want string + }{ + {"command present", model.Process{Command: "nginx"}, "nginx"}, + {"falls back to cmdline", model.Process{Cmdline: "/usr/bin/foo --bar"}, "/usr/bin/foo --bar"}, + {"unknown when both empty", model.Process{PID: 3540}, "(unknown)"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := ChainName(tt.p); got != tt.want { + t.Errorf("ChainName(%+v) = %q, want %q", tt.p, got, tt.want) + } + }) + } +} + +func TestRenderShortEmptyAncestry(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + RenderShort(&buf, model.Result{}, false) + + // Empty ancestry should produce just a newline, not panic or omit anything. + if got := buf.String(); got != "\n" { + t.Errorf("RenderShort with empty ancestry = %q, want a single newline", got) + } +} diff --git a/internal/output/standard.go b/internal/output/standard.go new file mode 100644 index 0000000..457f04d --- /dev/null +++ b/internal/output/standard.go @@ -0,0 +1,656 @@ +package output + +import ( + "fmt" + "io" + "net" + "sort" + "strconv" + "strings" + + "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) + } + } + rel, dtStr := FormatStartedAt(proc.StartedAt) + if colorEnabled { + out.Printf("%sStarted%s : %s (%s)\n", ColorMagenta, ColorReset, rel, dtStr) + } else { + out.Printf("Started : %s (%s)\n", rel, dtStr) + } + + // Restart count (sourced from systemd's NRestarts); shown only when the + // managing system has restarted the unit at least once. + if r.RestartCount > 0 { + if colorEnabled { + out.Printf("%sRestarts%s : %d\n", ColorMagenta, ColorReset, r.RestartCount) + } else { + out.Printf("Restarts : %d\n", r.RestartCount) + } + } + + 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 := SanitizeTerminal(ChainName(p)) + + 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(ansiString("\n\n")) + } else { + out.Printf("\nWhy It Exists :\n ") + for i, p := range r.Ancestry { + name := SanitizeTerminal(ChainName(p)) + out.Printf("%s (pid %d)", name, p.PID) + if i < len(r.Ancestry)-1 { + out.Printf(" \u2192 ") + } + } + out.Print(ansiString("\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) + } + } + } + + // Sockets section (address:port (proto | state)) + if len(proc.Sockets) > 0 { + visible := visibleSockets(proc.Sockets) + sortSockets(visible) + count := len(visible) + for i, s := range visible { + if i >= MaxDisplayItems { + out.Printf(" ... and %d more\n", count-i) + break + } + line := SanitizeTerminal(formatSocket(s)) + switch { + case i == 0 && colorEnabled: + out.Printf("%sSockets%s : %s\n", ColorGreen, ColorReset, line) + case i == 0: + out.Printf("Sockets : %s\n", line) + default: + out.Printf(" %s\n", line) + } + } + } + + // 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(ansiString("\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 : %s\n", formatBytes(proc.Memory.VMS)) + out.Printf(" Resident : %s\n", formatBytes(proc.Memory.RSS)) + if r.ResourceContext != nil && r.ResourceContext.MemoryUsage > 0 { + out.Printf(" Private : %s\n", formatBytes(r.ResourceContext.MemoryUsage)) + } + if proc.Memory.Shared > 0 { + out.Printf(" Shared : %s\n", formatBytes(proc.Memory.Shared)) + } + } else { + out.Printf("\nMemory:\n") + out.Printf(" Virtual : %s\n", formatBytes(proc.Memory.VMS)) + out.Printf(" Resident : %s\n", formatBytes(proc.Memory.RSS)) + if r.ResourceContext != nil && r.ResourceContext.MemoryUsage > 0 { + out.Printf(" Private : %s\n", formatBytes(r.ResourceContext.MemoryUsage)) + } + if proc.Memory.Shared > 0 { + out.Printf(" Shared : %s\n", formatBytes(proc.Memory.Shared)) + } + } + } + + // 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 : %s (%d ops)\n", formatBytes(proc.IO.ReadBytes), proc.IO.ReadOps) + } + if proc.IO.WriteBytes > 0 { + out.Printf(" Write : %s (%d ops)\n", formatBytes(proc.IO.WriteBytes), proc.IO.WriteOps) + } + } else { + out.Printf("\nI/O Statistics:\n") + if proc.IO.ReadBytes > 0 { + out.Printf(" Read : %s (%d ops)\n", formatBytes(proc.IO.ReadBytes), proc.IO.ReadOps) + } + if proc.IO.WriteBytes > 0 { + out.Printf(" Write : %s (%d ops)\n", formatBytes(proc.IO.WriteBytes), 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 := SanitizeTerminalLine(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 := SanitizeTerminalLine(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) + } + } +} + +// formatBytes renders a byte count with a binary unit (KB/MB/GB/...), keeping +// large values readable. Values below 1 KB are shown in bytes. +func formatBytes(n uint64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := uint64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// formatSocket renders one row of the Sockets section as +// "
: ( | )". +func formatSocket(s model.Socket) string { + addr := s.Address + hostPort := net.JoinHostPort(addr, strconv.Itoa(s.Port)) + proto := s.Protocol + if proto == "" { + proto = "?" + } + state := displayState(s.State) + return fmt.Sprintf("%s (%s | %s)", hostPort, proto, state) +} + +// displayState pretty-prints socket states. The kernel-style "LISTEN" reads +// awkwardly next to "ESTABLISHED" / "CLOSE_WAIT", so it's expanded here. +func displayState(state string) string { + switch state { + case "": + return "?" + case "LISTEN": + return "LISTENING" + default: + return state + } +} + +// socketSortRank orders sockets so listeners come first, then connected +// sockets, then everything else. Within a rank, the renderer further sorts by +// port for stable output. +func socketSortRank(state string) int { + switch state { + case "LISTEN": + return 0 + case "OPEN": // bound UDP — the UDP equivalent of LISTEN + return 1 + case "ESTABLISHED": + return 2 + default: + return 3 + } +} + +// visibleSockets returns sockets that have enough information to be rendered. +// Anything with a blank address or port-zero is silently dropped. +func visibleSockets(sockets []model.Socket) []model.Socket { + out := make([]model.Socket, 0, len(sockets)) + for _, s := range sockets { + if s.Address != "" && s.Port > 0 { + out = append(out, s) + } + } + return out +} + +// sortSockets orders sockets for the Sockets section in place: addresses +// grouped together, ports ascending within an address, LISTEN above +// ESTABLISHED when they share an address:port pair. +func sortSockets(sockets []model.Socket) { + sort.SliceStable(sockets, func(i, j int) bool { + a, b := sockets[i], sockets[j] + if a.Address != b.Address { + return a.Address < b.Address + } + if a.Port != b.Port { + return a.Port < b.Port + } + return socketSortRank(a.State) < socketSortRank(b.State) + }) +} diff --git a/internal/output/standard_snapshot_test.go b/internal/output/standard_snapshot_test.go new file mode 100644 index 0000000..7461476 --- /dev/null +++ b/internal/output/standard_snapshot_test.go @@ -0,0 +1,165 @@ +package output + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// fixedFixture returns a deterministic Result so RenderStandard's output can +// be asserted line-by-line. Timestamps are far enough in the past for the +// "Started" relative phrasing to be stable ("X days ago" branch). +func fixedFixture() model.Result { + startedAt := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + target := model.Process{ + PID: 1234, + PPID: 1, + Command: "nginx", + Cmdline: "/usr/sbin/nginx -g daemon off;", + User: "nginx", + StartedAt: startedAt, + Service: "nginx.service", + Sockets: []model.Socket{ + {Address: "192.168.1.5", Port: 80, Protocol: "TCP", State: "ESTABLISHED"}, + {Address: "0.0.0.0", Port: 443, Protocol: "TCP", State: "LISTEN"}, + {Address: "0.0.0.0", Port: 443, Protocol: "TCP", State: "ESTABLISHED"}, + {Address: "0.0.0.0", Port: 80, Protocol: "TCP", State: "LISTEN"}, + }, + Health: "healthy", + Forked: "not-forked", + } + return model.Result{ + Target: model.Target{Type: model.TargetName, Value: "nginx"}, + Process: target, + Ancestry: []model.Process{{PID: 1, Command: "systemd"}, target}, + Source: model.Source{Type: model.SourceSystemd, Name: "nginx.service"}, + } +} + +// Guards the regression where the tool's own layout newlines (e.g. the blank +// line before "Source") were passed as Printer args and escaped to a literal +// "\n". The fixture has no newline in any value, so any literal "\n" in the +// output is a layout newline that leaked. +func TestRenderStandardDoesNotEscapeLayoutNewlines(t *testing.T) { + for _, color := range []bool{false, true} { + var buf bytes.Buffer + RenderStandard(&buf, fixedFixture(), color, false) + if strings.Contains(buf.String(), `\n`) { + t.Errorf("color=%v: layout newline escaped to a literal \\n:\n%s", color, buf.String()) + } + } +} + +// TestRenderStandardContract pins down the structural contract of the +// standard output: which sections appear, their order, and the format of +// rows users (and scripts piping our output) depend on. We assert on +// substrings rather than exact byte-for-byte equality so the test doesn't +// break on cosmetic tweaks like extra blank lines or label padding. +func TestRenderStandardContract(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + RenderStandard(&buf, fixedFixture(), false, false) + out := buf.String() + + mustContain := []string{ + "Target : nginx", + "Process : nginx (pid 1234)", + "User : nginx", + "Service : nginx.service", + "Command : /usr/sbin/nginx -g daemon off;", + "Why It Exists :", + "systemd", + "nginx", + "Source : nginx.service (systemd)", + "Sockets :", + "0.0.0.0:80 (TCP | LISTENING)", + "0.0.0.0:443 (TCP | LISTENING)", + "0.0.0.0:443 (TCP | ESTABLISHED)", + "192.168.1.5:80 (TCP | ESTABLISHED)", + } + for _, s := range mustContain { + if !strings.Contains(out, s) { + t.Errorf("RenderStandard output missing %q\n---\n%s\n---", s, out) + } + } +} + +// TestRenderStandardSocketsOrder verifies the Sockets section actually emits +// rows in sortSockets order: addresses grouped, LISTEN above ESTABLISHED on +// shared address:port, ports ascending. This is the renderer-level mirror of +// TestSortSockets — it catches regressions where the comparator is correct +// but the renderer accidentally re-orders the slice afterwards. +func TestRenderStandardSocketsOrder(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + RenderStandard(&buf, fixedFixture(), false, false) + out := buf.String() + + rows := []string{ + "0.0.0.0:80 (TCP | LISTENING)", + "0.0.0.0:443 (TCP | LISTENING)", + "0.0.0.0:443 (TCP | ESTABLISHED)", + "192.168.1.5:80 (TCP | ESTABLISHED)", + } + last := -1 + for _, row := range rows { + idx := strings.Index(out, row) + if idx == -1 { + t.Fatalf("missing socket row %q in output", row) + } + if idx < last { + t.Errorf("socket row %q appeared before the previous row (idx %d < %d)", row, idx, last) + } + last = idx + } +} + +// TestRenderStandardRestarts verifies the Restarts row renders only when the +// managing system reports at least one restart, matching the documented +// example output. +func TestRenderStandardRestarts(t *testing.T) { + t.Parallel() + + // Zero restarts: the row must be omitted (no noise for the common case). + var zero bytes.Buffer + RenderStandard(&zero, fixedFixture(), false, false) + if strings.Contains(zero.String(), "Restarts") { + t.Errorf("Restarts row should be omitted when RestartCount is 0; output:\n%s", zero.String()) + } + + // Non-zero restarts: the row appears with the count. + res := fixedFixture() + res.RestartCount = 3 + var got bytes.Buffer + RenderStandard(&got, res, false, false) + if !strings.Contains(got.String(), "Restarts : 3") { + t.Errorf("expected \"Restarts : 3\" in output; got:\n%s", got.String()) + } +} + +// TestRenderStandardOmitsEmptyOptionalSections protects against accidentally +// printing labels with no value (e.g. "Container :"). The fixture leaves +// Container, GitRepo, and warnings empty; none of those labels should appear. +func TestRenderStandardOmitsEmptyOptionalSections(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + RenderStandard(&buf, fixedFixture(), false, false) + out := buf.String() + + mustNotContain := []string{ + "Container :", + "Git Repo :", + "Warnings :", + } + for _, s := range mustNotContain { + if strings.Contains(out, s) { + t.Errorf("RenderStandard output should not contain %q for empty fixture; output:\n%s", s, out) + } + } +} diff --git a/internal/output/standard_test.go b/internal/output/standard_test.go new file mode 100644 index 0000000..1a3930c --- /dev/null +++ b/internal/output/standard_test.go @@ -0,0 +1,176 @@ +package output + +import ( + "reflect" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestDisplayState(t *testing.T) { + t.Parallel() + + tests := []struct { + in, want string + }{ + {"LISTEN", "LISTENING"}, + {"ESTABLISHED", "ESTABLISHED"}, + {"CLOSE_WAIT", "CLOSE_WAIT"}, + {"OPEN", "OPEN"}, + {"", "?"}, + } + + for _, tt := range tests { + if got := displayState(tt.in); got != tt.want { + t.Errorf("displayState(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestSocketSortRank(t *testing.T) { + t.Parallel() + + // LISTEN must always rank above ESTABLISHED, and both above unknown. + // OPEN sits between LISTEN and ESTABLISHED. + if socketSortRank("LISTEN") >= socketSortRank("ESTABLISHED") { + t.Errorf("LISTEN should rank below ESTABLISHED") + } + if socketSortRank("OPEN") <= socketSortRank("LISTEN") { + t.Errorf("OPEN should rank above LISTEN") + } + if socketSortRank("ESTABLISHED") >= socketSortRank("TIME_WAIT") { + t.Errorf("ESTABLISHED should rank above unknown states") + } + if socketSortRank("LISTEN") != 0 { + t.Errorf("LISTEN should be rank 0 so it appears first") + } +} + +func TestFormatSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s model.Socket + want string + }{ + { + name: "tcp listener", + s: model.Socket{Address: "0.0.0.0", Port: 443, Protocol: "TCP", State: "LISTEN"}, + want: "0.0.0.0:443 (TCP | LISTENING)", + }, + { + name: "established", + s: model.Socket{Address: "127.0.0.1", Port: 43525, Protocol: "TCP", State: "ESTABLISHED"}, + want: "127.0.0.1:43525 (TCP | ESTABLISHED)", + }, + { + name: "ipv6 wraps host:port via JoinHostPort", + s: model.Socket{Address: "::1", Port: 8080, Protocol: "TCP6", State: "LISTEN"}, + want: "[::1]:8080 (TCP6 | LISTENING)", + }, + { + name: "udp open", + s: model.Socket{Address: "0.0.0.0", Port: 53, Protocol: "UDP", State: "OPEN"}, + want: "0.0.0.0:53 (UDP | OPEN)", + }, + { + name: "missing protocol falls back to ?", + s: model.Socket{Address: "127.0.0.1", Port: 9999, Protocol: "", State: "LISTEN"}, + want: "127.0.0.1:9999 (? | LISTENING)", + }, + { + name: "blank state renders as ?", + s: model.Socket{Address: "127.0.0.1", Port: 9999, Protocol: "TCP", State: ""}, + want: "127.0.0.1:9999 (TCP | ?)", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := formatSocket(tt.s); got != tt.want { + t.Errorf("formatSocket(%+v) = %q, want %q", tt.s, got, tt.want) + } + }) + } +} + +func TestVisibleSocketsDropsIncomplete(t *testing.T) { + t.Parallel() + + in := []model.Socket{ + {Address: "127.0.0.1", Port: 8080, State: "LISTEN"}, + {Address: "", Port: 80, State: "LISTEN"}, // missing address + {Address: "127.0.0.1", Port: 0, State: "LISTEN"}, // missing port + {Address: "0.0.0.0", Port: 443, State: "LISTEN"}, + } + got := visibleSockets(in) + if len(got) != 2 { + t.Fatalf("visibleSockets dropped wrong number: got %d, want 2", len(got)) + } + if got[0].Port != 8080 || got[1].Port != 443 { + t.Errorf("visibleSockets unexpected order/content: %+v", got) + } +} + +func TestSortSockets(t *testing.T) { + t.Parallel() + + // Scenario lifted from the real bug report: address grouping with a + // LISTEN that must sit above its ESTABLISHED sibling on the same + // address:port, and ports ascending within each address. + in := []model.Socket{ + {Address: "192.168.176.122", Port: 58236, State: "ESTABLISHED"}, + {Address: "192.168.176.122", Port: 36146, State: "ESTABLISHED"}, + {Address: "127.0.0.1", Port: 43525, State: "ESTABLISHED"}, + {Address: "192.168.176.122", Port: 36170, State: "ESTABLISHED"}, + {Address: "127.0.0.1", Port: 43525, State: "LISTEN"}, + {Address: "192.168.176.122", Port: 36162, State: "ESTABLISHED"}, + } + want := []model.Socket{ + {Address: "127.0.0.1", Port: 43525, State: "LISTEN"}, + {Address: "127.0.0.1", Port: 43525, State: "ESTABLISHED"}, + {Address: "192.168.176.122", Port: 36146, State: "ESTABLISHED"}, + {Address: "192.168.176.122", Port: 36162, State: "ESTABLISHED"}, + {Address: "192.168.176.122", Port: 36170, State: "ESTABLISHED"}, + {Address: "192.168.176.122", Port: 58236, State: "ESTABLISHED"}, + } + + sortSockets(in) + if !reflect.DeepEqual(in, want) { + t.Errorf("sortSockets order mismatch.\n got: %+v\nwant: %+v", in, want) + } +} + +func TestSortSocketsListenBeforeEstablishedSamePort(t *testing.T) { + t.Parallel() + + // Insertion order is ESTABLISHED first, but the sort must surface + // LISTEN above it. + in := []model.Socket{ + {Address: "10.0.0.1", Port: 80, State: "ESTABLISHED"}, + {Address: "10.0.0.1", Port: 80, State: "LISTEN"}, + } + sortSockets(in) + if in[0].State != "LISTEN" { + t.Errorf("LISTEN must sort above ESTABLISHED on same address:port; got %+v", in) + } +} + +func TestSortSocketsStableWithinSameRank(t *testing.T) { + t.Parallel() + + // Two ESTABLISHED sockets on different ports of the same address should + // be ordered by port. Stability is asserted via the deterministic input + // ordering: lower port first regardless of input position. + in := []model.Socket{ + {Address: "10.0.0.1", Port: 9000, State: "ESTABLISHED"}, + {Address: "10.0.0.1", Port: 8000, State: "ESTABLISHED"}, + } + sortSockets(in) + if in[0].Port != 8000 { + t.Errorf("expected lower port first within same address; got %+v", in) + } +} diff --git a/internal/output/standard_verbose_test.go b/internal/output/standard_verbose_test.go new file mode 100644 index 0000000..e3ca439 --- /dev/null +++ b/internal/output/standard_verbose_test.go @@ -0,0 +1,102 @@ +package output + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// richVerboseResult populates every optional field so a verbose render lights +// up all of its sections (resource/memory/IO/files/FDs/socket/threads/children). +func richVerboseResult() model.Result { + proc := model.Process{ + PID: 1234, + PPID: 1, + Command: "nginx", + Cmdline: "/usr/sbin/nginx -g daemon off;", + User: "nginx", + StartedAt: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), + Health: "healthy", + Forked: "not-forked", + Memory: model.MemoryInfo{ + VMS: 200 * 1024 * 1024, RSS: 50 * 1024 * 1024, + VMSMB: 200, RSSMB: 50, Shared: 10 * 1024 * 1024, + }, + IO: model.IOStats{ReadBytes: 5 * 1024 * 1024, WriteBytes: 2 * 1024 * 1024, ReadOps: 100, WriteOps: 50}, + FDCount: 3, + FDLimit: 1024, + FileDescs: []string{"0 -> /dev/null", "1 -> /var/log/nginx.log", "2 -> /etc/nginx.conf"}, + ThreadCount: 4, + } + return model.Result{ + Target: model.Target{Type: model.TargetName, Value: "nginx"}, + Process: proc, + Ancestry: []model.Process{{PID: 1, Command: "systemd"}, proc}, + Source: model.Source{Type: model.SourceSystemd, Name: "nginx.service"}, + Warnings: []string{"Process is running as root"}, + ResourceContext: &model.ResourceContext{CPUUsage: 85.0, MemoryUsage: 40 * 1024 * 1024, PreventsSleep: true, ThermalState: "Heavy"}, + FileContext: &model.FileContext{OpenFiles: 90, FileLimit: 100, LockedFiles: []string{"/var/run/a.lock", "/var/run/b.lock"}}, + SocketInfo: &model.SocketInfo{State: "TIME_WAIT", Explanation: "waiting for delayed packets", Workaround: "use SO_REUSEADDR"}, + Children: []model.Process{{PID: 2000, Command: "worker"}}, + } +} + +func TestRenderStandardColoredVerbose(t *testing.T) { + var buf bytes.Buffer + RenderStandard(&buf, richVerboseResult(), true, true) + out := buf.String() + + for _, want := range []string{ + "CPU", "Energy", "Thermal", "Memory", "Virtual", "Resident", "Shared", + "I/O Statistics", "Open Files", "Locks", "File Descriptors", "/var/log/nginx.log", + "Socket", "waiting for delayed packets", "Threads", "Children", "worker", + "Warnings", "running as root", + } { + if !strings.Contains(out, want) { + t.Errorf("colored+verbose output missing %q\n---\n%s", want, out) + } + } +} + +func TestRenderStandardPlainVerbose(t *testing.T) { + var buf bytes.Buffer + RenderStandard(&buf, richVerboseResult(), false, true) + out := buf.String() + + for _, want := range []string{ + "CPU :", "Memory:", "I/O Statistics", "Open Files :", + "File Descriptors:", "Socket :", "Threads: 4", "Children of nginx", + } { + if !strings.Contains(out, want) { + t.Errorf("plain+verbose output missing %q\n---\n%s", want, out) + } + } +} + +func TestRenderStandardVerboseAltBranches(t *testing.T) { + r := richVerboseResult() + proc := r.Ancestry[len(r.Ancestry)-1] + proc.FDLimit = 0 // unlimited descriptor limit + proc.FileDescs = make([]string, 15) + for i := range proc.FileDescs { + proc.FileDescs[i] = fmt.Sprintf("%d -> /tmp/f%d", i, i) + } + r.Ancestry[len(r.Ancestry)-1] = proc + r.Process = proc + r.ResourceContext.CPUUsage = 10.0 // below the high-CPU threshold (green branch) + r.FileContext.FileLimit = 0 // "of unlimited" open-files branch + + var buf bytes.Buffer + RenderStandard(&buf, r, true, true) + out := buf.String() + + for _, want := range []string{"unlimited", "Showing first", "and 5 more"} { + if !strings.Contains(out, want) { + t.Errorf("alt-branch verbose output missing %q\n---\n%s", want, out) + } + } +} diff --git a/internal/output/started.go b/internal/output/started.go new file mode 100644 index 0000000..277a7db --- /dev/null +++ b/internal/output/started.go @@ -0,0 +1,35 @@ +package output + +import ( + "fmt" + "time" +) + +// FormatStartedAt returns the "Started" line components: a relative phrase +// like "2 days ago" and an absolute timestamp like "Mon 2026-05-15 09:42:11 +00:00". +// Empty time produces ("unknown", ""). +func FormatStartedAt(t time.Time) (rel, absolute string) { + if t.IsZero() { + return "unknown", "" + } + dur := time.Since(t) + switch { + case dur.Hours() >= 48: + rel = fmt.Sprintf("%d days ago", int(dur.Hours())/24) + case dur.Hours() >= 24: + rel = "1 day ago" + case dur.Hours() >= 2: + rel = fmt.Sprintf("%d hours ago", int(dur.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" + } + } + absolute = t.Format("Mon 2006-01-02 15:04:05 -07:00") + return rel, absolute +} diff --git a/internal/output/started_test.go b/internal/output/started_test.go new file mode 100644 index 0000000..c0f842d --- /dev/null +++ b/internal/output/started_test.go @@ -0,0 +1,34 @@ +package output + +import ( + "testing" + "time" +) + +func TestFormatStartedAt(t *testing.T) { + if rel, abs := FormatStartedAt(time.Time{}); rel != "unknown" || abs != "" { + t.Errorf("zero time = (%q, %q), want (\"unknown\", \"\")", rel, abs) + } + + now := time.Now() + cases := []struct { + ago time.Duration + want string + }{ + {30 * time.Second, "just now"}, + {5 * time.Minute, "5 min ago"}, + {90 * time.Minute, "1 hour ago"}, + {5 * time.Hour, "5 hours ago"}, + {30 * time.Hour, "1 day ago"}, + {72 * time.Hour, "3 days ago"}, + } + for _, tc := range cases { + rel, abs := FormatStartedAt(now.Add(-tc.ago)) + if rel != tc.want { + t.Errorf("FormatStartedAt(-%v) rel = %q, want %q", tc.ago, rel, tc.want) + } + if abs == "" { + t.Errorf("FormatStartedAt(-%v) absolute timestamp should not be empty", tc.ago) + } + } +} diff --git a/internal/output/tree.go b/internal/output/tree.go new file mode 100644 index 0000000..c9db8a2 --- /dev/null +++ b/internal/output/tree.go @@ -0,0 +1,65 @@ +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, ChainName(proc), ColorReset, ColorDim, proc.PID, ColorReset) + } else { + p.Printf("%s (pid %d)\n", ChainName(proc), 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, ChainName(child), ColorDim, child.PID, ColorReset) + } else { + p.Printf("%s%s%s (pid %d)\n", baseIndent, connector, ChainName(child), child.PID) + } + } +} diff --git a/internal/output/tree_colored_test.go b/internal/output/tree_colored_test.go new file mode 100644 index 0000000..bbaf18a --- /dev/null +++ b/internal/output/tree_colored_test.go @@ -0,0 +1,35 @@ +package output + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestPrintTreeColored(t *testing.T) { + chain := []model.Process{{PID: 1, Command: "systemd"}, {PID: 1234, Command: "nginx"}} + kids := []model.Process{{PID: 2000, Command: "worker"}} + + var buf bytes.Buffer + PrintTree(&buf, chain, kids, true) + out := buf.String() + for _, want := range []string{"systemd", "nginx", "worker", "pid 1234"} { + if !strings.Contains(out, want) { + t.Errorf("colored tree missing %q:\n%s", want, out) + } + } + + // More than ten children collapses into a "... and N more" line. + many := make([]model.Process, 15) + for i := range many { + many[i] = model.Process{PID: i + 10, Command: fmt.Sprintf("c%d", i)} + } + var m bytes.Buffer + PrintTree(&m, chain, many, true) + if !strings.Contains(m.String(), "and 5 more") { + t.Errorf("expected colored tree truncation; got:\n%s", m.String()) + } +} diff --git a/internal/output/tree_test.go b/internal/output/tree_test.go new file mode 100644 index 0000000..5acf6a5 --- /dev/null +++ b/internal/output/tree_test.go @@ -0,0 +1,101 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestPrintTreeAncestryAndChildren(t *testing.T) { + t.Parallel() + + chain := []model.Process{ + {PID: 1, Command: "systemd"}, + {PID: 100, Command: "nginx"}, + } + children := []model.Process{ + {PID: 101, Command: "worker-1"}, + {PID: 102, Command: "worker-2"}, + } + + var buf bytes.Buffer + PrintTree(&buf, chain, children, false) + out := buf.String() + + mustContain := []string{ + "systemd (pid 1)", + "└─ nginx (pid 100)", + "├─ worker-1 (pid 101)", + "└─ worker-2 (pid 102)", + } + for _, s := range mustContain { + if !strings.Contains(out, s) { + t.Errorf("PrintTree output missing %q\n---\n%s\n---", s, out) + } + } +} + +func TestPrintTreeTruncatesChildrenOverLimit(t *testing.T) { + t.Parallel() + + chain := []model.Process{{PID: 100, Command: "nginx"}} + + // 12 children — limit is 10, expect "... and 2 more" + children := make([]model.Process, 12) + for i := range children { + children[i] = model.Process{PID: 200 + i, Command: "worker"} + } + + var buf bytes.Buffer + PrintTree(&buf, chain, children, false) + out := buf.String() + + if !strings.Contains(out, "... and 2 more") { + t.Errorf("PrintTree should report truncation; output:\n%s", out) + } + // The 11th and 12th children must NOT appear. + if strings.Contains(out, "(pid 210)") || strings.Contains(out, "(pid 211)") { + t.Errorf("PrintTree should omit children beyond the limit; output:\n%s", out) + } +} + +func TestPrintTreeNoChildrenSection(t *testing.T) { + t.Parallel() + + chain := []model.Process{{PID: 1, Command: "init"}} + + var buf bytes.Buffer + PrintTree(&buf, chain, nil, false) + out := buf.String() + + // With no children, the output should be just the ancestry line. + if strings.Contains(out, "├─") || strings.Contains(out, "and") { + t.Errorf("PrintTree with no children should not render branch lines; got:\n%s", out) + } +} + +func TestPrintTreeLastChildUsesElbowConnector(t *testing.T) { + t.Parallel() + + chain := []model.Process{{PID: 100, Command: "nginx"}} + children := []model.Process{ + {PID: 201, Command: "worker-a"}, + {PID: 202, Command: "worker-b"}, + } + + var buf bytes.Buffer + PrintTree(&buf, chain, children, false) + out := buf.String() + + // First child uses ├─, last uses └─. Locate by PID-suffixed substring. + first := strings.Index(out, "├─ worker-a") + last := strings.Index(out, "└─ worker-b") + if first == -1 { + t.Errorf("expected first child rendered with ├─; output:\n%s", out) + } + if last == -1 { + t.Errorf("expected last child rendered with └─; output:\n%s", out) + } +} diff --git a/internal/pipeline/analyze.go b/internal/pipeline/analyze.go new file mode 100644 index 0000000..69a74e1 --- /dev/null +++ b/internal/pipeline/analyze.go @@ -0,0 +1,126 @@ +package pipeline + +import ( + "sort" + "strconv" + "strings" + + 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) + + // ReadProcess labels lxc.payload cgroups generically as "lxc-based:" since + // it can't see the ancestry. source.Detect knows the actual runtime via the + // ancestor binary (incusd/lxd/lxc-start) — rewrite the per-process label so + // the Container line matches the Source line. + if src.Type == model.SourceContainer { + switch src.Name { + case "incus", "lxd", "lxc": + for i := range ancestry { + switch { + case strings.HasPrefix(ancestry[i].Container, "lxc-based:"): + ancestry[i].Container = src.Name + ":" + strings.TrimPrefix(ancestry[i].Container, "lxc-based:") + case ancestry[i].Container == "lxc-based": + ancestry[i].Container = src.Name + } + } + } + } + + var proc model.Process + resolvedTarget := "unknown" + if len(ancestry) > 0 { + proc = ancestry[len(ancestry)-1] + resolvedTarget = proc.Command + } + + // Resolve the target container's healthcheck so the warning only fires when + // the runtime confirms none is configured. + if proc.ContainerID != "" { + hc := procpkg.ContainerHealthcheckStatus(proc.ContainerID, proc.ContainerRuntime) + proc.ContainerHealthcheck = hc + if len(ancestry) > 0 { + ancestry[len(ancestry)-1].ContainerHealthcheck = hc + } + } + + // 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, restartCount, src.Type), + ResourceContext: resCtx, + FileContext: fileCtx, + Children: childProcesses, + } + + return res, nil +} diff --git a/internal/pipeline/analyze_bench_test.go b/internal/pipeline/analyze_bench_test.go new file mode 100644 index 0000000..8f04501 --- /dev/null +++ b/internal/pipeline/analyze_bench_test.go @@ -0,0 +1,78 @@ +package pipeline + +import ( + "os" + "testing" + + procpkg "github.com/pranshuparmar/witr/internal/proc" +) + +// Performance budget +// ------------------ +// These benchmarks pin down where `witr ` spends its time and give the +// later caching and per-PID memoization work a fixed yardstick. They drive the +// real pipeline against the running test process, whose ancestry exists on +// every platform. +// +// go test ./internal/pipeline/ -run '^$' -bench . -benchmem +// +// Baseline (WSL/Ubuntu dev host, 2026-06, ~6-deep ancestry under `go test`): +// BenchmarkAnalyzePID ~83 ms/op 1.7k allocs standard report +// BenchmarkAnalyzePIDVerbose ~332 ms/op 3.6k allocs --verbose + tree +// BenchmarkResolveAncestry ~77 ms/op 1.7k allocs the ancestry walk +// BenchmarkReadProcess ~12 ms/op 290 allocs one process read +// +// The walk is ~93% of AnalyzePID: ResolveAncestry calls ReadProcess per +// ancestor, and on Linux each ReadProcess shells out to `systemctl` and probes +// for a git repo — one systemctl per hop drives the ~12 ms ReadProcess cost, +// multiplied by ancestry depth. +// +// TARGETS (warm cache, workstation-class host): +// - Subprocess budget (host-independent — the real lever): <= 2 `systemctl` +// spawns per ancestry walk regardless of depth; <= 2 per-PID tool +// invocations per hop on macOS/FreeBSD. Today: ~1 systemctl per hop. +// - Wall-clock: standard AnalyzePID < 40 ms, ResolveAncestry < 30 ms, +// ReadProcess < 5 ms; --verbose < 200 ms. Removing the per-hop systemctl +// fan-out should roughly halve today's numbers. +// +// Re-run before/after the caching work and record the deltas in that PR. + +func BenchmarkAnalyzePID(b *testing.B) { + self := os.Getpid() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := AnalyzePID(AnalyzeConfig{PID: self}); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkAnalyzePIDVerbose(b *testing.B) { + self := os.Getpid() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := AnalyzePID(AnalyzeConfig{PID: self, Verbose: true, Tree: true}); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkResolveAncestry(b *testing.B) { + self := os.Getpid() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := procpkg.ResolveAncestry(self); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkReadProcess(b *testing.B) { + self := os.Getpid() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := procpkg.ReadProcess(self); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/pipeline/analyze_test.go b/internal/pipeline/analyze_test.go new file mode 100644 index 0000000..523e93e --- /dev/null +++ b/internal/pipeline/analyze_test.go @@ -0,0 +1,57 @@ +package pipeline + +import ( + "os" + "testing" +) + +// TestAnalyzePID_Self drives the full pipeline (ancestry walk → source +// detection → result assembly) against the running test process, which is +// guaranteed to exist on every platform. +func TestAnalyzePID_Self(t *testing.T) { + self := os.Getpid() + + res, err := AnalyzePID(AnalyzeConfig{PID: self}) + if err != nil { + t.Fatalf("AnalyzePID(self=%d): %v", self, err) + } + + if res.Process.PID != self { + t.Errorf("Process.PID = %d, want %d", res.Process.PID, self) + } + if len(res.Ancestry) == 0 { + t.Fatal("expected a non-empty ancestry chain") + } + if last := res.Ancestry[len(res.Ancestry)-1]; last.PID != self { + t.Errorf("ancestry should end at the target PID; got %d, want %d", last.PID, self) + } + if res.ResolvedTarget == "" { + t.Error("ResolvedTarget should be set to the process command") + } + // Detect always classifies a source (at minimum SourceUnknown), never blank. + if res.Source.Type == "" { + t.Error("Source.Type should be classified") + } +} + +// TestAnalyzePID_Verbose ensures the verbose/tree path (extended info + child +// collection) runs without error and still returns the target. +func TestAnalyzePID_Verbose(t *testing.T) { + self := os.Getpid() + + res, err := AnalyzePID(AnalyzeConfig{PID: self, Verbose: true, Tree: true}) + if err != nil { + t.Fatalf("verbose AnalyzePID(self=%d): %v", self, err) + } + if res.Process.PID != self { + t.Errorf("Process.PID = %d, want %d", res.Process.PID, self) + } +} + +// TestAnalyzePID_Nonexistent confirms the pipeline surfaces an error rather than +// returning a zero-value result for a PID that doesn't exist. +func TestAnalyzePID_Nonexistent(t *testing.T) { + if _, err := AnalyzePID(AnalyzeConfig{PID: 2147483646}); err == nil { + t.Error("expected an error for a nonexistent PID") + } +} diff --git a/internal/proc/ancestry.go b/internal/proc/ancestry.go new file mode 100644 index 0000000..7e844fe --- /dev/null +++ b/internal/proc/ancestry.go @@ -0,0 +1,44 @@ +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 +} diff --git a/internal/proc/boot_darwin.go b/internal/proc/boot_darwin.go new file mode 100644 index 0000000..ca23d1d --- /dev/null +++ b/internal/proc/boot_darwin.go @@ -0,0 +1,38 @@ +//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) +} diff --git a/internal/proc/boot_freebsd.go b/internal/proc/boot_freebsd.go new file mode 100644 index 0000000..7c60843 --- /dev/null +++ b/internal/proc/boot_freebsd.go @@ -0,0 +1,39 @@ +//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 +} diff --git a/internal/proc/boot_linux.go b/internal/proc/boot_linux.go new file mode 100644 index 0000000..d1d64fb --- /dev/null +++ b/internal/proc/boot_linux.go @@ -0,0 +1,52 @@ +//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 +} + +// startTimeFromTicks converts a process start time expressed in clock ticks +// since boot (/proc/[pid]/stat field 22) into an absolute time. It divides +// before scaling to nanoseconds: the naive `ticks * time.Second / hz` overflows +// int64 once ticks·1e9 exceeds ~9.2e18, i.e. for any process on a host whose +// uptime exceeds ~2.9 years, which would yield a garbage (often negative) time. +func startTimeFromTicks(boot time.Time, startTicks int64, hz int) time.Time { + if hz <= 0 { + hz = 100 + } + h := int64(hz) + secs := startTicks / h + nsec := (startTicks % h) * int64(time.Second) / h + return boot.Add(time.Duration(secs)*time.Second + time.Duration(nsec)) +} diff --git a/internal/proc/boot_linux_test.go b/internal/proc/boot_linux_test.go new file mode 100644 index 0000000..08bef3a --- /dev/null +++ b/internal/proc/boot_linux_test.go @@ -0,0 +1,33 @@ +//go:build linux + +package proc + +import ( + "testing" + "time" +) + +func TestStartTimeFromTicks(t *testing.T) { + boot := time.Unix(1_600_000_000, 0) + + // Basic conversion: 250 ticks at 100 Hz is 2.5s after boot. + if got := startTimeFromTicks(boot, 250, 100); !got.Equal(boot.Add(2500 * time.Millisecond)) { + t.Errorf("startTimeFromTicks(boot, 250, 100) = %v, want boot+2.5s", got) + } + + // hz <= 0 falls back to 100 rather than dividing by zero. + if got := startTimeFromTicks(boot, 100, 0); !got.Equal(boot.Add(time.Second)) { + t.Errorf("startTimeFromTicks(boot, 100, 0) = %v, want boot+1s", got) + } + + // Multi-year uptime must not overflow int64. The naive ticks*time.Second/hz + // overflows past ~2.9 years and lands before boot; the helper must not. + bigTicks := int64(5 * 365 * 24 * 60 * 60 * 100) // ~5 years at 100 Hz + got := startTimeFromTicks(boot, bigTicks, 100) + if got.Before(boot) { + t.Fatalf("startTimeFromTicks(big) = %v, before boot %v (overflow)", got, boot) + } + if d, want := got.Sub(boot), time.Duration(bigTicks/100)*time.Second; d != want { + t.Errorf("startTimeFromTicks(big) = boot+%v, want boot+%v", d, want) + } +} diff --git a/internal/proc/boot_windows.go b/internal/proc/boot_windows.go new file mode 100644 index 0000000..b293b9e --- /dev/null +++ b/internal/proc/boot_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package proc + +import "time" + +var procGetTickCount64 = modkernel32.NewProc("GetTickCount64") + +// bootTime derives the system boot time from milliseconds since startup. +func bootTime() time.Time { + ret, _, _ := procGetTickCount64.Call() + uptime := time.Duration(ret) * time.Millisecond + return time.Now().Add(-uptime) +} diff --git a/internal/proc/boot_windows_test.go b/internal/proc/boot_windows_test.go new file mode 100644 index 0000000..a8be904 --- /dev/null +++ b/internal/proc/boot_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows + +package proc + +import ( + "testing" + "time" +) + +func TestBootTimeIsInThePast(t *testing.T) { + if bt := bootTime(); !bt.Before(time.Now()) { + t.Errorf("bootTime() = %v, expected a time in the past", bt) + } +} + +func TestBootTimeIsAfter2000(t *testing.T) { + floor := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + if bt := bootTime(); bt.Before(floor) { + t.Errorf("bootTime() = %v, expected >= %v", bt, floor) + } +} + +func TestBootTimeUptimeReasonable(t *testing.T) { + uptime := time.Since(bootTime()) + if uptime < 0 { + t.Errorf("uptime is negative: %v", uptime) + } + if uptime > 100*365*24*time.Hour { + t.Errorf("uptime > 100 years: %v", uptime) + } +} diff --git a/internal/proc/capabilities_linux.go b/internal/proc/capabilities_linux.go new file mode 100644 index 0000000..730f1fb --- /dev/null +++ b/internal/proc/capabilities_linux.go @@ -0,0 +1,92 @@ +//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< -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 +} diff --git a/internal/proc/cmdline_linux.go b/internal/proc/cmdline_linux.go new file mode 100644 index 0000000..0c0a040 --- /dev/null +++ b/internal/proc/cmdline_linux.go @@ -0,0 +1,23 @@ +//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 +} diff --git a/internal/proc/cmdline_windows.go b/internal/proc/cmdline_windows.go new file mode 100644 index 0000000..de06e4c --- /dev/null +++ b/internal/proc/cmdline_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package proc + +// GetCmdline returns a best-effort identifier for a PID using the exe +// basename from the ToolHelp32 snapshot. Used as a fallback in multi-match +// output when ReadProcess itself failed. +func GetCmdline(pid int) string { + procs, err := enumerateProcesses() + if err != nil { + return "(unknown)" + } + for _, p := range procs { + if p.PID == pid && p.Exe != "" { + return p.Exe + } + } + return "(unknown)" +} diff --git a/internal/proc/cmdline_windows_test.go b/internal/proc/cmdline_windows_test.go new file mode 100644 index 0000000..cb1078e --- /dev/null +++ b/internal/proc/cmdline_windows_test.go @@ -0,0 +1,25 @@ +//go:build windows + +package proc + +import ( + "os" + "strings" + "testing" +) + +func TestGetCmdlineSelf(t *testing.T) { + got := GetCmdline(os.Getpid()) + if got == "" || got == "(unknown)" { + t.Errorf("GetCmdline(self) = %q, want a non-empty exe basename", got) + } + if !strings.HasSuffix(strings.ToLower(got), ".exe") { + t.Errorf("GetCmdline(self) = %q, expected a .exe suffix", got) + } +} + +func TestGetCmdlineNonexistentPID(t *testing.T) { + if got := GetCmdline(2147483646); got != "(unknown)" { + t.Errorf("GetCmdline(large-pid) = %q, want %q", got, "(unknown)") + } +} diff --git a/internal/proc/command.go b/internal/proc/command.go new file mode 100644 index 0000000..7fd7cb0 --- /dev/null +++ b/internal/proc/command.go @@ -0,0 +1,100 @@ +package proc + +import ( + "os" + "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 == '_' +} + +// binaryBasename returns the executable name from a string that is known to +// be a single full binary path (e.g. from `lsof ftxt`, `/proc//exe`, or +// `ps -o comm=` on its own line). Unlike extractExecutableName it does NOT +// tokenize on whitespace, so paths containing spaces — like macOS .app +// bundles ("/Applications/Microsoft Teams.app/Contents/MacOS/Microsoft Teams") +// — are preserved intact. +func binaryBasename(rawPath string) string { + s := strings.TrimSpace(rawPath) + s = strings.Trim(s, `"'`) + if s == "" { + return "" + } + base := filepath.Base(s) + if base == "." || isPathSeparator(base) { + return "" + } + return base +} + +// isPathSeparator reports whether s is a single bare path separator. filepath.Base +// returns the platform separator for a root path ("/" on Unix, "\\" on Windows), +// which is never a real binary name. +func isPathSeparator(s string) bool { + return len(s) == 1 && os.IsPathSeparator(s[0]) +} + +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 == "" || isPathSeparator(base) { + continue + } + return base + } + return "" +} diff --git a/internal/proc/command_test.go b/internal/proc/command_test.go new file mode 100644 index 0000000..152548c --- /dev/null +++ b/internal/proc/command_test.go @@ -0,0 +1,213 @@ +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", + }, + { + // Documents the known limitation that caused issue #201: when `ps` + // emits an unquoted argv (which it always does on darwin/freebsd), + // a path containing spaces is tokenized incorrectly. Callers must + // use binaryBasename(comm) or binaryBasename(binPath) to get the + // correct display name in this case — extractExecutableName cannot + // recover it from raw args alone. + name: "loses spaces in unquoted .app path (issue #201)", + cmdline: "/Applications/Microsoft Teams.app/Contents/MacOS/Microsoft Teams --arg", + want: "Microsoft", + }, + } + + 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) + } + }) + } +} + +func TestBinaryBasename(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "preserves spaces in .app path", + in: "/Applications/Microsoft Teams.app/Contents/MacOS/Microsoft Teams", + want: "Microsoft Teams", + }, + { + name: "preserves spaces in helper renderer path", + in: "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/Current/Helpers/Google Chrome Helper (Renderer).app/Contents/MacOS/Google Chrome Helper (Renderer)", + want: "Google Chrome Helper (Renderer)", + }, + { + name: "trims surrounding whitespace and newline (ps -o comm= output)", + in: "/Applications/Visual Studio Code.app/Contents/MacOS/Electron\n", + want: "Electron", + }, + { + name: "strips surrounding quotes", + in: `"/usr/local/bin/my server"`, + want: "my server", + }, + { + name: "returns empty for empty input", + in: "", + want: "", + }, + { + name: "returns empty for whitespace-only input", + in: " \n ", + want: "", + }, + { + name: "rejects bare slash", + in: "/", + want: "", + }, + { + name: "handles simple basename without path", + in: "bash", + want: "bash", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := binaryBasename(tt.in); got != tt.want { + t.Fatalf("binaryBasename(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/proc/container.go b/internal/proc/container.go new file mode 100644 index 0000000..1be4fff --- /dev/null +++ b/internal/proc/container.go @@ -0,0 +1,265 @@ +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 unavailable or no container matches. +func ResolveContainerByPort(port int) *model.ContainerMatch { + if _, err := exec.LookPath("docker"); err != nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + format := strings.Join([]string{ + "{{.ID}}", "{{.Names}}", "{{.Image}}", "{{.Command}}", + "{{.State}}", "{{.Status}}", "{{.CreatedAt}}", + "{{.Networks}}", "{{.Mounts}}", "{{.Ports}}", "{{.Labels}}", + }, "|") + cmd := exec.CommandContext(ctx, "docker", "ps", "--no-trunc", "--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 + } + if idx := strings.Index(line, "\n"); idx >= 0 { + line = line[:idx] + } + + parts := strings.SplitN(line, "|", 11) + if len(parts) < 11 { + return nil + } + labels := parseLabelString(parts[10]) + + return &model.ContainerMatch{ + Runtime: "docker", + ID: parts[0], + Name: parts[1], + Image: parts[2], + Command: strings.Trim(parts[3], "\""), + State: parts[4], + Status: parts[5], + Health: healthFromStatus(parts[5]), + CreatedAt: parseDockerTime(parts[6]), + Networks: parts[7], + Mounts: parts[8], + Ports: parts[9], + ComposeProject: labels["com.docker.compose.project"], + ComposeService: labels["com.docker.compose.service"], + ComposeConfigFile: labels["com.docker.compose.project.config_files"], + ComposeWorkingDir: labels["com.docker.compose.project.working_dir"], + } +} + +// isValidContainerID reports whether id is a safe container identifier to hand +// to a runtime CLI: a non-empty token of [A-Za-z0-9_.-] that begins with an +// alphanumeric. Rejecting a leading dash (and any other metacharacter) keeps a +// malformed, cgroup-derived id from being parsed as a CLI option. +func isValidContainerID(id string) bool { + if id == "" { + return false + } + for i := 0; i < len(id); i++ { + c := id[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case (c == '_' || c == '.' || c == '-') && i > 0: + default: + return false + } + } + return true +} + +// resolveContainerName attempts to resolve a container ID to a name using the specified runtime CLI. +func resolveContainerName(id, runtime string) string { + if !isValidContainerID(id) { + return "" + } + var cmd *exec.Cmd + var prefix string + + ctx := context.Background() + switch runtime { + case "docker": + if _, err := exec.LookPath("docker"); err != nil { + return "" + } + cmd = exec.CommandContext(ctx, "docker", "inspect", "--format", "{{.Name}}|{{index .Config.Labels \"com.docker.compose.project\"}}|{{index .Config.Labels \"com.docker.compose.service\"}}", "--", id) + prefix = "docker: " + case "podman": + if _, err := exec.LookPath("podman"); err != nil { + return "" + } + cmd = commandAsOriginalUser(ctx, "podman", "inspect", "--format", "{{.Name}}", "--", id) + prefix = "podman: " + case "crictl": + if _, err := exec.LookPath("crictl"); err != nil { + return "" + } + cmd = exec.CommandContext(ctx, "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 = commandAsOriginalUser(ctx, "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 "" +} + +// ContainerHealthcheckStatus reports whether the container runtime has a +// healthcheck configured: "present", "absent", or "" when undeterminable +// (runtime unavailable, inspect error, or unsupported runtime). +func ContainerHealthcheckStatus(id, runtime string) string { + if !isValidContainerID(id) || (runtime != "docker" && runtime != "podman") { + return "" + } + if _, err := exec.LookPath(runtime); err != nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := runtimeCommand(ctx, runtime, "inspect", "--format", "{{if .Config.Healthcheck}}present{{else}}absent{{end}}", "--", id).Output() + if err != nil { + return "" + } + switch strings.TrimSpace(string(out)) { + case "present": + return "present" + case "absent": + return "absent" + } + 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 +} diff --git a/internal/proc/container_detect.go b/internal/proc/container_detect.go new file mode 100644 index 0000000..94530ba --- /dev/null +++ b/internal/proc/container_detect.go @@ -0,0 +1,60 @@ +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 "" +} diff --git a/internal/proc/container_detect_test.go b/internal/proc/container_detect_test.go new file mode 100644 index 0000000..dadb2af --- /dev/null +++ b/internal/proc/container_detect_test.go @@ -0,0 +1,30 @@ +package proc + +import "testing" + +func TestDetectContainerFromCmdline(t *testing.T) { + tests := []struct { + cmdline string + want string + }{ + {"", ""}, + {"/usr/sbin/nginx -g daemon off;", ""}, + {"/usr/bin/dockerd --containerd /run/containerd.sock", "docker"}, + {"docker run --name web nginx", "docker: web"}, + {"podman run --name db postgres", "podman: db"}, + {"kind create cluster --name dev", "k8s: dev"}, + {"nerdctl run --name app alpine", "containerd: app"}, + {"minikube start", "kubernetes"}, + {"minikube start -p dev", "k8s: dev"}, + {"colima start", "colima: default"}, + {"colima start --profile work", "colima: work"}, + {"/run/kubepods/besteffort/podxyz/shim", "kubernetes"}, + {"/usr/bin/containerd-shim-runc-v2 -namespace moby", "containerd"}, + {"podman", "podman"}, + } + for _, tt := range tests { + if got := detectContainerFromCmdline(tt.cmdline); got != tt.want { + t.Errorf("detectContainerFromCmdline(%q) = %q, want %q", tt.cmdline, got, tt.want) + } + } +} diff --git a/internal/proc/container_runtime.go b/internal/proc/container_runtime.go new file mode 100644 index 0000000..809020d --- /dev/null +++ b/internal/proc/container_runtime.go @@ -0,0 +1,131 @@ +package proc + +import ( + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ContainerRuntime is a backend (docker, podman, …) that can list containers +// and resolve a container's main host PID. +type ContainerRuntime interface { + Name() string + Available() bool + List() []*model.ContainerMatch + HostPID(id string) int +} + +var registeredRuntimes []ContainerRuntime + +func registerRuntime(rt ContainerRuntime) { + registeredRuntimes = append(registeredRuntimes, rt) +} + +// ResolveContainer queries every available container runtime and returns the +// merged set of matches against the query. Match predicate is substring +// (case-insensitive) across name, image, and command, unless exact is true in +// which case any of those fields must equal the query. +func ResolveContainer(query string, exact bool) []*model.ContainerMatch { + q := strings.ToLower(query) + var out []*model.ContainerMatch + seen := make(map[string]bool) + for _, rt := range registeredRuntimes { + if !rt.Available() { + continue + } + for _, c := range rt.List() { + if !matchContainer(c, q, exact) { + continue + } + key := rt.Name() + "|" + c.ID + if seen[key] { + continue + } + seen[key] = true + out = append(out, c) + } + } + return out +} + +func matchContainer(c *model.ContainerMatch, query string, exact bool) bool { + fields := []string{ + strings.ToLower(c.Name), + strings.ToLower(c.Image), + strings.ToLower(c.Command), + strings.ToLower(c.ComposeProject), + strings.ToLower(c.ComposeService), + } + for _, f := range fields { + if f == "" { + continue + } + if exact { + if f == query { + return true + } + } else if strings.Contains(f, query) { + return true + } + } + return false +} + +// ListAllContainers returns every container reported by every available +// runtime, deduped by runtime|id. Used by the TUI's Containers tab. +func ListAllContainers() []*model.ContainerMatch { + var out []*model.ContainerMatch + seen := make(map[string]bool) + for _, rt := range registeredRuntimes { + if !rt.Available() { + continue + } + for _, c := range rt.List() { + key := rt.Name() + "|" + c.ID + if seen[key] { + continue + } + seen[key] = true + out = append(out, c) + } + } + return out +} + +// ResolveContainerHostPID returns the PID of the container's main process on +// the host. Returns 0 if the runtime can't be reached or the PID isn't +// available (container not running, namespaced PID, etc.). +func ResolveContainerHostPID(runtime, id string) int { + for _, rt := range registeredRuntimes { + if rt.Name() == runtime && rt.Available() { + return rt.HostPID(id) + } + } + return 0 +} + +// enrichingRuntime is implemented by runtimes that can supply additional +// per-container details via a follow-up call, used when a single match is +// resolved and the caller wants richer metadata than the initial list scan +// produced. +type enrichingRuntime interface { + Enrich(*model.ContainerMatch) +} + +// EnrichContainer asks the originating runtime to fill in any extra fields +// available via a per-container query (e.g. crictl inspect). No-op when the +// runtime doesn't expose extra detail. +func EnrichContainer(match *model.ContainerMatch) { + if match == nil { + return + } + for _, rt := range registeredRuntimes { + if rt.Name() != match.Runtime || !rt.Available() { + continue + } + if e, ok := rt.(enrichingRuntime); ok { + e.Enrich(match) + } + return + } +} diff --git a/internal/proc/container_test.go b/internal/proc/container_test.go new file mode 100644 index 0000000..b9d8198 --- /dev/null +++ b/internal/proc/container_test.go @@ -0,0 +1,113 @@ +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 TestShortID(t *testing.T) { + t.Parallel() + + tests := []struct { + in, want string + }{ + {"a1b2c3d4e5f6a1b2c3d4e5f6", "a1b2c3d4e5f6"}, + {"a1b2c3d4e5f6", "a1b2c3d4e5f6"}, + {"a1b2", "a1b2"}, + {"", ""}, + } + for _, tt := range tests { + if got := shortID(tt.in); got != tt.want { + t.Errorf("shortID(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestIsValidContainerID(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", true}, // 64-hex + {"a1b2c3d4e5f6", true}, // short id + {"my_app.1-name", true}, // separators mid-token + {"", false}, // empty + {"-rf", false}, // leading dash → would parse as a flag + {"--format", false}, // leading dash + {".hidden", false}, // leading separator + {"id with space", false}, // whitespace + {"id;rm -rf", false}, // shell metacharacter + {"id\n--format", false}, // embedded newline + {"a/b", false}, // slash + } + for _, tt := range tests { + if got := isValidContainerID(tt.in); got != tt.want { + t.Errorf("isValidContainerID(%q) = %v, want %v", tt.in, 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) + } + }) + } +} diff --git a/internal/proc/container_verify_linux.go b/internal/proc/container_verify_linux.go new file mode 100644 index 0000000..73d9392 --- /dev/null +++ b/internal/proc/container_verify_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "strings" +) + +// PIDBelongsToContainer verifies that the host process at pid actually belongs +// to the given container by checking its cgroup membership. Guards against +// the case where docker inspect returns a PID that's namespaced to a Docker +// VM (macOS, Windows) and happens to coincide with an unrelated host PID. +func PIDBelongsToContainer(pid int, containerID string) bool { + if pid <= 0 || containerID == "" { + return false + } + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)) + if err != nil { + return false + } + return strings.Contains(string(data), containerID) +} diff --git a/internal/proc/container_verify_other.go b/internal/proc/container_verify_other.go new file mode 100644 index 0000000..c3eee6b --- /dev/null +++ b/internal/proc/container_verify_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package proc + +// PIDBelongsToContainer always returns false on non-Linux platforms because +// the cgroup-based check that proves PID ownership doesn't exist there. The +// caller falls back to rendering container details directly without trusting +// the host PID. +func PIDBelongsToContainer(pid int, containerID string) bool { + return false +} diff --git a/internal/proc/docker_proxy.go b/internal/proc/docker_proxy.go new file mode 100644 index 0000000..0557a93 --- /dev/null +++ b/internal/proc/docker_proxy.go @@ -0,0 +1,42 @@ +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 "" +} diff --git a/internal/proc/env_windows_test.go b/internal/proc/env_windows_test.go new file mode 100644 index 0000000..aeb6c7a --- /dev/null +++ b/internal/proc/env_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package proc + +import ( + "os" + "reflect" + "strings" + "testing" + "unicode/utf16" +) + +func TestParseEnvBlock(t *testing.T) { + var block []uint16 + for _, e := range []string{"A=1", `PATH=C:\Windows`} { + block = append(block, utf16.Encode([]rune(e))...) + block = append(block, 0) + } + block = append(block, 0) // terminating empty entry + + if envBlockEnd(block) < 0 { + t.Error("envBlockEnd should find the terminator") + } + got := parseEnvBlock(block) + want := []string{"A=1", `PATH=C:\Windows`} + if !reflect.DeepEqual(got, want) { + t.Errorf("parseEnvBlock = %v, want %v", got, want) + } +} + +// TestReadProcessEnvSelf confirms the PEB environment read returns this +// process's environment (PATH is always set). +func TestReadProcessEnvSelf(t *testing.T) { + p, err := ReadProcess(os.Getpid()) + if err != nil { + t.Fatalf("ReadProcess(self): %v", err) + } + if len(p.Env) == 0 { + t.Fatal("expected a non-empty environment for self") + } + for _, e := range p.Env { + if strings.HasPrefix(strings.ToUpper(e), "PATH=") { + return + } + } + t.Errorf("PATH not found among %d env entries", len(p.Env)) +} diff --git a/internal/proc/extended_darwin.go b/internal/proc/extended_darwin.go new file mode 100644 index 0000000..9b910fc --- /dev/null +++ b/internal/proc/extended_darwin.go @@ -0,0 +1,64 @@ +//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 +} diff --git a/internal/proc/extended_darwin_test.go b/internal/proc/extended_darwin_test.go new file mode 100644 index 0000000..88a86b7 --- /dev/null +++ b/internal/proc/extended_darwin_test.go @@ -0,0 +1,28 @@ +//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) + } + }) + } +} diff --git a/internal/proc/extended_freebsd.go b/internal/proc/extended_freebsd.go new file mode 100644 index 0000000..a38779c --- /dev/null +++ b/internal/proc/extended_freebsd.go @@ -0,0 +1,70 @@ +//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 +} diff --git a/internal/proc/extended_linux.go b/internal/proc/extended_linux.go new file mode 100644 index 0000000..210b2a1 --- /dev/null +++ b/internal/proc/extended_linux.go @@ -0,0 +1,105 @@ +//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 +} diff --git a/internal/proc/extended_windows.go b/internal/proc/extended_windows.go new file mode 100644 index 0000000..8aa9723 --- /dev/null +++ b/internal/proc/extended_windows.go @@ -0,0 +1,106 @@ +//go:build windows + +package proc + +import ( + "fmt" + "syscall" + "unsafe" + + "github.com/pranshuparmar/witr/pkg/model" +) + +var ( + modpsapi = syscall.NewLazyDLL("psapi.dll") + procGetProcessMemoryInfo = modpsapi.NewProc("GetProcessMemoryInfo") + procGetProcessIoCounters = modkernel32.NewProc("GetProcessIoCounters") + procGetProcessHandleCount = modkernel32.NewProc("GetProcessHandleCount") +) + +// processMemoryCountersEx mirrors PROCESS_MEMORY_COUNTERS_EX. The CB field +// must be set to sizeof(struct) before GetProcessMemoryInfo so Windows can +// distinguish it from the non-EX variant. +type processMemoryCountersEx struct { + CB uint32 + PageFaultCount uint32 + PeakWorkingSetSize uintptr + WorkingSetSize uintptr + QuotaPeakPagedPoolUsage uintptr + QuotaPagedPoolUsage uintptr + QuotaPeakNonPagedPoolUsage uintptr + QuotaNonPagedPoolUsage uintptr + PagefileUsage uintptr + PeakPagefileUsage uintptr + PrivateUsage uintptr +} + +type ioCounters struct { + ReadOperationCount uint64 + WriteOperationCount uint64 + OtherOperationCount uint64 + ReadTransferCount uint64 + WriteTransferCount uint64 + OtherTransferCount uint64 +} + +// ReadExtendedInfo returns memory, I/O, thread, and handle counters for a +// PID. File descriptors and FD limit are zero-valued on Windows. +func ReadExtendedInfo(pid int) (model.MemoryInfo, model.IOStats, []string, int, uint64, int, error) { + var memInfo model.MemoryInfo + var ioStats model.IOStats + var threadCount int + var fdCount int + + // Full access first, falling back to limited access for protected processes. + handle, err := syscall.OpenProcess(PROCESS_QUERY_INFORMATION|PROCESS_VM_READ, false, uint32(pid)) + if err != nil { + handle, err = syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return memInfo, ioStats, nil, 0, 0, 0, fmt.Errorf("OpenProcess(%d): %w", pid, err) + } + } + defer syscall.CloseHandle(handle) + + var pmc processMemoryCountersEx + pmc.CB = uint32(unsafe.Sizeof(pmc)) + if ret, _, _ := procGetProcessMemoryInfo.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&pmc)), + uintptr(pmc.CB), + ); ret != 0 { + memInfo.RSS = uint64(pmc.WorkingSetSize) + memInfo.RSSMB = float64(memInfo.RSS) / (1024 * 1024) + memInfo.VMS = uint64(pmc.PrivateUsage) + memInfo.VMSMB = float64(memInfo.VMS) / (1024 * 1024) + } + + var io ioCounters + if ret, _, _ := procGetProcessIoCounters.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&io)), + ); ret != 0 { + ioStats.ReadOps = io.ReadOperationCount + ioStats.ReadBytes = io.ReadTransferCount + ioStats.WriteOps = io.WriteOperationCount + ioStats.WriteBytes = io.WriteTransferCount + } + + var hCount uint32 + if ret, _, _ := procGetProcessHandleCount.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&hCount)), + ); ret != 0 { + fdCount = int(hCount) + } + + if procs, err := enumerateProcesses(); err == nil { + for _, p := range procs { + if p.PID == pid { + threadCount = p.Threads + break + } + } + } + + return memInfo, ioStats, nil, fdCount, 0, threadCount, nil +} diff --git a/internal/proc/extended_windows_test.go b/internal/proc/extended_windows_test.go new file mode 100644 index 0000000..5751538 --- /dev/null +++ b/internal/proc/extended_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +package proc + +import ( + "os" + "testing" +) + +func TestReadExtendedInfoSelf(t *testing.T) { + mem, _, _, fdCount, _, threadCount, err := ReadExtendedInfo(os.Getpid()) + if err != nil { + t.Fatalf("ReadExtendedInfo(self): %v", err) + } + if mem.RSS == 0 { + t.Errorf("RSS = 0, want > 0") + } + if mem.RSSMB <= 0 { + t.Errorf("RSSMB = %v, want > 0", mem.RSSMB) + } + if mem.VMS == 0 { + t.Errorf("VMS = 0, want > 0") + } + if threadCount < 1 { + t.Errorf("threadCount = %d, want >= 1", threadCount) + } + if fdCount < 1 { + t.Errorf("fdCount = %d, want >= 1", fdCount) + } +} + +func TestReadExtendedInfoNonexistentPID(t *testing.T) { + if _, _, _, _, _, _, err := ReadExtendedInfo(0); err == nil { + t.Errorf("ReadExtendedInfo(0) returned no error; want OpenProcess failure") + } +} diff --git a/internal/proc/fd_darwin.go b/internal/proc/fd_darwin.go new file mode 100644 index 0000000..cc4a449 --- /dev/null +++ b/internal/proc/fd_darwin.go @@ -0,0 +1,61 @@ +//go:build darwin + +package proc + +import ( + "strconv" + "sync" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// Cached results of ListOpenPorts to avoid running lsof multiple times during +// an ancestry walk (typically 5-10 ReadProcess calls within milliseconds). +var ( + openPortsCache []model.OpenPort + openPortsCacheTime time.Time + openPortsCacheMu sync.Mutex + openPortsCacheTTL = 2 * time.Second +) + +func listOpenPortsCached() []model.OpenPort { + openPortsCacheMu.Lock() + defer openPortsCacheMu.Unlock() + + if openPortsCache != nil && time.Since(openPortsCacheTime) < openPortsCacheTTL { + return openPortsCache + } + ports, err := ListOpenPorts() + if err != nil { + return nil + } + openPortsCache = ports + openPortsCacheTime = time.Now() + return ports +} + +// socketsForPID returns every IP socket owned by a PID, including non-listening +// sockets. Backed by `lsof -i -P -n` via ListOpenPorts. +func socketsForPID(pid int) []model.Socket { + all := listOpenPortsCached() + var sockets []model.Socket + seen := make(map[string]bool) + for _, p := range all { + if p.PID != pid { + continue + } + key := p.Protocol + "|" + p.Address + "|" + strconv.Itoa(p.Port) + "|" + p.State + if seen[key] { + continue + } + seen[key] = true + sockets = append(sockets, model.Socket{ + Port: p.Port, + Address: p.Address, + Protocol: p.Protocol, + State: p.State, + }) + } + return sockets +} diff --git a/internal/proc/fd_freebsd.go b/internal/proc/fd_freebsd.go new file mode 100644 index 0000000..c71ab11 --- /dev/null +++ b/internal/proc/fd_freebsd.go @@ -0,0 +1,61 @@ +//go:build freebsd + +package proc + +import ( + "strconv" + "sync" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// Cached results of ListOpenPorts to avoid re-invoking sockstat for every +// process visited during an ancestry walk. +var ( + openPortsCache []model.OpenPort + openPortsCacheTime time.Time + openPortsCacheMu sync.Mutex + openPortsCacheTTL = 2 * time.Second +) + +func listOpenPortsCached() []model.OpenPort { + openPortsCacheMu.Lock() + defer openPortsCacheMu.Unlock() + + if openPortsCache != nil && time.Since(openPortsCacheTime) < openPortsCacheTTL { + return openPortsCache + } + ports, err := ListOpenPorts() + if err != nil { + return nil + } + openPortsCache = ports + openPortsCacheTime = time.Now() + return ports +} + +// socketsForPID returns every IP socket owned by a PID, including non-listening +// sockets. Backed by `sockstat` via ListOpenPorts. +func socketsForPID(pid int) []model.Socket { + all := listOpenPortsCached() + var sockets []model.Socket + seen := make(map[string]bool) + for _, p := range all { + if p.PID != pid { + continue + } + key := p.Protocol + "|" + p.Address + "|" + strconv.Itoa(p.Port) + "|" + p.State + if seen[key] { + continue + } + seen[key] = true + sockets = append(sockets, model.Socket{ + Port: p.Port, + Address: p.Address, + Protocol: p.Protocol, + State: p.State, + }) + } + return sockets +} diff --git a/internal/proc/fd_linux.go b/internal/proc/fd_linux.go new file mode 100644 index 0000000..ab79800 --- /dev/null +++ b/internal/proc/fd_linux.go @@ -0,0 +1,38 @@ +//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 +} diff --git a/internal/proc/filecontext_darwin.go b/internal/proc/filecontext_darwin.go new file mode 100644 index 0000000..c41dfa1 --- /dev/null +++ b/internal/proc/filecontext_darwin.go @@ -0,0 +1,156 @@ +//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. lsof may exit non-zero when one + // of the process's FDs is inaccessible while still emitting valid data + // on stdout; salvage stdout when present. + out, err := exec.Command("lsof", "-p", strconv.Itoa(pid)).Output() + if err != nil && len(out) == 0 { + 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 + // lsof may exit non-zero when one of the process's FDs is inaccessible + // while still emitting valid data on stdout; salvage stdout when present. + out, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-F", "fn").Output() + if err != nil && len(out) == 0 { + 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 || len(out2) > 0 { + 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 +} diff --git a/internal/proc/filecontext_freebsd.go b/internal/proc/filecontext_freebsd.go new file mode 100644 index 0000000..1e79c82 --- /dev/null +++ b/internal/proc/filecontext_freebsd.go @@ -0,0 +1,118 @@ +//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 +} diff --git a/internal/proc/filecontext_linux.go b/internal/proc/filecontext_linux.go new file mode 100644 index 0000000..04cb019 --- /dev/null +++ b/internal/proc/filecontext_linux.go @@ -0,0 +1,160 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/unix" + + "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) + + 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) +} + +// getLockedFiles returns the files locked by the process, read from +// /proc/locks. Paths are resolved by matching each lock's device:inode against +// the process's own open fds, keeping the work bounded to this one process. +// (lslocks resolves every lock on the system and blocks on slow mounts.) +func getLockedFiles(pid int) []string { + data, err := os.ReadFile("/proc/locks") + if err != nil { + return nil + } + + // /proc/locks line: ": + // ", with MAJOR:MINOR in hex and INODE in decimal. + type fileID struct{ dev, ino uint64 } + ids := map[fileID]string{} // -> raw "major:minor:inode" identifier for fallback + pidStr := strconv.Itoa(pid) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 8 || fields[4] != pidStr { + continue + } + parts := strings.Split(fields[5], ":") + if len(parts) != 3 { + continue + } + major, err1 := strconv.ParseUint(parts[0], 16, 32) + minor, err2 := strconv.ParseUint(parts[1], 16, 32) + ino, err3 := strconv.ParseUint(parts[2], 10, 64) + if err1 != nil || err2 != nil || err3 != nil { + continue + } + ids[fileID{unix.Mkdev(uint32(major), uint32(minor)), ino}] = fields[5] + } + if len(ids) == 0 { + return nil + } + + // Resolve paths from the process's own fds; keep the raw identifier for any + // lock that can't be matched to an open fd (e.g. an unlinked file). + paths := map[fileID]string{} + fdDir := fmt.Sprintf("/proc/%d/fd", pid) + if entries, err := os.ReadDir(fdDir); err == nil { + for _, e := range entries { + if len(paths) == len(ids) { + break + } + fdPath := fdDir + "/" + e.Name() + info, err := os.Stat(fdPath) + if err != nil { + continue + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + continue + } + id := fileID{uint64(st.Dev), uint64(st.Ino)} + if _, want := ids[id]; want { + if target, err := os.Readlink(fdPath); err == nil { + paths[id] = target + } + } + } + } + + out := make([]string, 0, len(ids)) + for id, raw := range ids { + if p := paths[id]; p != "" { + out = append(out, p) + } else { + out = append(out, raw) + } + } + sort.Strings(out) + return out +} diff --git a/internal/proc/filecontext_windows.go b/internal/proc/filecontext_windows.go new file mode 100644 index 0000000..a62432d --- /dev/null +++ b/internal/proc/filecontext_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +func GetFileContext(pid int) *model.FileContext { + return nil +} diff --git a/internal/proc/git.go b/internal/proc/git.go new file mode 100644 index 0000000..fc0f863 --- /dev/null +++ b/internal/proc/git.go @@ -0,0 +1,75 @@ +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++ { + gitPath := filepath.Join(searchDir, ".git") + if fi, err := os.Stat(gitPath); err == nil { + gitDir := gitPath + if !fi.IsDir() { + // In a worktree or submodule, .git is a file holding a + // "gitdir: " pointer to the real git directory. + gitDir = gitDirFromFile(gitPath, searchDir) + } + if gitDir != "" { + return filepath.Base(searchDir), gitBranchFromHEAD(gitDir) + } + } + + parent := filepath.Dir(searchDir) + if parent == searchDir { + break + } + searchDir = parent + } + + return "", "" +} + +// gitDirFromFile parses the "gitdir: " pointer in a .git file (used by +// worktrees and submodules) and returns the real git directory as an absolute +// path, or "" if it can't be read. +func gitDirFromFile(gitFile, baseDir string) string { + data, err := os.ReadFile(gitFile) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + rest, ok := strings.CutPrefix(strings.TrimSpace(line), "gitdir:") + if !ok { + continue + } + dir := strings.TrimSpace(rest) + if dir == "" { + return "" + } + if !filepath.IsAbs(dir) { + dir = filepath.Join(baseDir, dir) + } + return dir + } + return "" +} + +// gitBranchFromHEAD reads /HEAD and returns the checked-out branch name, +// or "" when HEAD is detached or unreadable. +func gitBranchFromHEAD(gitDir string) string { + head, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) + if err != nil { + return "" + } + if ref, ok := strings.CutPrefix(strings.TrimSpace(string(head)), "ref: "); ok { + return strings.TrimPrefix(ref, "refs/heads/") + } + return "" +} diff --git a/internal/proc/git_test.go b/internal/proc/git_test.go new file mode 100644 index 0000000..e5a06ae --- /dev/null +++ b/internal/proc/git_test.go @@ -0,0 +1,98 @@ +package proc + +import ( + "os" + "path/filepath" + "testing" +) + +func mustMkdir(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectGitInfo(t *testing.T) { + root := t.TempDir() + + // Normal repo: /repo/.git/ is a directory, HEAD on "main". + repo := filepath.Join(root, "repo") + repoGit := filepath.Join(repo, ".git") + mustMkdir(t, repoGit) + mustWrite(t, filepath.Join(repoGit, "HEAD"), "ref: refs/heads/main\n") + + if name, branch := detectGitInfo(repo); name != "repo" || branch != "main" { + t.Errorf("dir repo: got (%q, %q), want (repo, main)", name, branch) + } + + // A nested subdirectory walks up to the repo. + sub := filepath.Join(repo, "a", "b") + mustMkdir(t, sub) + if name, branch := detectGitInfo(sub); name != "repo" || branch != "main" { + t.Errorf("subdir: got (%q, %q), want (repo, main)", name, branch) + } + + // Worktree: /wt/.git is a FILE pointing at a worktree gitdir under the + // main repo, which carries its own HEAD on "feature". + wt := filepath.Join(root, "wt") + mustMkdir(t, wt) + wtGitDir := filepath.Join(repoGit, "worktrees", "wt") + mustMkdir(t, wtGitDir) + mustWrite(t, filepath.Join(wtGitDir, "HEAD"), "ref: refs/heads/feature\n") + mustWrite(t, filepath.Join(wt, ".git"), "gitdir: "+wtGitDir+"\n") + + if name, branch := detectGitInfo(wt); name != "wt" || branch != "feature" { + t.Errorf("worktree: got (%q, %q), want (wt, feature)", name, branch) + } + + // No repo anywhere up the tree. + if name, branch := detectGitInfo(root); name != "" || branch != "" { + t.Errorf("no repo: got (%q, %q), want empty", name, branch) + } +} + +func TestGitDirFromFile(t *testing.T) { + dir := t.TempDir() + + // Absolute pointer: returned unchanged. Derive the target from the temp dir + // so it is absolute on every OS — a bare "/abs/..." is not absolute on + // Windows, where git writes drive-letter paths like C:/repo/.git/worktrees/x. + absTarget := filepath.Join(dir, "real", ".git", "worktrees", "x") + mustWrite(t, filepath.Join(dir, "abs.git"), "gitdir: "+absTarget+"\n") + if got := gitDirFromFile(filepath.Join(dir, "abs.git"), dir); got != absTarget { + t.Errorf("absolute: got %q, want %q", got, absTarget) + } + + mustWrite(t, filepath.Join(dir, "rel.git"), "gitdir: ../parent/.git/worktrees/x\n") + want := filepath.Join(dir, "../parent/.git/worktrees/x") + if got := gitDirFromFile(filepath.Join(dir, "rel.git"), dir); got != want { + t.Errorf("relative: got %q, want %q", got, want) + } + + if got := gitDirFromFile(filepath.Join(dir, "missing.git"), dir); got != "" { + t.Errorf("missing file: got %q, want empty", got) + } +} + +func TestGitBranchFromHEAD(t *testing.T) { + dir := t.TempDir() + + mustWrite(t, filepath.Join(dir, "HEAD"), "ref: refs/heads/dev\n") + if got := gitBranchFromHEAD(dir); got != "dev" { + t.Errorf("ref HEAD: got %q, want dev", got) + } + + // Detached HEAD (raw commit) yields no branch. + mustWrite(t, filepath.Join(dir, "HEAD"), "0123456789abcdef0123456789abcdef01234567\n") + if got := gitBranchFromHEAD(dir); got != "" { + t.Errorf("detached HEAD: got %q, want empty", got) + } +} diff --git a/internal/proc/integration_linux_test.go b/internal/proc/integration_linux_test.go new file mode 100644 index 0000000..b1fd38c --- /dev/null +++ b/internal/proc/integration_linux_test.go @@ -0,0 +1,164 @@ +//go:build linux + +package proc + +import ( + "net" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" +) + +// These tests anchor on the test process itself (its PID, an fd it holds, a +// lock it takes, a port it binds) so the assertions are deterministic even +// though the real /proc has hundreds of unrelated entries. + +func TestReadExtendedInfoSelf(t *testing.T) { + mem, _, fds, fdCount, fdLimit, threads, err := ReadExtendedInfo(os.Getpid()) + if err != nil { + t.Fatalf("ReadExtendedInfo(self): %v", err) + } + if mem.RSS == 0 { + t.Error("RSS should be non-zero for a running process") + } + if fdCount == 0 || len(fds) == 0 { + t.Errorf("fdCount=%d, len(fds)=%d; both should be > 0 (stdin/out/err at minimum)", fdCount, len(fds)) + } + if fdLimit == 0 { + t.Error("fdLimit should be > 0") + } + if threads == 0 { + t.Error("threadCount should be > 0 (the Go runtime is multi-threaded)") + } +} + +func TestGetFileContextSelf(t *testing.T) { + fc := GetFileContext(os.Getpid()) + if fc == nil { + t.Fatal("GetFileContext(self) = nil") + } + if fc.OpenFiles == 0 { + t.Error("OpenFiles should be > 0") + } + if fc.FileLimit == 0 { + t.Error("FileLimit should be > 0") + } +} + +func TestGetResourceContextSelf(t *testing.T) { + // We can't assert specific resource numbers (they depend on the host), but + // the call must always return a non-nil context and exercise every reader. + if GetResourceContext(os.Getpid()) == nil { + t.Error("GetResourceContext(self) should never be nil") + } +} + +func TestGetCmdlineSelf(t *testing.T) { + if cmd := GetCmdline(os.Getpid()); cmd == "" || cmd == "(unknown)" { + t.Errorf("GetCmdline(self) = %q, want the test binary's command line", cmd) + } + if cmd := GetCmdline(0); cmd != "(unknown)" { + t.Errorf("GetCmdline(0) = %q, want \"(unknown)\" for the unreadable kernel PID", cmd) + } +} + +func TestListProcessSnapshotIncludesSelf(t *testing.T) { + snap, err := ListProcessSnapshot() + if err != nil { + t.Fatalf("ListProcessSnapshot: %v", err) + } + self := os.Getpid() + for _, p := range snap { + if p.PID == self { + return + } + } + t.Errorf("snapshot of %d processes did not include self PID %d", len(snap), self) +} + +func TestResolveChildrenFindsSpawnedChild(t *testing.T) { + if _, err := ResolveChildren(0); err == nil { + t.Error("ResolveChildren(0) should reject the invalid PID") + } + + c := exec.Command("sleep", "30") + if err := c.Start(); err != nil { + t.Skipf("cannot spawn child: %v", err) + } + defer func() { + _ = c.Process.Kill() + _ = c.Wait() + }() + + kids, err := ResolveChildren(os.Getpid()) + if err != nil { + t.Fatalf("ResolveChildren(self): %v", err) + } + for _, k := range kids { + if k.PID == c.Process.Pid { + return + } + } + t.Errorf("ResolveChildren(self) did not include the spawned child %d (got %d children)", c.Process.Pid, len(kids)) +} + +func TestListAllOpenFilesFindsHeldFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "witr-open.txt") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + self := os.Getpid() + for _, of := range ListAllOpenFiles() { + if of.PID == self && of.Path == path { + if of.Type != "OPEN" { + t.Errorf("Type = %q, want OPEN", of.Type) + } + return + } + } + t.Errorf("ListAllOpenFiles did not include our open file %q owned by PID %d", path, self) +} + +func TestListLockedFilesFindsHeldLock(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "witr-lock") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + // Take a real POSIX write lock so it shows up in /proc/locks under our PID. + lk := syscall.Flock_t{Type: syscall.F_WRLCK, Whence: 0} + if err := syscall.FcntlFlock(f.Fd(), syscall.F_SETLK, &lk); err != nil { + t.Skipf("cannot place POSIX lock: %v", err) + } + + self := os.Getpid() + for _, l := range ListLockedFiles() { + if l.PID == self { + return // our lock surfaced — resolveLockPath/lockProcessName/statKey exercised + } + } + t.Errorf("ListLockedFiles did not include a lock held by self (PID %d)", self) +} + +func TestGetSocketStateForPortFindsListener(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + info := GetSocketStateForPort(port) + if info == nil { + t.Fatalf("GetSocketStateForPort(%d) = nil, want our listener", port) + } + if info.State != "LISTEN" { + t.Errorf("socket state = %q, want LISTEN", info.State) + } +} diff --git a/internal/proc/integration_test.go b/internal/proc/integration_test.go new file mode 100644 index 0000000..f3b448c --- /dev/null +++ b/internal/proc/integration_test.go @@ -0,0 +1,128 @@ +//go:build linux || darwin || freebsd || windows + +package proc + +import ( + "net" + "os" + "testing" +) + +// Integration smoke tests for the platform-specific OS plumbing. They assert +// invariants — "the call returns *something* sensible" — not exact output, +// because the real system has hundreds of processes the test cannot know +// about. The single thing we DO know about is our own test binary plus +// anything we explicitly spawn or bind to, so every assertion is anchored on +// one of those. + +// TestIntegration_ListProcessesIncludesSelf confirms the enumerator is wired +// to a real source (ToolHelp32 on Windows, /proc on Linux, ps/sysctl on macOS +// and FreeBSD) by finding the test process in its output. +func TestIntegration_ListProcessesIncludesSelf(t *testing.T) { + procs, err := ListProcesses() + if err != nil { + t.Fatalf("ListProcesses: %v", err) + } + if len(procs) == 0 { + t.Fatalf("ListProcesses returned 0 processes; expected at least our own") + } + + self := os.Getpid() + for _, p := range procs { + if p.PID == self { + return + } + } + t.Errorf("ListProcesses did not contain self PID %d (returned %d procs)", self, len(procs)) +} + +// TestIntegration_ReadProcessSelf asserts ReadProcess produces a non-degenerate +// record for the test binary: matching PID, a parent (anything we can spawn +// has a parent), and a non-empty command name. +func TestIntegration_ReadProcessSelf(t *testing.T) { + p, err := ReadProcess(os.Getpid()) + if err != nil { + t.Fatalf("ReadProcess(self): %v", err) + } + if p.PID != os.Getpid() { + t.Errorf("ReadProcess(self).PID = %d, want %d", p.PID, os.Getpid()) + } + if p.PPID == 0 { + t.Errorf("ReadProcess(self).PPID = 0, want non-zero (every spawned process has a parent)") + } + if p.Command == "" { + t.Errorf("ReadProcess(self).Command is empty") + } +} + +// TestIntegration_ReadProcessSelfMemory is the regression guard for issue #205: +// the standard (non-verbose) per-process path must populate resident memory. +// Our own process always has a non-zero working set, so MemoryRSS must be > 0 +// on every platform. +func TestIntegration_ReadProcessSelfMemory(t *testing.T) { + p, err := ReadProcess(os.Getpid()) + if err != nil { + t.Fatalf("ReadProcess(self): %v", err) + } + if p.MemoryRSS == 0 { + t.Errorf("ReadProcess(self).MemoryRSS = 0; want non-zero resident memory") + } +} + +// TestIntegration_ReadProcessNonexistent verifies error handling for a PID +// that cannot exist (PID 0 is reserved by the kernel and never represents a +// userland process). +func TestIntegration_ReadProcessNonexistent(t *testing.T) { + if _, err := ReadProcess(0); err == nil { + t.Errorf("ReadProcess(0) returned no error; want an error") + } +} + +// TestIntegration_ResolveAncestrySelf walks the parent chain from our PID +// and confirms the chain ends with us (every implementation should produce +// init/systemd/launchd/SCM ... → self). +func TestIntegration_ResolveAncestrySelf(t *testing.T) { + chain, err := ResolveAncestry(os.Getpid()) + if err != nil { + t.Fatalf("ResolveAncestry(self): %v", err) + } + if len(chain) == 0 { + t.Fatalf("ResolveAncestry returned empty chain") + } + last := chain[len(chain)-1] + if last.PID != os.Getpid() { + t.Errorf("ancestry chain does not end in self PID; got %d, want %d", last.PID, os.Getpid()) + } + // Sanity: the chain should have at least two entries (us + a parent). + if len(chain) < 2 { + t.Errorf("ancestry chain has only %d entries; expected at least our parent too", len(chain)) + } +} + +// TestIntegration_ListOpenPortsFindsLoopbackListener binds a real TCP listener +// on a random localhost port, then asserts ListOpenPorts attributes that port +// to our test process. This is the highest-value integration test — every +// platform path (lsof on macOS, /proc on Linux, sockstat on FreeBSD, netstat +// on Windows) gets exercised end to end. +func TestIntegration_ListOpenPortsFindsLoopbackListener(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + self := os.Getpid() + + ports, err := ListOpenPorts() + if err != nil { + t.Fatalf("ListOpenPorts: %v", err) + } + + for _, p := range ports { + if p.Port == port && p.PID == self { + return + } + } + t.Errorf("ListOpenPorts did not find loopback listener on port %d owned by PID %d", port, self) +} diff --git a/internal/proc/libproc_darwin_cgo.go b/internal/proc/libproc_darwin_cgo.go new file mode 100644 index 0000000..f4f2bc6 --- /dev/null +++ b/internal/proc/libproc_darwin_cgo.go @@ -0,0 +1,247 @@ +//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 +} diff --git a/internal/proc/libproc_darwin_stub.go b/internal/proc/libproc_darwin_stub.go new file mode 100644 index 0000000..d99b0f5 --- /dev/null +++ b/internal/proc/libproc_darwin_stub.go @@ -0,0 +1,17 @@ +//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 +} diff --git a/internal/proc/libproc_darwin_test.go b/internal/proc/libproc_darwin_test.go new file mode 100644 index 0000000..795a59b --- /dev/null +++ b/internal/proc/libproc_darwin_test.go @@ -0,0 +1,19 @@ +//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") + } +} diff --git a/internal/proc/locks_darwin.go b/internal/proc/locks_darwin.go new file mode 100644 index 0000000..69d6775 --- /dev/null +++ b/internal/proc/locks_darwin.go @@ -0,0 +1,70 @@ +//go:build darwin + +package proc + +import ( + "os/exec" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListLockedFiles uses `lsof -l` to surface file locks. macOS has no +// /proc/locks equivalent; this is best-effort coverage and may take a +// noticeable beat on busy systems because lsof scans every open fd. +func ListLockedFiles() []*model.LockedFile { + // lsof may exit non-zero when it can't read one of the processes it + // scans (permission denied, process exiting mid-scan, etc.) while still + // emitting valid rows on stdout for the rest; salvage stdout when present. + out, err := exec.Command("lsof", "-l", "-n", "-P").Output() + if err != nil && len(out) == 0 { + return nil + } + + var locks []*model.LockedFile + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + // COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + if len(fields) < 9 { + continue + } + // lsof prints the lock flag inside the FD column, e.g. "5uW" (W = write lock). + fd := fields[3] + mode := lsofFDLockMode(fd) + if mode == "" { + continue + } + pid, err := strconv.Atoi(fields[1]) + if err != nil || pid <= 0 { + continue + } + path := strings.Join(fields[8:], " ") + locks = append(locks, &model.LockedFile{ + PID: pid, + Process: fields[0], + Path: path, + Type: "FLOCK", + Mode: mode, + }) + } + return locks +} + +// lsofFDLockMode extracts the lock indicator from an lsof FD column. +// Per lsof(8): trailing 'W' = write lock, 'R' = read lock, 'r'/'w' = locked +// region, 'u' = upgradable. Returns "" when no lock flag is present. +func lsofFDLockMode(fd string) string { + if fd == "" { + return "" + } + switch fd[len(fd)-1] { + case 'W', 'w': + return "WRITE" + case 'R', 'r': + return "READ" + case 'u', 'U': + return "RW" + } + return "" +} diff --git a/internal/proc/locks_freebsd.go b/internal/proc/locks_freebsd.go new file mode 100644 index 0000000..0aeb2e5 --- /dev/null +++ b/internal/proc/locks_freebsd.go @@ -0,0 +1,59 @@ +//go:build freebsd + +package proc + +import ( + "os/exec" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListLockedFiles returns file locks observable via `fstat`. FreeBSD doesn't +// expose a clean system-wide lock table the way /proc/locks does, so this is +// best-effort: lines whose path or fd flags indicate a lock are emitted. +func ListLockedFiles() []*model.LockedFile { + out, err := exec.Command("fstat").Output() + if err != nil { + return nil + } + + var locks []*model.LockedFile + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + // USER CMD PID FD MOUNT INUM MODE SZ|DV R/W [NAME] + if !lockIndicators(line) { + continue + } + pid, err := strconv.Atoi(fields[2]) + if err != nil || pid <= 0 { + continue + } + mode := "RW" + if len(fields) >= 9 { + mode = strings.ToUpper(fields[8]) + } + path := "" + if len(fields) >= 10 { + path = strings.Join(fields[9:], " ") + } + locks = append(locks, &model.LockedFile{ + PID: pid, + Process: fields[1], + Path: path, + Type: "FLOCK", + Mode: mode, + }) + } + return locks +} + +func lockIndicators(line string) bool { + return strings.Contains(line, ".lock") || + strings.Contains(line, "LOCK") || + strings.Contains(line, "/lock") +} diff --git a/internal/proc/locks_linux.go b/internal/proc/locks_linux.go new file mode 100644 index 0000000..734e284 --- /dev/null +++ b/internal/proc/locks_linux.go @@ -0,0 +1,123 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "strconv" + "strings" + "syscall" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// statKey returns the file's inode as a decimal string. /proc/locks emits +// device:inode but the device-numbering format the kernel uses there doesn't +// always match what userspace stat returns, so we match on inode alone. +// Inode collisions across filesystems are theoretically possible but +// vanishingly rare in practice. +func statKey(info os.FileInfo) string { + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d", st.Ino) +} + +// ListLockedFiles returns every file lock currently held on the system, +// parsed from /proc/locks. Inodes are resolved to paths by scanning the +// owning process's /proc//fd/* — incomplete coverage is acceptable +// (anonymous fds, vanished processes, etc. just get the device:inode literal). +func ListLockedFiles() []*model.LockedFile { + data, err := os.ReadFile("/proc/locks") + if err != nil { + return nil + } + + pathCache := make(map[int]map[string]string) + commCache := make(map[int]string) + + var out []*model.LockedFile + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + // /proc/locks: : + if len(fields) < 8 { + continue + } + lockType := fields[1] + access := fields[3] + pidStr := fields[4] + devInode := fields[5] + + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + continue + } + + path := resolveLockPath(pid, devInode, pathCache) + if path == "" { + path = devInode + } + + out = append(out, &model.LockedFile{ + PID: pid, + Process: lockProcessName(pid, commCache), + Path: path, + Type: lockType, + Mode: access, + }) + } + return out +} + +// resolveLockPath walks /proc//fd to map a /proc/locks device:inode +// entry back to a file path. Cached per-PID across all locks in this scan. +// Matches on inode (last segment of the device:inode key) for portability. +func resolveLockPath(pid int, devInode string, cache map[int]map[string]string) string { + inodeKey := devInode + if i := strings.LastIndex(inodeKey, ":"); i >= 0 { + inodeKey = inodeKey[i+1:] + } + + if m, ok := cache[pid]; ok { + return m[inodeKey] + } + + m := make(map[string]string) + cache[pid] = m + + fdDir := fmt.Sprintf("/proc/%d/fd", pid) + entries, err := os.ReadDir(fdDir) + if err != nil { + return "" + } + for _, entry := range entries { + target, err := os.Readlink(fdDir + "/" + entry.Name()) + if err != nil { + continue + } + info, err := os.Stat(target) + if err != nil { + continue + } + if key := statKey(info); key != "" { + m[key] = target + } + } + return m[inodeKey] +} + +func lockProcessName(pid int, cache map[int]string) string { + if name, ok := cache[pid]; ok { + return name + } + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + cache[pid] = "" + return "" + } + name := strings.TrimSpace(string(data)) + cache[pid] = name + return name +} diff --git a/internal/proc/locks_windows.go b/internal/proc/locks_windows.go new file mode 100644 index 0000000..5131d29 --- /dev/null +++ b/internal/proc/locks_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +// ListLockedFiles returns nil on Windows. Windows uses file sharing modes +// rather than POSIX-style advisory locks, and there's no public API for +// enumerating all current locks system-wide. +func ListLockedFiles() []*model.LockedFile { return nil } diff --git a/internal/proc/net_darwin.go b/internal/proc/net_darwin.go new file mode 100644 index 0000000..fe8da8a --- /dev/null +++ b/internal/proc/net_darwin.go @@ -0,0 +1,146 @@ +//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 +} diff --git a/internal/proc/net_darwin_test.go b/internal/proc/net_darwin_test.go new file mode 100644 index 0000000..14818b8 --- /dev/null +++ b/internal/proc/net_darwin_test.go @@ -0,0 +1,49 @@ +//go:build darwin + +package proc + +import "testing" + +func TestParseNetstatAddr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantAddr string + wantPort int + }{ + // macOS lsof / netstat use dot-separated format by default. + {name: "ipv4 dot-separated", raw: "127.0.0.1.8080", wantAddr: "127.0.0.1", wantPort: 8080}, + {name: "ipv4 colon-separated", raw: "127.0.0.1:8080", wantAddr: "127.0.0.1", wantPort: 8080}, + + // Wildcard means "all interfaces" → 0.0.0.0. + {name: "wildcard dot", raw: "*.443", wantAddr: "0.0.0.0", wantPort: 443}, + {name: "wildcard colon", raw: "*:443", wantAddr: "0.0.0.0", wantPort: 443}, + + // IPv6 forms. + {name: "ipv6 any-address bracketed dot", raw: "[::].443", wantAddr: "::", wantPort: 443}, + {name: "ipv6 any-address bracketed colon", raw: "[::]:443", wantAddr: "::", wantPort: 443}, + {name: "ipv6 loopback bracketed", raw: "[::1].8080", wantAddr: "::1", wantPort: 8080}, + {name: "ipv6 specific bracketed", raw: "[fe80::1].22", wantAddr: "fe80::1", wantPort: 22}, + + // Garbage / malformed. + {name: "empty", raw: "", wantAddr: "", wantPort: 0}, + {name: "bare star", raw: "*", wantAddr: "", wantPort: 0}, + {name: "unterminated bracket", raw: "[::1.8080", wantAddr: "", wantPort: 0}, + {name: "non-numeric port", raw: "127.0.0.1.abc", wantAddr: "", wantPort: 0}, + {name: "ipv6 with non-numeric port", raw: "[::1].abc", wantAddr: "", wantPort: 0}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotAddr, gotPort := parseNetstatAddr(tt.raw) + if gotAddr != tt.wantAddr || gotPort != tt.wantPort { + t.Errorf("parseNetstatAddr(%q) = (%q, %d), want (%q, %d)", + tt.raw, gotAddr, gotPort, tt.wantAddr, tt.wantPort) + } + }) + } +} diff --git a/internal/proc/net_freebsd.go b/internal/proc/net_freebsd.go new file mode 100644 index 0000000..a917fac --- /dev/null +++ b/internal/proc/net_freebsd.go @@ -0,0 +1,171 @@ +//go:build freebsd + +package proc + +import ( + "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 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 +} diff --git a/internal/proc/net_freebsd_test.go b/internal/proc/net_freebsd_test.go new file mode 100644 index 0000000..c6d8e8a --- /dev/null +++ b/internal/proc/net_freebsd_test.go @@ -0,0 +1,48 @@ +//go:build freebsd + +package proc + +import "testing" + +func TestParseSockstatAddr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + proto string + wantAddr string + wantPort int + }{ + // IPv4 happy paths. + {name: "ipv4 specific", raw: "127.0.0.1:8080", proto: "tcp4", wantAddr: "127.0.0.1", wantPort: 8080}, + {name: "ipv4 wildcard colon", raw: "*:443", proto: "tcp4", wantAddr: "0.0.0.0", wantPort: 443}, + {name: "ipv4 wildcard via '*' ip", raw: "*:53", proto: "udp4", wantAddr: "0.0.0.0", wantPort: 53}, + + // IPv6 happy paths — protocol disambiguates wildcards. + {name: "ipv6 wildcard via tcp6 proto", raw: "*:443", proto: "tcp6", wantAddr: "::", wantPort: 443}, + {name: "ipv6 bracketed loopback", raw: "[::1]:8080", proto: "tcp6", wantAddr: "::1", wantPort: 8080}, + {name: "ipv6 bracketed link-local", raw: "[fe80::1]:22", proto: "tcp6", wantAddr: "fe80::1", wantPort: 22}, + + // Dot-separated fallback (older FreeBSD output). + {name: "ipv4 dot-separated", raw: "127.0.0.1.8080", proto: "tcp4", wantAddr: "127.0.0.1", wantPort: 8080}, + + // Garbage / malformed. + {name: "empty", raw: "", proto: "tcp4", wantAddr: "", wantPort: 0}, + {name: "unterminated bracket", raw: "[::1:8080", proto: "tcp6", wantAddr: "", wantPort: 0}, + {name: "ipv6 missing port separator", raw: "[::1]8080", proto: "tcp6", wantAddr: "", wantPort: 0}, + {name: "non-numeric port", raw: "127.0.0.1:abc", proto: "tcp4", wantAddr: "", wantPort: 0}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotAddr, gotPort := parseSockstatAddr(tt.raw, tt.proto) + if gotAddr != tt.wantAddr || gotPort != tt.wantPort { + t.Errorf("parseSockstatAddr(%q, %q) = (%q, %d), want (%q, %d)", + tt.raw, tt.proto, gotAddr, gotPort, tt.wantAddr, tt.wantPort) + } + }) + } +} diff --git a/internal/proc/net_linux.go b/internal/proc/net_linux.go new file mode 100644 index 0000000..d73beb4 --- /dev/null +++ b/internal/proc/net_linux.go @@ -0,0 +1,197 @@ +//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 +} diff --git a/internal/proc/net_linux_test.go b/internal/proc/net_linux_test.go new file mode 100644 index 0000000..2c14f67 --- /dev/null +++ b/internal/proc/net_linux_test.go @@ -0,0 +1,139 @@ +//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) + } + }) + + } +} diff --git a/internal/proc/net_windows.go b/internal/proc/net_windows.go new file mode 100644 index 0000000..4815706 --- /dev/null +++ b/internal/proc/net_windows.go @@ -0,0 +1,154 @@ +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 +} + +// GetSocketsForPID returns every IP socket owned by a PID, including +// non-listening sockets, by parsing `netstat -ano`. +func GetSocketsForPID(pid int) []model.Socket { + out, err := exec.Command("netstat", "-ano").Output() + if err != nil { + return nil + } + + lines := strings.Split(string(out), "\n") + var sockets []model.Socket + 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, state string + if strings.HasPrefix(proto, "TCP") { + if len(fields) < 5 { + continue + } + state = fields[3] + if state == "LISTENING" { + state = "LISTEN" + } + matchPID = fields[4] + } else if strings.HasPrefix(proto, "UDP") { + state = "OPEN" + matchPID = fields[3] + } else { + continue + } + + if matchPID != pidStr { + 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 { + continue + } + key := proto + "|" + ip + "|" + portStr + "|" + state + if seen[key] { + continue + } + seen[key] = true + sockets = append(sockets, model.Socket{ + Port: port, + Address: ip, + Protocol: proto, + State: state, + }) + } + return sockets +} diff --git a/internal/proc/openfiles_darwin.go b/internal/proc/openfiles_darwin.go new file mode 100644 index 0000000..8dd7db2 --- /dev/null +++ b/internal/proc/openfiles_darwin.go @@ -0,0 +1,106 @@ +//go:build darwin + +package proc + +import ( + "os/exec" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListAllOpenFiles uses `lsof` to enumerate open files across all processes. +// Kernel-internal entries (sockets, pipes, kqueue, etc.) are filtered out so +// the result roughly resembles "files a user would recognize on disk". This +// can take a noticeable beat on busy systems. +func ListAllOpenFiles() []*model.LockedFile { + // lsof may exit non-zero when it can't read one of the processes it + // scans (permission denied, process exiting mid-scan, etc.) while still + // emitting valid rows on stdout for the rest; salvage stdout when present. + out, err := exec.Command("lsof", "-l", "-n", "-P").Output() + if err != nil && len(out) == 0 { + return nil + } + + var files []*model.LockedFile + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + // COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + if len(fields) < 9 { + continue + } + fdType := fields[4] + if !lsofTypeIsFile(fdType) { + continue + } + pid, err := strconv.Atoi(fields[1]) + if err != nil || pid <= 0 { + continue + } + path := strings.Join(fields[8:], " ") + if !isInterestingDarwinPath(path) { + continue + } + files = append(files, &model.LockedFile{ + PID: pid, + Process: fields[0], + Path: path, + Type: "OPEN", + Mode: lsofFDMode(fields[3]), + }) + } + return files +} + +// lsofTypeIsFile keeps regular files and directories; drops sockets, pipes, +// kqueue, character/block devices, and other kernel-internal handle types. +func lsofTypeIsFile(t string) bool { + switch t { + case "REG", "DIR": + return true + } + return false +} + +// isInterestingDarwinPath drops paths that are almost never useful when a +// user is asking "who has this file open?". +func isInterestingDarwinPath(p string) bool { + if p == "" || p == "/dev/null" { + return false + } + if strings.HasPrefix(p, "/dev/tty") || strings.HasPrefix(p, "/dev/ttys") { + return false + } + return true +} + +// lsofFDMode strips the lock indicators from an lsof FD column and returns +// R/W/RW based on the access mode character. Returns "" if unrecognized. +func lsofFDMode(fd string) string { + if fd == "" { + return "" + } + // Strip trailing lock indicators (W,R,r,w,u,U). + end := len(fd) + for end > 0 { + switch fd[end-1] { + case 'W', 'R', 'r', 'w', 'u', 'U', 'N', ' ': + end-- + continue + } + break + } + if end == 0 { + return "" + } + switch fd[end-1] { + case 'r': + return "R" + case 'w': + return "W" + case 'u': + return "RW" + } + return "" +} diff --git a/internal/proc/openfiles_freebsd.go b/internal/proc/openfiles_freebsd.go new file mode 100644 index 0000000..4dbd61b --- /dev/null +++ b/internal/proc/openfiles_freebsd.go @@ -0,0 +1,57 @@ +//go:build freebsd + +package proc + +import ( + "os/exec" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListAllOpenFiles uses `fstat` to enumerate open files across all processes. +// Non-file entries (pipes, sockets, kqueue) and obvious noise are dropped so +// the result resembles "files a user would recognize on disk". +func ListAllOpenFiles() []*model.LockedFile { + out, err := exec.Command("fstat").Output() + if err != nil { + return nil + } + + var files []*model.LockedFile + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + // USER CMD PID FD MOUNT INUM MODE SZ|DV R/W [NAME] + if len(fields) < 10 { + continue + } + pid, err := strconv.Atoi(fields[2]) + if err != nil || pid <= 0 { + continue + } + path := strings.Join(fields[9:], " ") + if !isInterestingFreebsdPath(path) { + continue + } + mode := strings.ToUpper(fields[8]) + files = append(files, &model.LockedFile{ + PID: pid, + Process: fields[1], + Path: path, + Type: "OPEN", + Mode: mode, + }) + } + return files +} + +func isInterestingFreebsdPath(p string) bool { + if p == "" || p == "-" || p == "/dev/null" { + return false + } + if strings.HasPrefix(p, "/dev/tty") || strings.HasPrefix(p, "/dev/pts/") { + return false + } + return true +} diff --git a/internal/proc/openfiles_linux.go b/internal/proc/openfiles_linux.go new file mode 100644 index 0000000..ec1875f --- /dev/null +++ b/internal/proc/openfiles_linux.go @@ -0,0 +1,110 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListAllOpenFiles walks /proc//fd for every visible process and +// returns one LockedFile entry per open fd that resolves to a real file +// path. Kernel-internal fds (sockets, pipes, anon_inodes, /proc, /sys, +// /dev/null, /memfd) are dropped — they're virtually never what a user is +// searching for and dominate the raw count. +// +// Type is set to "OPEN" and Mode is derived from /proc//fdinfo flags +// when readable; missing fdinfo just leaves Mode empty. +func ListAllOpenFiles() []*model.LockedFile { + procDirs, err := os.ReadDir("/proc") + if err != nil { + return nil + } + + commCache := make(map[int]string) + var out []*model.LockedFile + + for _, d := range procDirs { + if !d.IsDir() { + continue + } + pid, err := strconv.Atoi(d.Name()) + if err != nil || pid <= 0 { + continue + } + + fdDir := fmt.Sprintf("/proc/%d/fd", pid) + entries, err := os.ReadDir(fdDir) + if err != nil { + continue // permission denied or process gone + } + + for _, fd := range entries { + target, err := os.Readlink(fdDir + "/" + fd.Name()) + if err != nil { + continue + } + if !isInterestingFile(target) { + continue + } + out = append(out, &model.LockedFile{ + PID: pid, + Process: lockProcessName(pid, commCache), + Path: target, + Type: "OPEN", + Mode: fdMode(pid, fd.Name()), + }) + } + } + return out +} + +// isInterestingFile returns false for fd link targets that are virtually +// never what users are looking for when asking "who has this file open?". +func isInterestingFile(target string) bool { + switch { + case strings.HasPrefix(target, "socket:["): + case strings.HasPrefix(target, "pipe:["): + case strings.HasPrefix(target, "anon_inode:"): + case strings.HasPrefix(target, "/memfd:"): + case strings.HasPrefix(target, "/proc/"): + case strings.HasPrefix(target, "/sys/"): + case target == "/dev/null": + case strings.HasPrefix(target, "/dev/tty"): + case strings.HasPrefix(target, "/dev/pts/"): + default: + return true + } + return false +} + +// fdMode reads /proc//fdinfo/ for the O_ACCMODE flag bits and +// returns "R", "W", or "RW". Returns "" if the file isn't readable. +func fdMode(pid int, fd string) string { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/fdinfo/%s", pid, fd)) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "flags:") { + continue + } + flags, err := strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(line, "flags:")), 8, 64) + if err != nil { + return "" + } + switch flags & 3 { // O_ACCMODE = 3; 0=RDONLY, 1=WRONLY, 2=RDWR + case 0: + return "R" + case 1: + return "W" + case 2: + return "RW" + } + } + return "" +} diff --git a/internal/proc/openfiles_linux_test.go b/internal/proc/openfiles_linux_test.go new file mode 100644 index 0000000..07d9f2b --- /dev/null +++ b/internal/proc/openfiles_linux_test.go @@ -0,0 +1,23 @@ +//go:build linux + +package proc + +import "testing" + +func TestIsInterestingFile(t *testing.T) { + uninteresting := []string{ + "socket:[12345]", "pipe:[678]", "anon_inode:[eventfd]", "/memfd:foo", + "/proc/self/status", "/sys/kernel/x", "/dev/null", "/dev/tty1", "/dev/pts/0", + } + for _, p := range uninteresting { + if isInterestingFile(p) { + t.Errorf("isInterestingFile(%q) = true, want false (kernel/internal fd)", p) + } + } + interesting := []string{"/home/user/notes.txt", "/var/log/app.log", "/etc/hosts"} + for _, p := range interesting { + if !isInterestingFile(p) { + t.Errorf("isInterestingFile(%q) = false, want true (real file)", p) + } + } +} diff --git a/internal/proc/openfiles_windows.go b/internal/proc/openfiles_windows.go new file mode 100644 index 0000000..68cb6c3 --- /dev/null +++ b/internal/proc/openfiles_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +// ListAllOpenFiles is unsupported on Windows. The handle enumeration APIs +// (NtQuerySystemInformation with SystemHandleInformation) require additional +// kernel-side resolution and are out of scope here; the Locks tab is hidden +// on Windows anyway. +func ListAllOpenFiles() []*model.LockedFile { return nil } diff --git a/internal/proc/peb_windows.go b/internal/proc/peb_windows.go new file mode 100644 index 0000000..ca55301 --- /dev/null +++ b/internal/proc/peb_windows.go @@ -0,0 +1,368 @@ +//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 PPID and a fallback image name from the snapshot. + ppid, snapExe, _ := getInfoFromSnapshot(pid) + info.PPID = ppid + + // Prefer the full image path. Fall back to the snapshot's bare image name + // for minimal/system processes (System, Memory Compression, vmmemWSL) whose + // path can't be queried, so the name still resolves instead of being blank. + exePath := getProcessImageName(handleLimited) + if exePath == "" { + exePath = snapExe + } + info.Exe = exePath + if exePath != "" { + info.CommandLine = filepath.Base(exePath) + } + + // 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 = readEnvironmentBlock(handle, params.Environment) + + return nil +} + +func readProcessMemory(handle syscall.Handle, addr uintptr, dest unsafe.Pointer, size uintptr) bool { + // lpNumberOfBytesRead is a SIZE_T* (pointer-sized: 8 bytes on x64). It MUST + // be uintptr, not uint32 — a uint32 here lets the kernel write 8 bytes into + // a 4-byte slot, corrupting adjacent memory and causing nondeterministic + // crashes far from this call site. + var read uintptr + ret, _, _ := procReadProcessMem.Call( + uintptr(handle), + addr, + uintptr(dest), + size, + uintptr(unsafe.Pointer(&read)), + ) + return ret != 0 +} + +// readEnvironmentBlock reads a process's environment from its PEB. The block is +// a run of "KEY=VALUE\0" entries terminated by an empty entry (a \0\0). It is +// read in chunks until that terminator appears or a read fails, and bounded so a +// corrupt pointer can't drive an unbounded read of remote memory. +func readEnvironmentBlock(handle syscall.Handle, addr uintptr) []string { + if addr == 0 { + return nil + } + const chunkWords = 2048 // 4 KiB per read + const maxWords = 64 * 1024 // cap at 128 KiB + var block []uint16 + for len(block) < maxWords { + buf := make([]uint16, chunkWords) + if !readProcessMemory(handle, addr+uintptr(len(block)*2), unsafe.Pointer(&buf[0]), uintptr(len(buf)*2)) { + break + } + block = append(block, buf...) + if envBlockEnd(block) >= 0 { + break + } + } + return parseEnvBlock(block) +} + +// envBlockEnd returns the index of the \0\0 terminator, or -1 if not yet read. +func envBlockEnd(block []uint16) int { + for i := 0; i+1 < len(block); i++ { + if block[i] == 0 && block[i+1] == 0 { + return i + } + } + return -1 +} + +// parseEnvBlock splits the environment block into "KEY=VALUE" entries, stopping +// at the empty entry that terminates it. +func parseEnvBlock(block []uint16) []string { + var env []string + start := 0 + for i := 0; i < len(block); i++ { + if block[i] != 0 { + continue + } + if i == start { // empty entry: end of block + break + } + env = append(env, syscall.UTF16ToString(block[start:i])) + start = i + 1 + } + return env +} + +func readUnicodeString(handle syscall.Handle, us unicodeString) string { + // us.Length is a byte count. Read only whole uint16 code units, and never + // more bytes than the destination buffer holds: a malformed (odd or partial) + // Length from an incomplete PEB read must not overrun buf. + n := int(us.Length) / 2 + if n == 0 || us.Buffer == 0 { + return "" + } + buf := make([]uint16, n) + if !readProcessMemory(handle, us.Buffer, unsafe.Pointer(&buf[0]), uintptr(n*2)) { + 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) { + procs, err := enumerateProcesses() + if err != nil { + return 0, "", err + } + for _, p := range procs { + if p.PID == pid { + return p.PPID, p.Exe, nil + } + } + return 0, "", fmt.Errorf("process %d not found in snapshot", pid) +} + +// processCommandLineInformation is the NtQueryInformationProcess class (60, +// Windows 8.1+) that returns a process's command line. +const processCommandLineInformation = 60 + +// windowsProcessCmdline returns a process's full command line via +// NtQueryInformationProcess(ProcessCommandLineInformation). The kernel copies +// the command line into our own buffer, so — unlike walking the PEB with +// ReadProcessMemory — there is no remote process-memory access: inaccessible or +// unusual processes return an error, and any anomaly degrades to an empty +// string rather than faulting. Safe to call across the whole process list. +func windowsProcessCmdline(pid int) string { + handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "" + } + defer syscall.CloseHandle(handle) + + const statusInfoLengthMismatch = 0xC0000004 + bufLen := uint32(4096) + for attempt := 0; attempt < 2; attempt++ { + buf := make([]byte, bufLen) + var retLen uint32 + status, _, _ := procNtQueryInfo.Call( + uintptr(handle), + uintptr(processCommandLineInformation), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(bufLen), + uintptr(unsafe.Pointer(&retLen)), + ) + // Buffer too small: grow to the reported size and retry once. + if uint32(status) == statusInfoLengthMismatch && retLen > bufLen && retLen <= 1<<20 { + bufLen = retLen + continue + } + if status != 0 { + return "" + } + + // The buffer starts with a UNICODE_STRING whose Buffer points into the + // same buffer, just past the struct. Validate every offset before use. + us := (*unicodeString)(unsafe.Pointer(&buf[0])) + if us.Length == 0 || us.Buffer == 0 { + return "" + } + base := uintptr(unsafe.Pointer(&buf[0])) + if us.Buffer < base { + return "" + } + offset := us.Buffer - base + if offset+uintptr(us.Length) > uintptr(len(buf)) { + return "" + } + return syscall.UTF16ToString(unsafe.Slice((*uint16)(unsafe.Pointer(&buf[offset])), int(us.Length)/2)) + } + return "" +} diff --git a/internal/proc/proc_extra_linux_test.go b/internal/proc/proc_extra_linux_test.go new file mode 100644 index 0000000..3858268 --- /dev/null +++ b/internal/proc/proc_extra_linux_test.go @@ -0,0 +1,65 @@ +//go:build linux + +package proc + +import ( + "context" + "os" + "syscall" + "testing" +) + +func TestPIDBelongsToContainer(t *testing.T) { + if PIDBelongsToContainer(0, "abc") { + t.Error("a non-positive PID should never belong to a container") + } + if PIDBelongsToContainer(os.Getpid(), "") { + t.Error("an empty container id should never match") + } + // Our own cgroup won't contain a random fabricated container id. + if PIDBelongsToContainer(os.Getpid(), "deadbeefdeadbeefdeadbeefdeadbeef") { + t.Error("self should not belong to a fabricated container id") + } +} + +func TestGetLockedFilesFindsHeldLock(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "witr-flock") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + lk := syscall.Flock_t{Type: syscall.F_WRLCK, Whence: 0} + if err := syscall.FcntlFlock(f.Fd(), syscall.F_SETLK, &lk); err != nil { + t.Skipf("cannot place POSIX lock: %v", err) + } + + // getLockedFiles reads /proc/locks and resolves the held lock to the open + // file's path via this process's own fds. + got := getLockedFiles(os.Getpid()) + if len(got) == 0 { + t.Fatal("getLockedFiles(self) returned nothing while holding a lock") + } + found := false + for _, p := range got { + if p == f.Name() { + found = true + break + } + } + if !found { + t.Errorf("getLockedFiles(self) = %v, want it to include %q", got, f.Name()) + } +} + +func TestCommandAsOriginalUser(t *testing.T) { + // When not running under sudo (the common case), it behaves exactly like + // exec.CommandContext: the command and args pass through unchanged. + cmd := commandAsOriginalUser(context.Background(), "echo", "hello") + if cmd == nil { + t.Fatal("commandAsOriginalUser returned nil") + } + if len(cmd.Args) != 2 || cmd.Args[0] != "echo" || cmd.Args[1] != "hello" { + t.Errorf("cmd.Args = %v, want [echo hello]", cmd.Args) + } +} diff --git a/internal/proc/process_darwin.go b/internal/proc/process_darwin.go new file mode 100644 index 0000000..591e6d0 --- /dev/null +++ b/internal/proc/process_darwin.go @@ -0,0 +1,279 @@ +//go:build darwin + +package proc + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func ReadProcess(pid int) (model.Process, error) { + if pid <= 0 { + return model.Process{}, fmt.Errorf("invalid pid %d", pid) + } + pidStr := strconv.Itoa(pid) + + // Format: pid(0) ppid(1) uid(2) lstart(3-7) state(8) pcpu(9) rss(10) args(11+) + // ucomm is excluded because it can contain spaces (e.g. "Microsoft Teams"), + // which breaks strings.Fields parsing. The display name is derived from args instead. + cmd := exec.Command("ps", "-p", pidStr, "-o", "pid=,ppid=,uid=,lstart=,state=,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) < 11 { + 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] + + cpuPct, _ := strconv.ParseFloat(fields[9], 64) + rssKB, _ := strconv.ParseFloat(fields[10], 64) + + rawCmdline := "" + if len(fields) > 11 { + rawCmdline = strings.Join(fields[11:], " ") + } + cmdline := rawCmdline + + env := getEnvironment(pid) + cwd, binPath := getCwdAndBinaryPath(pid) + + health := "healthy" + var forked string + + switch state { + case "Z": + health = "zombie" + case "T": + health = "stopped" + } + + if health == "healthy" && cpuPct > 90 { + health = "high-cpu" + } + rssMB := rssKB / 1024 + if health == "healthy" && rssMB > 1024 { + health = "high-mem" + } + + memBytes := uint64(rssKB * 1024) + memPercent := 0.0 + if total := totalMemoryBytes(); total > 0 { + memPercent = float64(memBytes) / float64(total) * 100.0 + } + + // Display name resolution order: + // 1. filepath.Base(binPath) — binPath comes from `lsof ftxt` as a single + // unsplit line, so spaces in .app bundle paths are preserved. + // 2. `ps -p -o comm=` on its own line — also preserves spaces. + // 3. extractExecutableName(rawCmdline) — last resort. `ps -o args=` joins + // argv with single spaces and does not quote paths, so this can + // truncate names when the executable path contains spaces (issue #201). + displayName := binaryBasename(binPath) + if displayName == "" { + if commOut, commErr := exec.Command("ps", "-p", pidStr, "-o", "comm=").Output(); commErr == nil { + displayName = binaryBasename(string(commOut)) + } + } + if displayName == "" { + displayName = extractExecutableName(rawCmdline) + } + if cmdline == "" { + cmdline = displayName + } + + if ppid != 1 && displayName != "launchd" { + forked = "forked" + } else { + forked = "not-forked" + } + + user := readUserByUID(uid) + container := detectContainerFromCmdline(cmdline) + + if displayName == "docker-proxy" && container == "" { + container = resolveDockerProxyContainer(cmdline) + } + + service := detectLaunchdService(pid) + gitRepo, gitBranch := detectGitInfo(cwd) + procSockets := socketsForPID(pid) + + 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, + CPUPercent: cpuPct, + MemoryRSS: memBytes, + MemoryPercent: memPercent, + WorkingDir: cwd, + GitRepo: gitRepo, + GitBranch: gitBranch, + Container: container, + Service: service, + Sockets: procSockets, + Health: health, + Forked: forked, + Env: env, + ExeDeleted: exeDeleted, + }, nil +} + +var ( + totalMemOnce sync.Once + totalMemBytes uint64 +) + +// totalMemoryBytes returns total physical RAM in bytes via sysctl hw.memsize, +// or 0 if it can't be read. The value is constant for the machine, so it is +// resolved once rather than spawning sysctl on every ancestry hop. +func totalMemoryBytes() uint64 { + totalMemOnce.Do(func() { + out, err := exec.Command("sysctl", "-n", "hw.memsize").Output() + if err != nil { + return + } + totalMemBytes, _ = strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64) + }) + return totalMemBytes +} + +// getCwdAndBinaryPath returns the working directory and executable path for a process. +func getCwdAndBinaryPath(pid int) (cwd string, binPath string) { + cwd = "unknown" + + // lsof may exit non-zero when one of the requested FDs (e.g., txt for a + // deleted/inaccessible binary) is unavailable, but still emit valid cwd + // data on stdout. Salvage stdout when present instead of bailing out. + out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd,txt", "-F", "fn").Output() + if err != nil && len(out) == 0 { + 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 = strings.TrimSpace(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 "" +} diff --git a/internal/proc/process_darwin_test.go b/internal/proc/process_darwin_test.go new file mode 100644 index 0000000..abf3b76 --- /dev/null +++ b/internal/proc/process_darwin_test.go @@ -0,0 +1,59 @@ +//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") + } +} diff --git a/internal/proc/process_freebsd.go b/internal/proc/process_freebsd.go new file mode 100644 index 0000000..97eddfa --- /dev/null +++ b/internal/proc/process_freebsd.go @@ -0,0 +1,307 @@ +//go:build freebsd + +package proc + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func ReadProcess(pid int) (model.Process, error) { + // Reject PID 0 (and negatives): on FreeBSD `ps -p 0` returns the kernel + // swapper, which is not a real userland target. Matches the other platforms. + if pid <= 0 { + return model.Process{}, fmt.Errorf("invalid pid %d", pid) + } + pidStr := strconv.Itoa(pid) + + // Format: pid(0) ppid(1) uid(2) jid(3) state(4) pcpu(5) rss(6) lstart(7-11) args(12+) + // comm is excluded because it can contain spaces, which breaks strings.Fields parsing. + // The display name is derived from args instead. + cmd := exec.Command("ps", "-p", pidStr, + "-o", "pid=", "-o", "ppid=", "-o", "uid=", "-o", "jid=", + "-o", "state=", "-o", "pcpu=", "-o", "rss=", + "-o", "lstart=", "-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) < 12 { + 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() + } + + rawCmdline := "" + if len(fields) > 12 { + rawCmdline = strings.Join(fields[12:], " ") + } + cmdline := rawCmdline + + cwd, binPath := getCwdAndBinaryPath(pid) + env := getEnvironment(pid) + + health := "healthy" + var forked string + + // 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 health == "healthy" && cpuPct > 90 { + health = "high-cpu" + } + rssMB := rssKB / 1024 + if health == "healthy" && rssMB > 1024 { + health = "high-mem" + } + + memBytes := uint64(rssKB * 1024) + memPercent := 0.0 + if total := totalMemoryBytes(); total > 0 { + memPercent = float64(memBytes) / float64(total) * 100.0 + } + + // Display name resolution order: + // 1. filepath.Base(binPath) — binPath comes from `procstat -f` as a + // single unsplit line, preserving any spaces in the path. + // 2. `ps -p -o comm=` on its own line — also preserves spaces. + // 3. extractExecutableName(rawCmdline) — last resort. `ps -o args=` + // joins argv with single spaces and does not quote paths, so this + // can truncate names when the executable path contains spaces + // (issue #201). + displayName := binaryBasename(binPath) + if displayName == "" { + if commOut, commErr := exec.Command("ps", "-p", pidStr, "-o", "comm=").Output(); commErr == nil { + displayName = binaryBasename(string(commOut)) + } + } + if displayName == "" { + displayName = extractExecutableName(rawCmdline) + } + if cmdline == "" { + cmdline = displayName + } + + if ppid != 1 && displayName != "init" { + forked = "forked" + } else { + forked = "not-forked" + } + + user := readUserByUID(uid) + container := detectContainerFreeBSD(jid, cmdline) + + if displayName == "docker-proxy" && container == "" { + container = resolveDockerProxyContainer(cmdline) + } + + service := detectRcService(pid) + gitRepo, gitBranch := detectGitInfo(cwd) + procSockets := socketsForPID(pid) + + 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, + CPUPercent: cpuPct, + MemoryRSS: memBytes, + MemoryPercent: memPercent, + WorkingDir: cwd, + GitRepo: gitRepo, + GitBranch: gitBranch, + Container: container, + Service: service, + Sockets: procSockets, + Health: health, + Forked: forked, + Env: env, + ExeDeleted: exeDeleted, + }, nil +} + +var ( + totalMemOnce sync.Once + totalMemBytes uint64 +) + +// totalMemoryBytes returns total physical RAM in bytes via sysctl hw.physmem, +// or 0 if it can't be read. The value is constant for the machine, so it is +// resolved once rather than spawning sysctl on every ancestry hop. +func totalMemoryBytes() uint64 { + totalMemOnce.Do(func() { + out, err := exec.Command("sysctl", "-n", "hw.physmem").Output() + if err != nil { + return + } + totalMemBytes, _ = strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64) + }) + return totalMemBytes +} + +// 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)) +} diff --git a/internal/proc/process_linux.go b/internal/proc/process_linux.go new file mode 100644 index 0000000..a1ca33d --- /dev/null +++ b/internal/proc/process_linux.go @@ -0,0 +1,389 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func ReadProcess(pid int) (model.Process, error) { + if pid <= 0 { + return model.Process{}, fmt.Errorf("invalid pid %d", pid) + } + // 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 if cwd == "" { + cwd = "invalid" + } + + // Container detection + container := "" + var containerID, containerRuntime string + cgroupFile := fmt.Sprintf("/proc/%d/cgroup", pid) + if cgroupData, err := os.ReadFile(cgroupFile); err == nil { + cgroupStr := string(cgroupData) + switch { + case strings.Contains(cgroupStr, "docker"): + container = "docker" + containerRuntime = "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" + containerRuntime = "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" + containerRuntime = "crictl" + 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" + containerRuntime = "nerdctl" + 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" + } + case strings.Contains(cgroupStr, "lxc.payload"): + name := extractLXCBasedContainerName(cgroupStr) + if name != "" { + container = "lxc-based: " + name + } else { + container = "lxc-based" + } + } + } + + // 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 + } + } + } + + // Resolve the owning systemd .service from the process cgroup — a cheap file + // read with no subprocess. (This replaced a per-ancestor `systemctl status` + // probe whose parser never matched, so the field used to be empty.) + service := serviceFromCgroup(pid) + + 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 := startTimeFromTicks(bootTime(), startTicks, ticksPerSecond()) + + // Health: zombie/stopped + switch state { + case "Z": + health = "zombie" + case "T": + health = "stopped" + } + + // Flag high CPU (>2h total) and high memory (>1GB RSS). + 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 health == "healthy" && totalCPU > 60*60*2 { // >2h CPU time + health = "high-cpu" + } + pageSize := float64(os.Getpagesize()) + memBytes := rssPages * pageSize + memMB := memBytes / (1024 * 1024) + if health == "healthy" && memMB > 1024 { + health = "high-mem" + } + + memPercent := 0.0 + if total := totalMemoryBytes(); total > 0 { + memPercent = memBytes / float64(total) * 100.0 + } + + // Lifetime-average CPU%: total CPU time over wall-clock time since start. + cpuPercent := 0.0 + if wall := time.Since(startedAt).Seconds(); wall > 0 { + cpuPercent = totalCPU / wall * 100.0 + } + + user := readUser(pid) + + sockets, _ := readSocketsCached() + inodes := socketsForPID(pid) + + var procSockets []model.Socket + + // 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 { + 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 { + procSockets = append(procSockets, s) + + // Heuristic: If system allows dual-stack, we see a `::` listener, + // and there is NO explicit 0.0.0.0 listener, synthesize the implicit + // IPv4 mapping so users see what the kernel actually accepts. + if dualStackAllowed && s.State == "LISTEN" && s.Address == "::" && !ipv4Listeners[s.Port] { + v4 := s + v4.Address = "0.0.0.0" + v4.Protocol = strings.TrimSuffix(s.Protocol, "6") + procSockets = append(procSockets, v4) + } + } + } + // 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, + CPUPercent: cpuPercent, + MemoryRSS: uint64(memBytes), + MemoryPercent: memPercent, + WorkingDir: cwd, + GitRepo: gitRepo, + GitBranch: gitBranch, + Container: container, + ContainerID: containerID, + ContainerRuntime: containerRuntime, + Service: service, + Sockets: procSockets, + Health: health, + Forked: forked, + Env: env, + ExeDeleted: isBinaryDeleted(pid), + Capabilities: ReadCapabilities(pid), + }, nil +} + +var ( + totalMemOnce sync.Once + totalMemBytes uint64 +) + +// totalMemoryBytes returns total physical RAM in bytes from /proc/meminfo, or 0 +// if it can't be read. The value is constant for the machine, so it is resolved +// once rather than re-read on every ancestry hop. +func totalMemoryBytes() uint64 { + totalMemOnce.Do(func() { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + kb, _ := strconv.ParseUint(fields[1], 10, 64) + totalMemBytes = kb * 1024 + return + } + } + } + }) + return totalMemBytes +} + +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 "" +} + +func extractLXCBasedContainerName(cgroup string) string { + idx := strings.Index(cgroup, "lxc.payload.") + if idx == -1 { + return "" + } + rest := cgroup[idx+len("lxc.payload."):] + + // Only strip "user-_" prefix, not arbitrary underscores + if strings.HasPrefix(rest, "user-") { + if u := strings.Index(rest, "_"); u != -1 { + rest = rest[u+1:] + } + } + + if slash := strings.Index(rest, "/"); slash != -1 { + rest = rest[:slash] + } + return rest +} diff --git a/internal/proc/process_linux_test.go b/internal/proc/process_linux_test.go new file mode 100644 index 0000000..306d5ff --- /dev/null +++ b/internal/proc/process_linux_test.go @@ -0,0 +1,154 @@ +//go:build linux + +package proc + +import "testing" + +func TestProcessState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fields []string + want string + }{ + {name: "running", fields: []string{"R", "1", "1", "0"}, want: "R"}, + {name: "sleeping", fields: []string{"S", "1", "1"}, want: "S"}, + {name: "zombie", fields: []string{"Z"}, want: "Z"}, + {name: "stopped", fields: []string{"T"}, want: "T"}, + // /proc//stat sometimes emits trailing flag letters; only the + // first character is the canonical state code. + {name: "extra characters trimmed", fields: []string{"Sl", "1"}, want: "S"}, + {name: "empty fields", fields: nil, want: ""}, + {name: "blank state token", fields: []string{""}, want: ""}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := processState(tt.fields); got != tt.want { + t.Errorf("processState(%v) = %q, want %q", tt.fields, got, tt.want) + } + }) + } +} + +func TestExtractContainerID(t *testing.T) { + t.Parallel() + + const longID = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + + tests := []struct { + name string + cgroup string + dashPrefix string + slashPrefix string + want string + }{ + { + name: "docker dash-prefix scope (typical systemd-managed docker)", + cgroup: "0::/system.slice/docker-" + longID + ".scope", + dashPrefix: "docker-", + slashPrefix: "docker/", + want: longID, + }, + { + name: "docker slash-prefix (cgroupv1 / older docker)", + cgroup: "12:devices:/docker/" + longID, + dashPrefix: "docker-", + slashPrefix: "docker/", + want: longID, + }, + { + name: "podman libpod-prefix scope", + cgroup: "0::/user.slice/user-1000.slice/user@1000.service/app.slice/libpod-" + longID + ".scope", + dashPrefix: "libpod-", + slashPrefix: "libpod/", + want: longID, + }, + { + name: "slash-prefix path with extra ID chars uses first 64", + cgroup: "0::/docker/" + longID + "/extra", + dashPrefix: "docker-", + slashPrefix: "docker/", + want: longID, + }, + { + name: "slash-prefix path with too-short remainder returns empty", + cgroup: "0::/docker/shortid", + dashPrefix: "docker-", + slashPrefix: "docker/", + want: "", + }, + { + name: "no matching prefix", + cgroup: "0::/user.slice/user-1000.slice/session-2.scope", + dashPrefix: "docker-", + slashPrefix: "docker/", + want: "", + }, + { + name: "empty cgroup", + cgroup: "", + dashPrefix: "docker-", + slashPrefix: "docker/", + want: "", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := extractContainerID(tt.cgroup, tt.dashPrefix, tt.slashPrefix); got != tt.want { + t.Errorf("extractContainerID(%q, %q, %q) = %q, want %q", + tt.cgroup, tt.dashPrefix, tt.slashPrefix, got, tt.want) + } + }) + } +} + +func TestExtractLXCBasedContainerName(t *testing.T) { + t.Parallel() + tests := []struct { + name string + cgroup string + want string + }{ + { + name: "incus user-owned container", + cgroup: "0::/lxc.payload.user-1000_alpine-container/.lxc", + want: "alpine-container", + }, + { + name: "lxc root-owned container (no user prefix)", + cgroup: "0::/lxc.payload.my-container/.lxc", + want: "my-container", + }, + { + name: "lxc container with underscores in name", + cgroup: "0::/lxc.payload.test_raw_lxc_container_underline/.lxc", + want: "test_raw_lxc_container_underline", + }, + { + name: "non-lxc cgroup (docker)", + cgroup: "0::/system.slice/docker-abc123.scope", + want: "", + }, + { + name: "empty cgroup", + cgroup: "", + want: "", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := extractLXCBasedContainerName(tt.cgroup); got != tt.want { + t.Errorf("extractLXCBasedContainerName(%q) = %q, want %q", tt.cgroup, got, tt.want) + } + }) + } +} diff --git a/internal/proc/process_list_darwin.go b/internal/proc/process_list_darwin.go new file mode 100644 index 0000000..566ff7a --- /dev/null +++ b/internal/proc/process_list_darwin.go @@ -0,0 +1,147 @@ +//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, args. + // comm is excluded from this row because it can contain spaces (e.g. + // "Microsoft Teams") which breaks the strings.Fields column parse used below; + // it is fetched separately via readPIDCommMap() so the display name is taken + // from an unambiguous source rather than re-derived from the space-joined args. + out, err := exec.Command("ps", "-axo", "pid,ppid,user,lstart,%cpu,rss,%mem,args").Output() + if err != nil { + // Fallback to fast snapshot if ps fails + return ListProcessSnapshot() + } + + commMap := readPIDCommMap() + + 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) = 11 + if len(fields) < 11 { + 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) + + cmdline := "" + if len(fields) > 11 { + cmdline = strings.Join(fields[11:], " ") + } + + // Prefer the comm value (full executable path on macOS, captured + // separately so embedded spaces survive) over an args-based extractor + // which would mis-split paths like "/Applications/Microsoft Teams.app/...". + displayName := binaryBasename(commMap[pid]) + if displayName == "" { + displayName = extractExecutableName(cmdline) + } + if displayName == "" && len(fields) > 11 { + displayName = fields[11] + } + if displayName == "" { + continue + } + if cmdline == "" { + cmdline = displayName + } + + 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 +} diff --git a/internal/proc/process_list_freebsd.go b/internal/proc/process_list_freebsd.go new file mode 100644 index 0000000..497c82f --- /dev/null +++ b/internal/proc/process_list_freebsd.go @@ -0,0 +1,149 @@ +//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, args. + // comm is excluded from this row because it can contain spaces which breaks + // the strings.Fields column parse used below; it is fetched separately via + // readPIDCommMap() so the display name comes from an unambiguous source + // instead of being re-derived from the space-joined args. + // LC_ALL=C is required so lstart yields the expected 5-token English format; + // otherwise field offsets shift and %cpu picks up the rss column + cmd := exec.Command("ps", "-axo", "pid,ppid,user,lstart,%cpu,rss,%mem,args") + cmd.Env = buildEnvForPS() + out, err := cmd.Output() + if err != nil { + // Fallback to fast snapshot if ps fails + return ListProcessSnapshot() + } + + commMap := readPIDCommMap() + + 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) = 11 + if len(fields) < 11 { + 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) + + cmdline := "" + if len(fields) > 11 { + cmdline = strings.Join(fields[11:], " ") + } + + // Prefer the separately-captured comm value over an args-based extractor + // so paths containing spaces are not mis-split. + displayName := binaryBasename(commMap[pid]) + if displayName == "" { + displayName = extractExecutableName(cmdline) + } + if displayName == "" && len(fields) > 11 { + displayName = fields[11] + } + if displayName == "" { + continue + } + if cmdline == "" { + cmdline = displayName + } + + 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 +} diff --git a/internal/proc/process_list_linux.go b/internal/proc/process_list_linux.go new file mode 100644 index 0000000..1b0b2a8 --- /dev/null +++ b/internal/proc/process_list_linux.go @@ -0,0 +1,174 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListProcesses returns all running processes with the columns the TUI renders +// (PID, PPID, command, user, start time, CPU%, RSS, mem%, command line). It +// reads /proc directly instead of forking `ps -axo`, computing the same +// lifetime-average CPU% that ps reports — no subprocess per refresh. +func ListProcesses() ([]model.Process, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil, fmt.Errorf("read /proc: %w", err) + } + + // Per-list invariants, computed once rather than per process. + ticks := ticksPerSecond() + boot := bootTime() + totalMem := float64(totalMemoryBytes()) + pageSize := float64(os.Getpagesize()) + + 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 + } + if p, ok := readProcessListEntry(pid, ticks, boot, totalMem, pageSize); ok { + processes = append(processes, p) + } + } + return processes, nil +} + +// readProcessListEntry reads the TUI list columns for one PID from /proc, +// mirroring the fields the old `ps -axo` invocation produced. Returns ok=false +// when the process vanished mid-read or its stat is malformed. +func readProcessListEntry(pid, ticks int, boot time.Time, totalMem, pageSize float64) (model.Process, bool) { + stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return model.Process{}, false + } + raw := string(stat) + open := strings.Index(raw, "(") + closeParen := strings.LastIndex(raw, ")") + if open == -1 || closeParen == -1 || closeParen+2 >= len(raw) { + return model.Process{}, false + } + comm := raw[open+1 : closeParen] + fields := strings.Fields(raw[closeParen+2:]) + if len(fields) < 22 { + return model.Process{}, false + } + + ppid, _ := strconv.Atoi(fields[1]) + utime, _ := strconv.ParseFloat(fields[11], 64) + stime, _ := strconv.ParseFloat(fields[12], 64) + startTicks, _ := strconv.ParseInt(fields[19], 10, 64) + rssPages, _ := strconv.ParseFloat(fields[21], 64) + + startedAt := startTimeFromTicks(boot, startTicks, ticks) + memBytes := rssPages * pageSize + + // Lifetime-average CPU%: total CPU time over wall-clock since start (what ps reports). + cpuPercent := 0.0 + if wall := time.Since(startedAt).Seconds(); wall > 0 { + cpuPercent = (utime + stime) / float64(ticks) / wall * 100.0 + } + memPercent := 0.0 + if totalMem > 0 { + memPercent = memBytes / totalMem * 100.0 + } + + cmdline := "" + if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)); err == nil { + cmdline = strings.TrimSpace(strings.ReplaceAll(string(b), "\x00", " ")) + } + displayName := deriveDisplayCommand(comm, cmdline) + if displayName == "" { + displayName = comm + } + if cmdline == "" { + cmdline = displayName + } + + return model.Process{ + PID: pid, + PPID: ppid, + Command: displayName, + Cmdline: cmdline, + User: readUser(pid), + StartedAt: startedAt, + CPUPercent: cpuPercent, + MemoryRSS: uint64(memBytes), + MemoryPercent: memPercent, + }, true +} + +// 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, ")") + // close+2 >= len(raw) guards the raw[close+2:] slice below: a stat ending at + // the comm's ')' would otherwise panic. Matches ReadProcess's bounds check. + if open == -1 || close == -1 || close <= open || close+2 >= len(raw) { + 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 +} diff --git a/internal/proc/process_list_linux_test.go b/internal/proc/process_list_linux_test.go new file mode 100644 index 0000000..5574e86 --- /dev/null +++ b/internal/proc/process_list_linux_test.go @@ -0,0 +1,42 @@ +//go:build linux + +package proc + +import "testing" + +func TestParseStatSnapshot(t *testing.T) { + tests := []struct { + name string + pid int + stat string + wantPPID int + wantCmd string + wantErr bool + }{ + {"simple", 42, "42 (bash) S 1 42 42 0 -1 4194560", 1, "bash", false}, + // comm can contain spaces and nested parens; the parser must split on the + // LAST ')', not the first, to recover the real command and PPID. + {"comm with spaces and parens", 100, "100 (my (weird) proc) S 7 1 1 0", 7, "my (weird) proc", false}, + {"no parens is an error", 1, "garbage without parens", 0, "", true}, + {"truncated after comm is an error", 5, "5 (x) S", 0, "", true}, + // A stat ending exactly at the comm's ')' must error, not panic on the + // raw[close+2:] slice. + {"ends at comm close paren is an error", 5, "5 (x)", 0, "", true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + p, err := parseStatSnapshot(tt.pid, []byte(tt.stat)) + if (err != nil) != tt.wantErr { + t.Fatalf("parseStatSnapshot err = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if p.PID != tt.pid || p.PPID != tt.wantPPID || p.Command != tt.wantCmd { + t.Errorf("got {PID:%d PPID:%d Cmd:%q}, want {PID:%d PPID:%d Cmd:%q}", + p.PID, p.PPID, p.Command, tt.pid, tt.wantPPID, tt.wantCmd) + } + }) + } +} diff --git a/internal/proc/process_list_parity_linux_test.go b/internal/proc/process_list_parity_linux_test.go new file mode 100644 index 0000000..1e43d56 --- /dev/null +++ b/internal/proc/process_list_parity_linux_test.go @@ -0,0 +1,75 @@ +//go:build linux + +package proc + +import ( + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func findListedPID(procs []model.Process, pid int) *model.Process { + for i := range procs { + if procs[i].PID == pid { + return &procs[i] + } + } + return nil +} + +// TestListProcessesSelf checks the /proc-based ListProcesses (which replaced the +// `ps -axo` fork) produces self with plausible columns, and that the values it +// computes from /proc track what ps reports. +func TestListProcessesSelf(t *testing.T) { + procs, err := ListProcesses() + if err != nil { + t.Fatalf("ListProcesses: %v", err) + } + + me := findListedPID(procs, os.Getpid()) + if me == nil { + t.Fatalf("ListProcesses did not include self (pid %d)", os.Getpid()) + } + if me.MemoryRSS == 0 { + t.Error("self RSS should be > 0") + } + if me.Command == "" { + t.Error("self Command should be non-empty") + } + if me.User == "" { + t.Error("self User should be non-empty") + } + if me.PPID == 0 { + t.Error("self PPID should be non-zero") + } + if me.CPUPercent < 0 || me.CPUPercent > float64(runtime.NumCPU())*100+1 { + t.Errorf("self CPU%% = %v is implausible", me.CPUPercent) + } + + // RSS parity with ps (50% tolerance — resident memory drifts between the two reads). + if out, err := exec.Command("ps", "-p", strconv.Itoa(os.Getpid()), "-o", "rss=").Output(); err == nil { + if psKB, perr := strconv.ParseFloat(strings.TrimSpace(string(out)), 64); perr == nil && psKB > 0 { + psRSS := psKB * 1024 + if ratio := float64(me.MemoryRSS) / psRSS; ratio < 0.5 || ratio > 2.0 { + t.Errorf("RSS parity off: witr=%d bytes, ps=%.0f bytes (ratio %.2f)", me.MemoryRSS, psRSS, ratio) + } + } + } + + // PID 1 is long-lived, so its lifetime-average CPU% should closely track ps's + // (same formula, same /proc source) — a gross formula error would blow past 1%. + if p1 := findListedPID(procs, 1); p1 != nil { + if out, err := exec.Command("ps", "-p", "1", "-o", "%cpu=").Output(); err == nil { + if psCPU, perr := strconv.ParseFloat(strings.TrimSpace(string(out)), 64); perr == nil { + if diff := p1.CPUPercent - psCPU; diff > 1.0 || diff < -1.0 { + t.Errorf("PID 1 CPU%% parity: witr=%.3f, ps=%.3f", p1.CPUPercent, psCPU) + } + } + } + } +} diff --git a/internal/proc/process_list_windows.go b/internal/proc/process_list_windows.go new file mode 100644 index 0000000..229e442 --- /dev/null +++ b/internal/proc/process_list_windows.go @@ -0,0 +1,60 @@ +//go:build windows + +package proc + +import ( + "github.com/pranshuparmar/witr/pkg/model" +) + +// ListProcesses returns all running processes with the columns the TUI renders: +// PID/PPID/command name, owner, start time, command line, CPU% and resident +// memory. Each process is opened to read its metrics; fields that can't be read +// (protected/system processes without elevation) are left empty rather than +// failing the whole list. The TUI loads this asynchronously. +// +// The command line is read via NtQueryInformationProcess (windowsProcessCmdline) +// rather than a PEB walk, so it performs no remote process-memory access and is +// safe to call across every process. +func ListProcesses() ([]model.Process, error) { + procs, err := enumerateProcesses() + if err != nil { + return nil, err + } + + out := make([]model.Process, 0, len(procs)) + for _, p := range procs { + rss, cpu, cpuTime, started := windowsProcMetrics(p.PID) + out = append(out, model.Process{ + PID: p.PID, + PPID: p.PPID, + Command: p.Exe, + Cmdline: windowsProcessCmdline(p.PID), + User: readUser(p.PID), + StartedAt: started, + CPUPercent: cpu, + MemoryRSS: rss, + MemoryPercent: windowsMemoryPercent(rss), + Health: windowsHealth(rss, cpuTime), + }) + } + return out, nil +} + +// ListProcessSnapshot collects a lightweight view of running processes for +// child/descendant discovery. Backed by ToolHelp32 (no PowerShell, no WMI) so +// it never blocks on a stalled CIM provider. +func ListProcessSnapshot() ([]model.Process, error) { + procs, err := enumerateProcesses() + if err != nil { + return nil, err + } + out := make([]model.Process, 0, len(procs)) + for _, p := range procs { + out = append(out, model.Process{ + PID: p.PID, + PPID: p.PPID, + Command: p.Exe, + }) + } + return out, nil +} diff --git a/internal/proc/process_windows.go b/internal/proc/process_windows.go new file mode 100644 index 0000000..1af43dd --- /dev/null +++ b/internal/proc/process_windows.go @@ -0,0 +1,83 @@ +//go:build windows + +package proc + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func ReadProcess(pid int) (model.Process, error) { + // PID 0 is the System Idle Process on Windows (and negative PIDs are never + // valid), so reject them rather than returning the idle pseudo-process — + // this matches the other platforms, where /proc/0 or `ps -p 0` fails. + if pid <= 0 { + return model.Process{}, fmt.Errorf("invalid pid %d", pid) + } + info, err := GetProcessDetailedInfo(pid) + if err != nil { + return model.Process{}, err + } + + name := "" + if info.Exe != "" { + name = filepath.Base(info.Exe) + } + + procSockets := GetSocketsForPID(pid) + serviceName := detectWindowsServiceSource(pid) + container := detectContainerFromCmdline(info.CommandLine) + gitRepo, gitBranch := detectGitInfo(info.Cwd) + + // Resident memory (working set) and lifetime-average CPU%. CPU mirrors the + // figure shown in the verbose report (ResourceContext) so every output mode + // reports the same value. + rss, cpu, cpuTime, _ := windowsProcMetrics(pid) + + return model.Process{ + PID: pid, + PPID: info.PPID, + Command: name, + Cmdline: info.CommandLine, + Exe: info.Exe, + StartedAt: info.StartedAt, + User: readUser(pid), + CPUPercent: cpu, + MemoryRSS: rss, + MemoryPercent: windowsMemoryPercent(rss), + WorkingDir: info.Cwd, + GitRepo: gitRepo, + GitBranch: gitBranch, + Sockets: procSockets, + Health: windowsHealth(rss, cpuTime), + Forked: "unknown", + Env: info.Env, + Service: serviceName, + Container: container, + ExeDeleted: isWindowsBinaryDeleted(info.Exe), + }, nil +} + +func isWindowsBinaryDeleted(path string) bool { + // A non-absolute path means we only recovered the bare image name from the + // process snapshot (the case for protected/system processes we couldn't + // open, e.g. vmmemWSL) — that's "couldn't read the real path", not a + // confirmed-deleted binary, so don't raise the deleted-binary warning. + if path == "" || !filepath.IsAbs(path) { + return false + } + _, err := os.Stat(path) + return os.IsNotExist(err) +} + +// detectWindowsServiceSource returns the Windows service name that owns the PID, if any. +func detectWindowsServiceSource(pid int) string { + services, err := serviceMapForPIDs() + if err != nil { + return "" + } + return services[pid] +} diff --git a/internal/proc/process_windows_test.go b/internal/proc/process_windows_test.go new file mode 100644 index 0000000..02c3c04 --- /dev/null +++ b/internal/proc/process_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +package proc + +import ( + "os" + "path/filepath" + "testing" +) + +// TestIsWindowsBinaryDeleted guards the issue #205 false positive: protected +// processes (e.g. vmmemWSL) only expose a bare image name via the process +// snapshot, which must be treated as "path unknown", not "binary deleted". +func TestIsWindowsBinaryDeleted(t *testing.T) { + // Bare image names and empty strings are not confirmed-deleted binaries. + for _, name := range []string{"", "vmmemWSL", "System", "Registry", "wslservice.exe"} { + if isWindowsBinaryDeleted(name) { + t.Errorf("isWindowsBinaryDeleted(%q) = true; bare/empty names must be treated as unknown, not deleted", name) + } + } + + // The running test binary is a real, existing absolute path. + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + if isWindowsBinaryDeleted(exe) { + t.Errorf("isWindowsBinaryDeleted(%q) = true; the running test binary exists", exe) + } + + // An absolute path that does not exist is a genuine deleted binary. + missing := filepath.Join(os.TempDir(), "witr-nonexistent-binary-xyz.exe") + if !isWindowsBinaryDeleted(missing) { + t.Errorf("isWindowsBinaryDeleted(%q) = false; want true for a missing absolute path", missing) + } +} diff --git a/internal/proc/psenv_unix.go b/internal/proc/psenv_unix.go new file mode 100644 index 0000000..e670596 --- /dev/null +++ b/internal/proc/psenv_unix.go @@ -0,0 +1,55 @@ +//go:build darwin || freebsd + +package proc + +import ( + "os" + "os/exec" + "strconv" + "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 +} + +// readPIDCommMap returns a pid->comm map produced by a single `ps -axo pid=,comm=` +// invocation. On macOS comm holds the full executable path (spaces included); +// on FreeBSD it holds the short command name. Either way the value is the +// authoritative source for the display name and is parsed by splitting on the +// first whitespace only, so values containing spaces survive intact. +func readPIDCommMap() map[int]string { + cmd := exec.Command("ps", "-axo", "pid=,comm=") + cmd.Env = buildEnvForPS() + out, err := cmd.Output() + if err != nil { + return nil + } + m := make(map[int]string) + for _, line := range strings.Split(string(out), "\n") { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + continue + } + idx := strings.IndexAny(trimmed, " \t") + if idx < 0 { + continue + } + pid, err := strconv.Atoi(trimmed[:idx]) + if err != nil { + continue + } + comm := strings.TrimSpace(trimmed[idx+1:]) + if comm != "" { + m[pid] = comm + } + } + return m +} diff --git a/internal/proc/resource_darwin.go b/internal/proc/resource_darwin.go new file mode 100644 index 0000000..d980788 --- /dev/null +++ b/internal/proc/resource_darwin.go @@ -0,0 +1,153 @@ +//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 "" +} + +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 +} diff --git a/internal/proc/resource_freebsd.go b/internal/proc/resource_freebsd.go new file mode 100644 index 0000000..41adc30 --- /dev/null +++ b/internal/proc/resource_freebsd.go @@ -0,0 +1,44 @@ +//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 +} diff --git a/internal/proc/resource_linux.go b/internal/proc/resource_linux.go new file mode 100644 index 0000000..df55452 --- /dev/null +++ b/internal/proc/resource_linux.go @@ -0,0 +1,193 @@ +//go:build linux + +package proc + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/godbus/dbus/v5" + "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) + + // Compute CPU% once and derive both the usage figure and the energy-impact + // label from it, instead of recomputing CPU via a second (slower) `top`. + if cpu, err := GetCPUPercent(pid, true); err == nil { + ctx.CPUUsage = cpu + ctx.EnergyImpact = energyImpactLabel(cpu) + } + 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 { + conn, err := dbus.SystemBus() + if err != nil { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // org.freedesktop.login1.Manager.ListInhibitors returns a(ssssuu): + // (what, who, why, mode, uid, pid). Reading it over D-Bus avoids forking + // `systemd-inhibit --list` on every resource lookup. SystemBus() returns a + // shared connection, so we don't close it here. + var inhibitors []struct { + What, Who, Why, Mode string + UID, PID uint32 + } + obj := conn.Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1")) + if err := obj.CallWithContext(ctx, "org.freedesktop.login1.Manager.ListInhibitors", 0).Store(&inhibitors); err != nil { + return false + } + for _, in := range inhibitors { + if int(in.PID) != pid { + continue + } + what := strings.ToLower(in.What) + if strings.Contains(what, "sleep") || strings.Contains(what, "idle") || strings.Contains(what, "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" +} + +// energyImpactLabel maps a CPU-usage percentage to a coarse energy-impact band. +func energyImpactLabel(cpu float64) string { + 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 +} diff --git a/internal/proc/resource_windows.go b/internal/proc/resource_windows.go new file mode 100644 index 0000000..96f128f --- /dev/null +++ b/internal/proc/resource_windows.go @@ -0,0 +1,170 @@ +//go:build windows + +package proc + +import ( + "sync" + "syscall" + "time" + "unsafe" + + "github.com/pranshuparmar/witr/pkg/model" +) + +var procGlobalMemoryStatusEx = modkernel32.NewProc("GlobalMemoryStatusEx") + +// memoryStatusEx mirrors MEMORYSTATUSEX. dwLength must be set to sizeof(struct) +// before GlobalMemoryStatusEx is called. +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +var ( + totalPhysOnce sync.Once + totalPhysVal uint64 +) + +// windowsTotalPhysicalMemory returns total physical RAM in bytes. The value is +// fixed for the life of the process, so it's read once and cached. +func windowsTotalPhysicalMemory() uint64 { + totalPhysOnce.Do(func() { + var ms memoryStatusEx + ms.Length = uint32(unsafe.Sizeof(ms)) + if ret, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))); ret != 0 { + totalPhysVal = ms.TotalPhys + } + }) + return totalPhysVal +} + +// windowsMemoryPercent expresses a resident byte count as a percentage of total +// physical RAM. +func windowsMemoryPercent(rss uint64) float64 { + total := windowsTotalPhysicalMemory() + if total == 0 { + return 0 + } + return float64(rss) / float64(total) * 100.0 +} + +// windowsProcMetrics opens a process once and returns its resident set size +// (WorkingSetSize, in bytes), lifetime-average CPU%, total CPU time, and start +// time. All are zero values when the handle can't be opened, which is expected +// for protected/system processes without elevation — callers keep the identity +// fields and report zeros rather than failing. It reads only fixed-size kernel +// structures (no remote process-memory access), so it's safe to call across the +// full process list. +func windowsProcMetrics(pid int) (rss uint64, cpu float64, cpuTime time.Duration, started time.Time) { + handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return 0, 0, 0, time.Time{} + } + defer syscall.CloseHandle(handle) + + var pmc processMemoryCountersEx + pmc.CB = uint32(unsafe.Sizeof(pmc)) + if ret, _, _ := procGetProcessMemoryInfo.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&pmc)), + uintptr(pmc.CB), + ); ret != 0 { + rss = uint64(pmc.WorkingSetSize) + } + + var creation, exit, kernel, user syscall.Filetime + if ret, _, _ := procGetProcessTimes.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&creation)), + uintptr(unsafe.Pointer(&exit)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ); ret != 0 { + started = time.Unix(0, creation.Nanoseconds()) + cpuTime = filetimeTicksToDuration(kernel) + filetimeTicksToDuration(user) + wall := time.Since(started) + if wall > 0 { + cpu = float64(cpuTime) / float64(wall) * 100.0 + } + } + + return rss, cpu, cpuTime, started +} + +// windowsHealth derives a health status from a process's resident memory and +// total CPU time. Windows has no zombie/stopped equivalent, so it reports the +// resource conditions, matching the Unix >2h CPU and >1GiB RSS thresholds. +func windowsHealth(rss uint64, cpuTime time.Duration) string { + switch { + case cpuTime > 2*time.Hour: + return "high-cpu" + case rss > 1<<30: // 1 GiB + return "high-mem" + default: + return "healthy" + } +} + +// GetResourceContext returns CPU and memory usage for a process. +// +// CPU usage is the lifetime average — total kernel + user CPU time divided +// by wall-clock time since the process started — not an instantaneous %. +// Memory is the private commit (PrivateUsage). +func GetResourceContext(pid int) *model.ResourceContext { + handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return nil + } + defer syscall.CloseHandle(handle) + + var ( + cpu float64 + mem uint64 + ) + + var pmc processMemoryCountersEx + pmc.CB = uint32(unsafe.Sizeof(pmc)) + if ret, _, _ := procGetProcessMemoryInfo.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&pmc)), + uintptr(pmc.CB), + ); ret != 0 { + mem = uint64(pmc.PrivateUsage) + } + + var creation, exit, kernel, user syscall.Filetime + if ret, _, _ := procGetProcessTimes.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&creation)), + uintptr(unsafe.Pointer(&exit)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ); ret != 0 { + startTime := time.Unix(0, creation.Nanoseconds()) + wall := time.Since(startTime) + cpuTime := filetimeTicksToDuration(kernel) + filetimeTicksToDuration(user) + if wall > 0 { + cpu = float64(cpuTime) / float64(wall) * 100.0 + } + } + + return &model.ResourceContext{ + CPUUsage: cpu, + MemoryUsage: mem, + } +} + +// filetimeTicksToDuration treats a Filetime as a count of 100-ns ticks (the +// shape kernel/user time take in GetProcessTimes), not as an absolute +// timestamp. +func filetimeTicksToDuration(ft syscall.Filetime) time.Duration { + ticks := uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime) + return time.Duration(ticks) * 100 * time.Nanosecond +} diff --git a/internal/proc/resource_windows_test.go b/internal/proc/resource_windows_test.go new file mode 100644 index 0000000..dd2a516 --- /dev/null +++ b/internal/proc/resource_windows_test.go @@ -0,0 +1,77 @@ +//go:build windows + +package proc + +import ( + "os" + "syscall" + "testing" + "time" +) + +func TestFiletimeTicksToDuration(t *testing.T) { + tests := []struct { + name string + high uint32 + low uint32 + want time.Duration + }{ + {"zero", 0, 0, 0}, + {"one microsecond", 0, 10, time.Microsecond}, + {"one second", 0, 10_000_000, time.Second}, + {"high word only", 1, 0, time.Duration(uint64(1)<<32) * 100 * time.Nanosecond}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + ft := syscall.Filetime{HighDateTime: tt.high, LowDateTime: tt.low} + if got := filetimeTicksToDuration(ft); got != tt.want { + t.Errorf("filetimeTicksToDuration(%d:%d) = %v, want %v", + tt.high, tt.low, got, tt.want) + } + }) + } +} + +func TestGetResourceContextSelf(t *testing.T) { + rc := GetResourceContext(os.Getpid()) + if rc == nil { + t.Fatalf("GetResourceContext(self) = nil") + } + if rc.MemoryUsage == 0 { + t.Errorf("MemoryUsage = 0, want > 0") + } + if rc.CPUUsage < 0 { + t.Errorf("CPUUsage = %v, want >= 0", rc.CPUUsage) + } + if rc.CPUUsage > 10000 { + t.Errorf("CPUUsage = %v, suspiciously high", rc.CPUUsage) + } +} + +func TestGetResourceContextNonexistentPID(t *testing.T) { + if got := GetResourceContext(0); got != nil { + t.Errorf("GetResourceContext(0) = %+v, want nil", got) + } +} + +func TestWindowsHealth(t *testing.T) { + const gib = uint64(1) << 30 + tests := []struct { + name string + rss uint64 + cpuTime time.Duration + want string + }{ + {"healthy", 100 << 20, time.Minute, "healthy"}, + {"high-mem", 2 * gib, time.Minute, "high-mem"}, + {"high-cpu", 100 << 20, 3 * time.Hour, "high-cpu"}, + {"high-cpu wins over high-mem", 2 * gib, 3 * time.Hour, "high-cpu"}, + {"1 GiB exactly is not high-mem", gib, time.Minute, "healthy"}, + } + for _, tt := range tests { + if got := windowsHealth(tt.rss, tt.cpuTime); got != tt.want { + t.Errorf("windowsHealth(%d, %v) = %q, want %q", tt.rss, tt.cpuTime, got, tt.want) + } + } +} diff --git a/internal/proc/runtime_crictl.go b/internal/proc/runtime_crictl.go new file mode 100644 index 0000000..76a3c8b --- /dev/null +++ b/internal/proc/runtime_crictl.go @@ -0,0 +1,139 @@ +package proc + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func init() { registerRuntime(crictlRuntime{}) } + +type crictlRuntime struct{} + +func (crictlRuntime) Name() string { return "k8s" } +func (crictlRuntime) Available() bool { return binAvailable("crictl") } + +func (crictlRuntime) List() []*model.ContainerMatch { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "crictl", "ps", "-o", "json").Output() + if err != nil { + return nil + } + + var payload struct { + Containers []struct { + ID string `json:"id"` + Image struct{ Image string } `json:"image"` + ImageRef string `json:"imageRef"` + Metadata struct{ Name string } `json:"metadata"` + Labels map[string]string `json:"labels"` + State string `json:"state"` + CreatedAt string `json:"createdAt"` + } `json:"containers"` + } + if err := json.Unmarshal(out, &payload); err != nil { + return nil + } + + var matches []*model.ContainerMatch + for _, c := range payload.Containers { + started, _ := time.Parse(time.RFC3339Nano, c.CreatedAt) + matches = append(matches, &model.ContainerMatch{ + Runtime: "k8s", + ID: c.ID, + Name: c.Metadata.Name, + Image: c.Image.Image, + State: strings.TrimPrefix(c.State, "CONTAINER_"), + Status: strings.TrimPrefix(c.State, "CONTAINER_"), + StartedAt: started, + }) + } + return matches +} + +func (crictlRuntime) HostPID(id string) int { + info, _ := crictlInspect(id) + return info.Info.Pid +} + +// Enrich populates Command, Mounts, and a more precise StartedAt by calling +// `crictl inspect` for the resolved container. Skips fields the inspect +// payload doesn't carry; partial enrichment is fine. +func (crictlRuntime) Enrich(match *model.ContainerMatch) { + payload, ok := crictlInspect(match.ID) + if !ok { + return + } + if args := payload.Info.RuntimeSpec.Process.Args; len(args) > 0 { + match.Command = strings.Join(args, " ") + } + if len(payload.Status.Mounts) > 0 { + var parts []string + for _, m := range payload.Status.Mounts { + entry := m.HostPath + " → " + m.ContainerPath + if m.Readonly { + entry += " (ro)" + } + parts = append(parts, entry) + } + match.Mounts = strings.Join(parts, ", ") + } + if payload.Status.StartedAt != "" { + if t, err := time.Parse(time.RFC3339Nano, payload.Status.StartedAt); err == nil { + match.StartedAt = t + } + } +} + +type crictlInspectPayload struct { + Status struct { + StartedAt string `json:"startedAt"` + Mounts []struct { + ContainerPath string `json:"containerPath"` + HostPath string `json:"hostPath"` + Readonly bool `json:"readonly"` + } `json:"mounts"` + } `json:"status"` + Info struct { + Pid int `json:"pid"` + RuntimeSpec struct { + Process struct { + Args []string `json:"args"` + } `json:"process"` + } `json:"runtimeSpec"` + } `json:"info"` +} + +func crictlInspect(id string) (crictlInspectPayload, bool) { + var p crictlInspectPayload + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "crictl", "inspect", id).Output() + if err != nil { + return p, false + } + if err := json.Unmarshal(out, &p); err == nil { + return p, true + } + // Older crictl versions wrap the `info` field as a JSON-encoded string; + // unwrap and try again. + var wrapper struct { + Status json.RawMessage `json:"status"` + Info string `json:"info"` + } + if json.Unmarshal(out, &wrapper) != nil { + return p, false + } + if len(wrapper.Status) > 0 { + _ = json.Unmarshal(wrapper.Status, &p.Status) + } + if wrapper.Info != "" { + _ = json.Unmarshal([]byte(wrapper.Info), &p.Info) + } + return p, true +} diff --git a/internal/proc/runtime_docker.go b/internal/proc/runtime_docker.go new file mode 100644 index 0000000..e1ef02d --- /dev/null +++ b/internal/proc/runtime_docker.go @@ -0,0 +1,13 @@ +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +func init() { registerRuntime(dockerRuntime{}) } + +type dockerRuntime struct{} + +func (dockerRuntime) Name() string { return "docker" } +func (dockerRuntime) Available() bool { return binAvailable("docker") } +func (dockerRuntime) List() []*model.ContainerMatch { return dockerLikeList("docker", "docker") } +func (dockerRuntime) HostPID(id string) int { return dockerLikeHostPID("docker", id) } +func (dockerRuntime) Enrich(match *model.ContainerMatch) { dockerLikeEnrich("docker", match) } diff --git a/internal/proc/runtime_dockerlike.go b/internal/proc/runtime_dockerlike.go new file mode 100644 index 0000000..8f5cb61 --- /dev/null +++ b/internal/proc/runtime_dockerlike.go @@ -0,0 +1,187 @@ +package proc + +import ( + "context" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +const runtimeQueryTimeout = 3 * time.Second + +var ( + healthRe = regexp.MustCompile(`\(([^)]+)\)\s*$`) + dockerCreatedAtLayouts = []string{ + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05 -0700 -0700", + time.RFC3339Nano, + time.RFC3339, + } +) + +// rootlessBins are runtime binaries whose container state is per-user +// (rootless). When witr runs under sudo, we drop privileges back to the +// original user for these so the user's containers stay visible. +var rootlessBins = map[string]bool{ + "podman": true, + "nerdctl": true, +} + +func dockerLikeList(bin, runtime string) []*model.ContainerMatch { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + + // {{.Labels}} returns the full label map as comma-separated key=value + // pairs. The {{.Label "key"}} form is Docker-specific and fails the + // template parser on Podman, so we read all labels and pick out the + // compose ones ourselves. + format := strings.Join([]string{ + "{{.ID}}", + "{{.Names}}", + "{{.Image}}", + "{{.Command}}", + "{{.State}}", + "{{.Status}}", + "{{.CreatedAt}}", + "{{.Networks}}", + "{{.Mounts}}", + "{{.Ports}}", + "{{.Labels}}", + }, "|") + out, err := runtimeCommand(ctx, bin, "ps", "--no-trunc", "--format", format).Output() + if err != nil { + return nil + } + + var matches []*model.ContainerMatch + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 11) + if len(parts) < 11 { + continue + } + labels := parseLabelString(parts[10]) + matches = append(matches, &model.ContainerMatch{ + Runtime: runtime, + ID: parts[0], + Name: parts[1], + Image: parts[2], + Command: strings.Trim(parts[3], "\""), + State: parts[4], + Status: parts[5], + Health: healthFromStatus(parts[5]), + CreatedAt: parseDockerTime(parts[6]), + Networks: parts[7], + Mounts: parts[8], + Ports: parts[9], + ComposeProject: labels["com.docker.compose.project"], + ComposeService: labels["com.docker.compose.service"], + ComposeConfigFile: labels["com.docker.compose.project.config_files"], + ComposeWorkingDir: labels["com.docker.compose.project.working_dir"], + }) + } + return matches +} + +// parseLabelString turns "key1=val1,key2=val2" into a map. Values with embedded +// commas are uncommon for the labels we care about (paths, project names), +// so the simple split is acceptable for now. +func parseLabelString(s string) map[string]string { + out := map[string]string{} + if s == "" { + return out + } + for _, kv := range strings.Split(s, ",") { + kv = strings.TrimSpace(kv) + if i := strings.Index(kv, "="); i > 0 { + out[kv[:i]] = kv[i+1:] + } + } + return out +} + +// healthFromStatus pulls "healthy" / "unhealthy" / "starting" out of a status +// like "Up 4 minutes (healthy)". Returns "" when no health check is wired. +func healthFromStatus(status string) string { + m := healthRe.FindStringSubmatch(status) + if len(m) != 2 { + return "" + } + v := strings.ToLower(strings.TrimSpace(m[1])) + switch v { + case "healthy", "unhealthy", "health: starting", "starting": + return strings.TrimPrefix(v, "health: ") + } + return "" +} + +func parseDockerTime(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + for _, layout := range dockerCreatedAtLayouts { + if t, err := time.Parse(layout, s); err == nil { + return t + } + } + return time.Time{} +} + +func dockerLikeHostPID(bin, id string) int { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := runtimeCommand(ctx, bin, "inspect", "-f", "{{.State.Pid}}", id).Output() + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return pid +} + +// dockerLikeEnrich fills in the container's actual start time via +// ` inspect --format '{{.State.StartedAt}}'`. The list scan only gives +// us creation time, which is misleading for any container that was stopped +// and restarted later. +func dockerLikeEnrich(bin string, match *model.ContainerMatch) { + if match == nil || match.ID == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := runtimeCommand(ctx, bin, "inspect", "-f", "{{.State.StartedAt}}", match.ID).Output() + if err != nil { + return + } + s := strings.TrimSpace(string(out)) + if s == "" || s == "0001-01-01T00:00:00Z" { + return + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + match.StartedAt = t + } +} + +// runtimeCommand wraps exec.CommandContext but, for rootless-typical runtimes +// (podman, nerdctl), drops privileges back to the original user when invoked +// under sudo. Daemon-based runtimes (docker) are left as-is so users who rely +// on `sudo` for docker socket access still work. +func runtimeCommand(ctx context.Context, bin string, args ...string) *exec.Cmd { + if rootlessBins[bin] { + return commandAsOriginalUser(ctx, bin, args...) + } + return exec.CommandContext(ctx, bin, args...) +} + +func binAvailable(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} diff --git a/internal/proc/runtime_dockerlike_test.go b/internal/proc/runtime_dockerlike_test.go new file mode 100644 index 0000000..bd3d593 --- /dev/null +++ b/internal/proc/runtime_dockerlike_test.go @@ -0,0 +1,46 @@ +package proc + +import ( + "testing" +) + +func TestParseLabelString(t *testing.T) { + got := parseLabelString("com.docker.compose.project=web, com.docker.compose.service=api") + if got["com.docker.compose.project"] != "web" { + t.Errorf("project = %q, want web", got["com.docker.compose.project"]) + } + if got["com.docker.compose.service"] != "api" { + t.Errorf("service = %q, want api", got["com.docker.compose.service"]) + } + if len(parseLabelString("")) != 0 { + t.Error("empty input should yield an empty map") + } +} + +func TestHealthFromStatus(t *testing.T) { + tests := map[string]string{ + "Up 4 minutes (healthy)": "healthy", + "Up 2 seconds (unhealthy)": "unhealthy", + "Up 1 second (health: starting)": "starting", + "Up 5 minutes": "", // no health check wired + "Exited (0) 3 minutes ago": "", // parens not at end of status + } + for in, want := range tests { + if got := healthFromStatus(in); got != want { + t.Errorf("healthFromStatus(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseDockerTime(t *testing.T) { + if !parseDockerTime("").IsZero() { + t.Error("empty input should yield the zero time") + } + if !parseDockerTime("not a timestamp").IsZero() { + t.Error("garbage input should yield the zero time") + } + got := parseDockerTime("2024-01-02T15:04:05Z") + if got.IsZero() || got.Year() != 2024 { + t.Errorf("parseDockerTime(RFC3339) = %v, want a 2024 time", got) + } +} diff --git a/internal/proc/runtime_incus.go b/internal/proc/runtime_incus.go new file mode 100644 index 0000000..8db1313 --- /dev/null +++ b/internal/proc/runtime_incus.go @@ -0,0 +1,13 @@ +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +func init() { registerRuntime(incusRuntime{}) } + +type incusRuntime struct{} + +func (incusRuntime) Name() string { return "incus" } +func (incusRuntime) Available() bool { return binAvailable("incus") } +func (incusRuntime) List() []*model.ContainerMatch { return lxdLikeList("incus", "incus") } +func (incusRuntime) HostPID(id string) int { return lxdLikeHostPID("incus", id) } +func (incusRuntime) Enrich(match *model.ContainerMatch) { lxdLikeEnrich("incus", match) } diff --git a/internal/proc/runtime_jail_freebsd.go b/internal/proc/runtime_jail_freebsd.go new file mode 100644 index 0000000..eb4627f --- /dev/null +++ b/internal/proc/runtime_jail_freebsd.go @@ -0,0 +1,117 @@ +//go:build freebsd + +package proc + +import ( + "context" + "encoding/json" + "os/exec" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func init() { registerRuntime(jailRuntime{}) } + +type jailRuntime struct{} + +func (jailRuntime) Name() string { return "jail" } +func (jailRuntime) Available() bool { return binAvailable("jls") } + +func (jailRuntime) List() []*model.ContainerMatch { + if matches, ok := jailListJSON(); ok { + return matches + } + return jailListText() +} + +// jailListJSON uses libxo's JSON encoder (available on modern FreeBSD) so +// values containing whitespace are parsed unambiguously. +func jailListJSON() ([]*model.ContainerMatch, bool) { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "jls", "--libxo=json", "jid", "name", "host.hostname", "path", "dying").Output() + if err != nil { + return nil, false + } + + var payload struct { + JailInformation struct { + Jail []map[string]string `json:"jail"` + } `json:"jail-information"` + } + if err := json.Unmarshal(out, &payload); err != nil { + return nil, false + } + + var matches []*model.ContainerMatch + for _, j := range payload.JailInformation.Jail { + state := "running" + if j["dying"] != "" && j["dying"] != "0" { + state = "dying" + } + matches = append(matches, &model.ContainerMatch{ + Runtime: "jail", + ID: j["jid"], + Name: j["name"], + Image: j["host.hostname"], + Command: j["path"], + State: state, + Status: state, + }) + } + return matches, true +} + +// jailListText is the legacy whitespace-parsing fallback for FreeBSD +// releases without libxo support in jls. +func jailListText() []*model.ContainerMatch { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "jls", "-h", "jid", "name", "host.hostname", "path", "dying").Output() + if err != nil { + return nil + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + return nil + } + var matches []*model.ContainerMatch + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + state := "running" + if len(fields) >= 5 && fields[4] != "0" { + state = "dying" + } + matches = append(matches, &model.ContainerMatch{ + Runtime: "jail", + ID: fields[0], + Name: fields[1], + Image: fields[2], + Command: fields[3], + State: state, + Status: state, + }) + } + return matches +} + +func (jailRuntime) HostPID(id string) int { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "ps", "-J", id, "-o", "pid=").Output() + if err != nil { + return 0 + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil && pid > 0 { + return pid + } + } + return 0 +} diff --git a/internal/proc/runtime_lxc.go b/internal/proc/runtime_lxc.go new file mode 100644 index 0000000..6a300f0 --- /dev/null +++ b/internal/proc/runtime_lxc.go @@ -0,0 +1,82 @@ +package proc + +import ( + "context" + "encoding/json" + "os/exec" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func init() { registerRuntime(lxcRuntime{}) } + +type lxcRuntime struct{} + +func (lxcRuntime) Name() string { return "lxc" } +func (lxcRuntime) Available() bool { return binAvailable("lxc-ls") } + +// List uses `lxc-ls --fancy --format json`, which returns a flat per-container +// record with name/state/ipv4/ipv6/groups/autostart/unprivileged. Image and +// PID aren't part of the listing — PID is filled in lazily via HostPID, image +// is left empty since classic LXC doesn't track image metadata after create. +func (lxcRuntime) List() []*model.ContainerMatch { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "lxc-ls", "--fancy", "--format", "json").Output() + if err != nil { + return nil + } + return parseLXCList(out) +} + +func (lxcRuntime) HostPID(id string) int { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "lxc-info", "-n", id, "-p", "-H").Output() + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return pid +} + +type lxcLsEntry struct { + Name string `json:"name"` + State string `json:"state"` + Autostart string `json:"autostart"` + Groups string `json:"groups"` + IPv4 string `json:"ipv4"` + IPv6 string `json:"ipv6"` + Unprivileged string `json:"unprivileged"` +} + +func parseLXCList(out []byte) []*model.ContainerMatch { + var entries []lxcLsEntry + if err := json.Unmarshal(out, &entries); err != nil { + return nil + } + var matches []*model.ContainerMatch + for _, e := range entries { + state := strings.ToLower(e.State) + networks := "" + switch { + case e.IPv4 != "" && e.IPv6 != "": + networks = e.IPv4 + ", " + e.IPv6 + case e.IPv4 != "": + networks = e.IPv4 + case e.IPv6 != "": + networks = e.IPv6 + } + matches = append(matches, &model.ContainerMatch{ + Runtime: "lxc", + ID: e.Name, + Name: e.Name, + State: state, + Status: e.State, + Networks: networks, + }) + } + return matches +} diff --git a/internal/proc/runtime_lxd.go b/internal/proc/runtime_lxd.go new file mode 100644 index 0000000..e96b8a8 --- /dev/null +++ b/internal/proc/runtime_lxd.go @@ -0,0 +1,18 @@ +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +func init() { registerRuntime(lxdRuntime{}) } + +type lxdRuntime struct{} + +func (lxdRuntime) Name() string { return "lxd" } + +// LXD's client binary is `lxc`, which collides with classic LXC's CLI prefix. +// We require both `lxc` and the `lxd` daemon binary to be present so we don't +// accidentally call into classic LXC's tooling (which doesn't take a `list` +// subcommand) on a system that has lxc-* installed but no LXD. +func (lxdRuntime) Available() bool { return binAvailable("lxc") && binAvailable("lxd") } +func (lxdRuntime) List() []*model.ContainerMatch { return lxdLikeList("lxc", "lxd") } +func (lxdRuntime) HostPID(id string) int { return lxdLikeHostPID("lxc", id) } +func (lxdRuntime) Enrich(match *model.ContainerMatch) { lxdLikeEnrich("lxc", match) } diff --git a/internal/proc/runtime_lxdlike.go b/internal/proc/runtime_lxdlike.go new file mode 100644 index 0000000..6af1041 --- /dev/null +++ b/internal/proc/runtime_lxdlike.go @@ -0,0 +1,167 @@ +package proc + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// Incus and LXD share the same REST API shape (Incus is a fork of LXD), so +// `incus list --format json` and `lxc list --format json` return the same +// payload. Shared helpers here parameterize on the binary so each runtime +// stays a thin registration. + +type lxdLikeInstance struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Description string `json:"description"` + Project string `json:"project"` + Config map[string]string `json:"config"` + ExpandedDevices map[string]map[string]string `json:"expanded_devices"` + State struct { + Status string `json:"status"` + Pid int `json:"pid"` + Network map[string]lxdLikeNetworkEntry `json:"network"` + } `json:"state"` +} + +type lxdLikeNetworkEntry struct { + Addresses []struct { + Family string `json:"family"` + Address string `json:"address"` + Scope string `json:"scope"` + } `json:"addresses"` +} + +func lxdLikeList(bin, runtime string) []*model.ContainerMatch { + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "list", "--format", "json").Output() + if err != nil { + return nil + } + return parseLXDLikeList(out, runtime) +} + +func lxdLikeHostPID(bin, id string) int { + if !isValidContainerID(id) { + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "list", id, "--format", "json").Output() + if err != nil { + return 0 + } + var instances []lxdLikeInstance + if err := json.Unmarshal(out, &instances); err != nil { + return 0 + } + for _, in := range instances { + if in.Name == id { + return in.State.Pid + } + } + if len(instances) > 0 { + return instances[0].State.Pid + } + return 0 +} + +func lxdLikeEnrich(bin string, match *model.ContainerMatch) { + if match == nil || match.Name == "" || !isValidContainerID(match.Name) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), runtimeQueryTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "list", match.Name, "--format", "json").Output() + if err != nil { + return + } + var instances []lxdLikeInstance + if json.Unmarshal(out, &instances) != nil { + return + } + for _, in := range instances { + if in.Name != match.Name { + continue + } + if nets := formatLXDLikeNetworks(in.State.Network); nets != "" { + match.Networks = nets + } + if mounts := formatLXDLikeMounts(in.ExpandedDevices); mounts != "" { + match.Mounts = mounts + } + return + } +} + +func parseLXDLikeList(out []byte, runtime string) []*model.ContainerMatch { + var instances []lxdLikeInstance + if err := json.Unmarshal(out, &instances); err != nil { + return nil + } + var matches []*model.ContainerMatch + for _, in := range instances { + image := in.Config["image.description"] + if image == "" { + if os := in.Config["image.os"]; os != "" { + image = strings.TrimSpace(os + " " + in.Config["image.release"]) + } + } + state := strings.ToLower(in.Status) + created, _ := time.Parse(time.RFC3339Nano, in.CreatedAt) + matches = append(matches, &model.ContainerMatch{ + Runtime: runtime, + ID: in.Name, + Name: in.Name, + Image: image, + State: state, + Status: in.Status, + CreatedAt: created, + }) + } + return matches +} + +func formatLXDLikeNetworks(networks map[string]lxdLikeNetworkEntry) string { + var parts []string + for iface, entry := range networks { + if iface == "lo" { + continue + } + for _, addr := range entry.Addresses { + if addr.Scope == "link" || addr.Scope == "local" { + continue + } + parts = append(parts, iface+": "+addr.Address) + } + } + return strings.Join(parts, ", ") +} + +func formatLXDLikeMounts(devices map[string]map[string]string) string { + var parts []string + for name, dev := range devices { + if dev["type"] != "disk" { + continue + } + source := dev["source"] + dest := dev["path"] + if source == "" || dest == "" { + continue + } + entry := source + " → " + dest + if dev["readonly"] == "true" { + entry += " (ro)" + } + parts = append(parts, name+": "+entry) + } + return strings.Join(parts, ", ") +} diff --git a/internal/proc/runtime_nerdctl.go b/internal/proc/runtime_nerdctl.go new file mode 100644 index 0000000..9e87f5d --- /dev/null +++ b/internal/proc/runtime_nerdctl.go @@ -0,0 +1,13 @@ +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +func init() { registerRuntime(nerdctlRuntime{}) } + +type nerdctlRuntime struct{} + +func (nerdctlRuntime) Name() string { return "containerd" } +func (nerdctlRuntime) Available() bool { return binAvailable("nerdctl") } +func (nerdctlRuntime) List() []*model.ContainerMatch { return dockerLikeList("nerdctl", "containerd") } +func (nerdctlRuntime) HostPID(id string) int { return dockerLikeHostPID("nerdctl", id) } +func (nerdctlRuntime) Enrich(match *model.ContainerMatch) { dockerLikeEnrich("nerdctl", match) } diff --git a/internal/proc/runtime_podman.go b/internal/proc/runtime_podman.go new file mode 100644 index 0000000..796f7cc --- /dev/null +++ b/internal/proc/runtime_podman.go @@ -0,0 +1,13 @@ +package proc + +import "github.com/pranshuparmar/witr/pkg/model" + +func init() { registerRuntime(podmanRuntime{}) } + +type podmanRuntime struct{} + +func (podmanRuntime) Name() string { return "podman" } +func (podmanRuntime) Available() bool { return binAvailable("podman") } +func (podmanRuntime) List() []*model.ContainerMatch { return dockerLikeList("podman", "podman") } +func (podmanRuntime) HostPID(id string) int { return dockerLikeHostPID("podman", id) } +func (podmanRuntime) Enrich(match *model.ContainerMatch) { dockerLikeEnrich("podman", match) } diff --git a/internal/proc/service_linux.go b/internal/proc/service_linux.go new file mode 100644 index 0000000..d8ade25 --- /dev/null +++ b/internal/proc/service_linux.go @@ -0,0 +1,52 @@ +//go:build linux + +package proc + +import ( + "fmt" + "os" + "strings" +) + +// serviceFromCgroup derives the systemd .service unit owning pid from its cgroup +// membership (/proc//cgroup) — a cheap file read, no subprocess. The cgroup +// file is world-readable, so this works for processes the caller does not own. +// Returns "" when the process belongs to a .scope (login session, app scope) or +// to no systemd unit. +func serviceFromCgroup(pid int) string { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)) + if err != nil { + return "" + } + return serviceUnitFromCgroup(string(data)) +} + +// serviceUnitFromCgroup parses /proc//cgroup content and returns the +// deepest systemd unit the process belongs to, but only when that unit is a +// .service. A process whose nearest unit is a .scope (a session or app scope, +// or init.scope) is not a managed service, so it yields "". +func serviceUnitFromCgroup(content string) string { + for _, line := range strings.Split(content, "\n") { + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + controllers, path := parts[1], strings.TrimSpace(parts[2]) + // cgroup v2 (unified) lines have an empty controller field; cgroup v1 + // carries a name=systemd controller. Skip other v1 hierarchies, whose + // paths don't reflect unit membership. + if controllers != "" && !strings.Contains(controllers, "systemd") { + continue + } + segments := strings.Split(path, "/") + for i := len(segments) - 1; i >= 0; i-- { + switch { + case strings.HasSuffix(segments[i], ".service"): + return segments[i] + case strings.HasSuffix(segments[i], ".scope"): + return "" + } + } + } + return "" +} diff --git a/internal/proc/service_linux_test.go b/internal/proc/service_linux_test.go new file mode 100644 index 0000000..0016ea2 --- /dev/null +++ b/internal/proc/service_linux_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package proc + +import "testing" + +func TestServiceUnitFromCgroup(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {"v2 system service", "0::/system.slice/nginx.service", "nginx.service"}, + {"v2 init scope", "0::/init.scope", ""}, + {"v2 login session scope", "0::/user.slice/user-1000.slice/session-2.scope", ""}, + {"scope nested under user manager", "0::/user.slice/user-1000.slice/user@1000.service/app.slice/foo.scope", ""}, + {"user service leaf", "0::/user.slice/user-1000.slice/user@1000.service/app.slice/myapp.service", "myapp.service"}, + {"delegated sub-cgroups under a service", "0::/system.slice/myapp.service/sub/leaf", "myapp.service"}, + {"cgroup v1 systemd hierarchy", "1:name=systemd:/system.slice/sshd.service\n4:cpu,cpuacct:/system.slice/sshd.service", "sshd.service"}, + {"cgroup v1 non-systemd skipped", "4:cpu,cpuacct:/system.slice/nginx.service", ""}, + {"root only", "0::/", ""}, + {"trailing newline tolerated", "0::/system.slice/redis.service\n", "redis.service"}, + {"empty", "", ""}, + } + for _, tt := range tests { + if got := serviceUnitFromCgroup(tt.content); got != tt.want { + t.Errorf("%s: serviceUnitFromCgroup(%q) = %q, want %q", tt.name, tt.content, got, tt.want) + } + } +} diff --git a/internal/proc/services_windows.go b/internal/proc/services_windows.go new file mode 100644 index 0000000..66c9901 --- /dev/null +++ b/internal/proc/services_windows.go @@ -0,0 +1,138 @@ +//go:build windows + +package proc + +import ( + "fmt" + "sync" + "syscall" + "time" + "unsafe" +) + +const ( + scManagerEnumerateService uint32 = 0x0004 + scEnumProcessInfo uint32 = 0 + serviceWin32 uint32 = 0x00000030 + serviceStateAll uint32 = 0x00000003 +) + +var ( + modadvapi32 = syscall.NewLazyDLL("advapi32.dll") + procOpenSCManagerW = modadvapi32.NewProc("OpenSCManagerW") + procCloseServiceHandle = modadvapi32.NewProc("CloseServiceHandle") + procEnumServicesStatusExW = modadvapi32.NewProc("EnumServicesStatusExW") +) + +type serviceStatusProcess struct { + ServiceType uint32 + CurrentState uint32 + ControlsAccepted uint32 + Win32ExitCode uint32 + ServiceSpecificExitCode uint32 + CheckPoint uint32 + WaitHint uint32 + ProcessId uint32 + ServiceFlags uint32 +} + +type enumServiceStatusProcessW struct { + ServiceName *uint16 + DisplayName *uint16 + ServiceStatusProcess serviceStatusProcess +} + +var ( + serviceMapCache map[int]string + serviceMapCacheTime time.Time + serviceMapCacheMu sync.Mutex + serviceMapCacheTTL = 2 * time.Second +) + +// serviceMapForPIDs returns a PID → service-name map for every running +// Windows service. Cached so an ancestry walk pays one SCM scan, not N. +func serviceMapForPIDs() (map[int]string, error) { + serviceMapCacheMu.Lock() + defer serviceMapCacheMu.Unlock() + + if serviceMapCache != nil && time.Since(serviceMapCacheTime) < serviceMapCacheTTL { + return serviceMapCache, nil + } + + scm, _, callErr := procOpenSCManagerW.Call(0, 0, uintptr(scManagerEnumerateService)) + if scm == 0 { + return nil, fmt.Errorf("OpenSCManager: %w", callErr) + } + defer procCloseServiceHandle.Call(scm) + + // First call with a zero buffer probes for the required size. + var bytesNeeded, count, resume uint32 + procEnumServicesStatusExW.Call( + scm, + uintptr(scEnumProcessInfo), + uintptr(serviceWin32), + uintptr(serviceStateAll), + 0, 0, + uintptr(unsafe.Pointer(&bytesNeeded)), + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&resume)), + 0, + ) + if bytesNeeded == 0 { + serviceMapCache = map[int]string{} + serviceMapCacheTime = time.Now() + return serviceMapCache, nil + } + + buf := make([]byte, bytesNeeded) + count = 0 + resume = 0 + ret, _, callErr := procEnumServicesStatusExW.Call( + scm, + uintptr(scEnumProcessInfo), + uintptr(serviceWin32), + uintptr(serviceStateAll), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(bytesNeeded), + uintptr(unsafe.Pointer(&bytesNeeded)), + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&resume)), + 0, + ) + if ret == 0 { + return nil, fmt.Errorf("EnumServicesStatusEx: %w", callErr) + } + + out := make(map[int]string, count) + entrySize := unsafe.Sizeof(enumServiceStatusProcessW{}) + base := unsafe.Pointer(&buf[0]) + for i := uintptr(0); i < uintptr(count); i++ { + entry := (*enumServiceStatusProcessW)(unsafe.Pointer(uintptr(base) + i*entrySize)) + pid := int(entry.ServiceStatusProcess.ProcessId) + if pid == 0 { + // Service registered but not currently running. + continue + } + name := utf16PtrToString(entry.ServiceName) + if name == "" { + continue + } + // First writer wins so share-process hosts (svchost.exe) keep a + // stable name across calls. + if _, exists := out[pid]; !exists { + out[pid] = name + } + } + + serviceMapCache = out + serviceMapCacheTime = time.Now() + return out, nil +} + +// utf16PtrToString converts a null-terminated UTF-16 pointer to a Go string. +func utf16PtrToString(p *uint16) string { + if p == nil { + return "" + } + return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(p))[:]) +} diff --git a/internal/proc/services_windows_test.go b/internal/proc/services_windows_test.go new file mode 100644 index 0000000..5e8f534 --- /dev/null +++ b/internal/proc/services_windows_test.go @@ -0,0 +1,73 @@ +//go:build windows + +package proc + +import ( + "testing" + "time" +) + +func TestServiceMapCacheRespectsTTL(t *testing.T) { + first, err := serviceMapForPIDs() + if err != nil { + t.Fatalf("serviceMapForPIDs: %v", err) + } + second, err := serviceMapForPIDs() + if err != nil { + t.Fatalf("second call: %v", err) + } + if len(first) != len(second) { + t.Errorf("cached map size mismatch: %d vs %d", len(first), len(second)) + } +} + +func TestServiceMapCacheRefreshesAfterTTL(t *testing.T) { + originalTTL := serviceMapCacheTTL + serviceMapCacheTTL = 1 * time.Millisecond + defer func() { serviceMapCacheTTL = originalTTL }() + + if _, err := serviceMapForPIDs(); err != nil { + t.Fatalf("first call: %v", err) + } + firstTime := serviceMapCacheTime + + time.Sleep(10 * time.Millisecond) + + if _, err := serviceMapForPIDs(); err != nil { + t.Fatalf("second call: %v", err) + } + if !serviceMapCacheTime.After(firstTime) { + t.Errorf("cache not refreshed after TTL: firstTime=%v secondTime=%v", + firstTime, serviceMapCacheTime) + } +} + +func TestUtf16PtrToStringNilSafe(t *testing.T) { + if got := utf16PtrToString(nil); got != "" { + t.Errorf("utf16PtrToString(nil) = %q, want empty string", got) + } +} + +func TestDetectWindowsServiceSourceUnknownPIDReturnsEmpty(t *testing.T) { + if got := detectWindowsServiceSource(0); got != "" { + t.Errorf("detectWindowsServiceSource(0) = %q, want empty", got) + } +} + +func TestServiceMapInvariants(t *testing.T) { + services, err := serviceMapForPIDs() + if err != nil { + t.Fatalf("serviceMapForPIDs: %v", err) + } + if len(services) == 0 { + t.Skipf("no running services found; nothing to validate") + } + for pid, name := range services { + if pid == 0 { + t.Errorf("service map contains zero PID entry (name=%q)", name) + } + if name == "" { + t.Errorf("service map contains empty name for PID %d", pid) + } + } +} diff --git a/internal/proc/snapshot_windows.go b/internal/proc/snapshot_windows.go new file mode 100644 index 0000000..785cbf7 --- /dev/null +++ b/internal/proc/snapshot_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package proc + +import ( + "fmt" + "sync" + "syscall" + "time" + "unsafe" +) + +type processSnapshot struct { + PID int + PPID int + Exe string + Threads int +} + +var ( + snapshotCache []processSnapshot + snapshotCacheTime time.Time + snapshotCacheMu sync.Mutex + snapshotCacheTTL = 1 * time.Second +) + +// enumerateProcesses returns every running process via ToolHelp32. Cached so +// repeated calls within a render pass reuse a single snapshot. +func enumerateProcesses() ([]processSnapshot, error) { + snapshotCacheMu.Lock() + defer snapshotCacheMu.Unlock() + + if snapshotCache != nil && time.Since(snapshotCacheTime) < snapshotCacheTTL { + return snapshotCache, nil + } + + snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(TH32CS_SNAPPROCESS), 0) + if syscall.Handle(snap) == syscall.InvalidHandle { + return nil, fmt.Errorf("CreateToolhelp32Snapshot failed") + } + defer syscall.CloseHandle(syscall.Handle(snap)) + + var pe32 PROCESSENTRY32 + pe32.Size = uint32(unsafe.Sizeof(pe32)) + + ret, _, _ := procProcess32First.Call(snap, uintptr(unsafe.Pointer(&pe32))) + if ret == 0 { + return nil, fmt.Errorf("Process32First failed") + } + + var out []processSnapshot + for { + out = append(out, processSnapshot{ + PID: int(pe32.ProcessID), + PPID: int(pe32.ParentProcessID), + Exe: syscall.UTF16ToString(pe32.ExeFile[:]), + Threads: int(pe32.CntThreads), + }) + ret, _, _ = procProcess32Next.Call(snap, uintptr(unsafe.Pointer(&pe32))) + if ret == 0 { + break + } + } + + snapshotCache = out + snapshotCacheTime = time.Now() + return out, nil +} diff --git a/internal/proc/snapshot_windows_test.go b/internal/proc/snapshot_windows_test.go new file mode 100644 index 0000000..51a161e --- /dev/null +++ b/internal/proc/snapshot_windows_test.go @@ -0,0 +1,46 @@ +//go:build windows + +package proc + +import ( + "os" + "testing" +) + +func TestEnumerateProcessesIncludesSelfWithThreads(t *testing.T) { + procs, err := enumerateProcesses() + if err != nil { + t.Fatalf("enumerateProcesses: %v", err) + } + + self := os.Getpid() + for _, p := range procs { + if p.PID == self { + if p.PPID == 0 { + t.Errorf("self snapshot has PPID = 0") + } + if p.Threads < 1 { + t.Errorf("self snapshot has Threads = %d, want >= 1", p.Threads) + } + if p.Exe == "" { + t.Errorf("self snapshot has empty Exe field") + } + return + } + } + t.Fatalf("enumerateProcesses did not include self PID %d (out of %d entries)", self, len(procs)) +} + +func TestEnumerateProcessesCacheReuse(t *testing.T) { + first, err := enumerateProcesses() + if err != nil { + t.Fatalf("first call: %v", err) + } + second, err := enumerateProcesses() + if err != nil { + t.Fatalf("second call: %v", err) + } + if len(first) != len(second) { + t.Errorf("cached vs fresh snapshot size mismatch: %d vs %d", len(first), len(second)) + } +} diff --git a/internal/proc/socketstate_darwin.go b/internal/proc/socketstate_darwin.go new file mode 100644 index 0000000..e800bcc --- /dev/null +++ b/internal/proc/socketstate_darwin.go @@ -0,0 +1,172 @@ +//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 +} diff --git a/internal/proc/socketstate_freebsd.go b/internal/proc/socketstate_freebsd.go new file mode 100644 index 0000000..1b57a5e --- /dev/null +++ b/internal/proc/socketstate_freebsd.go @@ -0,0 +1,149 @@ +//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 +} diff --git a/internal/proc/socketstate_linux.go b/internal/proc/socketstate_linux.go new file mode 100644 index 0000000..cb4fff7 --- /dev/null +++ b/internal/proc/socketstate_linux.go @@ -0,0 +1,156 @@ +//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" + } +} diff --git a/internal/proc/socketstate_linux_test.go b/internal/proc/socketstate_linux_test.go new file mode 100644 index 0000000..f25d876 --- /dev/null +++ b/internal/proc/socketstate_linux_test.go @@ -0,0 +1,58 @@ +//go:build linux + +package proc + +import ( + "strings" + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestMapTCPState(t *testing.T) { + known := map[int]string{ + 1: "ESTABLISHED", 2: "SYN_SENT", 6: "TIME_WAIT", 10: "LISTEN", 11: "CLOSING", + } + for in, want := range known { + if got := mapTCPState(in); got != want { + t.Errorf("mapTCPState(%d) = %q, want %q", in, got, want) + } + } + if got := mapTCPState(99); !strings.HasPrefix(got, "UNKNOWN") { + t.Errorf("mapTCPState(99) = %q, want an UNKNOWN fallback", got) + } +} + +func TestIsProblematicState(t *testing.T) { + for _, s := range []string{"TIME_WAIT", "CLOSE_WAIT", "FIN_WAIT_1", "FIN_WAIT_2"} { + if !isProblematicState(s) { + t.Errorf("%q should be flagged problematic", s) + } + } + for _, s := range []string{"LISTEN", "ESTABLISHED", "SYN_SENT"} { + if isProblematicState(s) { + t.Errorf("%q should not be flagged problematic", s) + } + } +} + +func TestAddStateExplanation(t *testing.T) { + // A state with a documented workaround gets both fields. + tw := &model.SocketInfo{State: "TIME_WAIT"} + addStateExplanation(tw) + if tw.Explanation == "" || tw.Workaround == "" { + t.Errorf("TIME_WAIT: explanation=%q workaround=%q, want both set", tw.Explanation, tw.Workaround) + } + // A benign state gets an explanation but no workaround. + ln := &model.SocketInfo{State: "LISTEN"} + addStateExplanation(ln) + if ln.Explanation == "" { + t.Error("LISTEN should have an explanation") + } + // An unknown state falls back to a generic explanation naming the state. + unk := &model.SocketInfo{State: "WEIRD"} + addStateExplanation(unk) + if !strings.Contains(unk.Explanation, "WEIRD") { + t.Errorf("unknown-state explanation = %q, want it to mention the state", unk.Explanation) + } +} diff --git a/internal/proc/socketstate_windows.go b/internal/proc/socketstate_windows.go new file mode 100644 index 0000000..b280efb --- /dev/null +++ b/internal/proc/socketstate_windows.go @@ -0,0 +1,92 @@ +//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" + } +} diff --git a/internal/proc/sort.go b/internal/proc/sort.go new file mode 100644 index 0000000..ccd9ce7 --- /dev/null +++ b/internal/proc/sort.go @@ -0,0 +1,13 @@ +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 + }) +} diff --git a/internal/proc/sort_test.go b/internal/proc/sort_test.go new file mode 100644 index 0000000..178ff01 --- /dev/null +++ b/internal/proc/sort_test.go @@ -0,0 +1,17 @@ +package proc + +import ( + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestSortProcesses(t *testing.T) { + ps := []model.Process{{PID: 30}, {PID: 10}, {PID: 20}} + sortProcesses(ps) + for i, want := range []int{10, 20, 30} { + if ps[i].PID != want { + t.Errorf("after sort, ps[%d].PID = %d, want %d", i, ps[i].PID, want) + } + } +} diff --git a/internal/proc/sudo_user_unix.go b/internal/proc/sudo_user_unix.go new file mode 100644 index 0000000..0f5e0ea --- /dev/null +++ b/internal/proc/sudo_user_unix.go @@ -0,0 +1,62 @@ +//go:build !windows + +package proc + +import ( + "context" + "os" + "os/exec" + "os/user" + "strconv" + "syscall" +) + +// commandAsOriginalUser builds an *exec.Cmd that, when invoked under sudo, +// runs as the original (non-root) user. This matters for tools like rootless +// podman and nerdctl whose state lives in $HOME/.local — invisible to root. +// +// When not running as root, or when SUDO_UID/SUDO_GID/SUDO_USER aren't set, +// it behaves identically to exec.CommandContext. +func commandAsOriginalUser(ctx context.Context, bin string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, bin, args...) + if os.Geteuid() != 0 { + return cmd + } + + uidStr := os.Getenv("SUDO_UID") + gidStr := os.Getenv("SUDO_GID") + sudoUser := os.Getenv("SUDO_USER") + if uidStr == "" || gidStr == "" || sudoUser == "" { + return cmd + } + + uid, err1 := strconv.ParseUint(uidStr, 10, 32) + gid, err2 := strconv.ParseUint(gidStr, 10, 32) + if err1 != nil || err2 != nil || uid == 0 { + return cmd + } + + u, err := user.Lookup(sudoUser) + if err != nil { + return cmd + } + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + }, + } + + // Rootless container tools resolve sockets and state under HOME and + // XDG_RUNTIME_DIR. Re-derive both so the child sees the original user's + // environment, not root's. + env := os.Environ() + env = append(env, "HOME="+u.HomeDir) + env = append(env, "USER="+sudoUser) + env = append(env, "LOGNAME="+sudoUser) + env = append(env, "XDG_RUNTIME_DIR=/run/user/"+uidStr) + cmd.Env = env + + return cmd +} diff --git a/internal/proc/sudo_user_windows.go b/internal/proc/sudo_user_windows.go new file mode 100644 index 0000000..1a83c20 --- /dev/null +++ b/internal/proc/sudo_user_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package proc + +import ( + "context" + "os/exec" +) + +// commandAsOriginalUser is a no-op on Windows. +func commandAsOriginalUser(ctx context.Context, bin string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, bin, args...) +} diff --git a/internal/proc/systemd_linux.go b/internal/proc/systemd_linux.go new file mode 100644 index 0000000..5201a90 --- /dev/null +++ b/internal/proc/systemd_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package proc + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// 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) +} diff --git a/internal/proc/systemd_stub.go b/internal/proc/systemd_stub.go new file mode 100644 index 0000000..95bdca2 --- /dev/null +++ b/internal/proc/systemd_stub.go @@ -0,0 +1,9 @@ +//go:build !linux + +package proc + +import "fmt" + +func ResolveSystemdService(port int) (string, error) { + return "", fmt.Errorf("systemd is only supported on Linux") +} diff --git a/internal/proc/user_darwin.go b/internal/proc/user_darwin.go new file mode 100644 index 0000000..df0fc4d --- /dev/null +++ b/internal/proc/user_darwin.go @@ -0,0 +1,33 @@ +//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) +} diff --git a/internal/proc/user_freebsd.go b/internal/proc/user_freebsd.go new file mode 100644 index 0000000..0b3b92d --- /dev/null +++ b/internal/proc/user_freebsd.go @@ -0,0 +1,33 @@ +//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) +} diff --git a/internal/proc/user_linux.go b/internal/proc/user_linux.go new file mode 100644 index 0000000..5c39b9d --- /dev/null +++ b/internal/proc/user_linux.go @@ -0,0 +1,61 @@ +//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) +} diff --git a/internal/proc/user_windows.go b/internal/proc/user_windows.go new file mode 100644 index 0000000..3a03b01 --- /dev/null +++ b/internal/proc/user_windows.go @@ -0,0 +1,54 @@ +//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) diff --git a/internal/source/bsdrc_darwin.go b/internal/source/bsdrc_darwin.go new file mode 100644 index 0000000..212ea16 --- /dev/null +++ b/internal/source/bsdrc_darwin.go @@ -0,0 +1,10 @@ +//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 +} diff --git a/internal/source/bsdrc_freebsd.go b/internal/source/bsdrc_freebsd.go new file mode 100644 index 0000000..12195ae --- /dev/null +++ b/internal/source/bsdrc_freebsd.go @@ -0,0 +1,153 @@ +//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 "" +} diff --git a/internal/source/bsdrc_linux.go b/internal/source/bsdrc_linux.go new file mode 100644 index 0000000..f930ae4 --- /dev/null +++ b/internal/source/bsdrc_linux.go @@ -0,0 +1,10 @@ +//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 +} diff --git a/internal/source/bsdrc_windows.go b/internal/source/bsdrc_windows.go new file mode 100644 index 0000000..44f494b --- /dev/null +++ b/internal/source/bsdrc_windows.go @@ -0,0 +1,10 @@ +//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 +} diff --git a/internal/source/container.go b/internal/source/container.go new file mode 100644 index 0000000..3800f5f --- /dev/null +++ b/internal/source/container.go @@ -0,0 +1,91 @@ +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", + } + case strings.Contains(content, "lxc.payload"): + return &model.Source{ + Type: model.SourceContainer, + Name: detectLXCRuntime(ancestry), + } + } + } + + // 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) +} + +func detectLXCRuntime(ancestry []model.Process) string { + for _, a := range ancestry { + switch a.Command { + case "incusd": + return "incus" + case "lxd": + return "lxd" + case "lxc-start": + return "lxc" + } + } + return "lxc" // fallback +} diff --git a/internal/source/cron.go b/internal/source/cron.go new file mode 100644 index 0000000..e03e719 --- /dev/null +++ b/internal/source/cron.go @@ -0,0 +1,20 @@ +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 +} diff --git a/internal/source/detect.go b/internal/source/detect.go new file mode 100644 index 0000000..12c845e --- /dev/null +++ b/internal/source/detect.go @@ -0,0 +1,274 @@ +package source + +import ( + "fmt" + "runtime" + "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, restartCount int, srcType ...model.SourceType) []string { + if len(p) == 0 { + return nil + } + + var w []string + + last := p[len(p)-1] + + // Warn on a service that has restarted many times. restartCount is the real + // count from the service manager (e.g. systemd NRestarts), or 0 when unknown. + if restartCount > 5 { + w = append(w, fmt.Sprintf("Service has restarted %d times", restartCount)) + } + + // 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.Sockets) { + 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, ", ")) + } + } + + var st model.SourceType + if len(srcType) > 0 { + st = srcType[0] + } else { + st = Detect(p).Type + } + // On Windows the ancestry frequently truncates at an orphaned process + // (Windows leaves a stale PPID instead of reparenting to an init process), + // so an unknown source is normal there — not a reliable "unsupervised" + // signal — and this warning would fire on most user processes. + if st == model.SourceUnknown && runtime.GOOS != "windows" { + w = append(w, "No known supervisor or service manager detected") + } + + // Warn if process is very old (>90 days). A zero start time means we + // couldn't read it (e.g. protected Windows processes), not that the process + // is ancient — skip the warning rather than emit a false positive. + if !last.StartedAt.IsZero() && 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 only when the runtime confirms no healthcheck is configured. Unknown + // ("") — snap/flatpak, unprobed runtimes, non-Linux — does not warn. + if last.ContainerHealthcheck == "absent" { + w = append(w, "Container has no healthcheck configured") + } + + // 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) + } + // Compare against a systemd template's base name, not its instance + // (getty@tty1 -> getty), so a template whose binary is named after the + // template (agetty) doesn't read as a mismatch. + if at := strings.IndexByte(svcCore, '@'); at >= 0 { + svcCore = svcCore[:at] + } + 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." + } +} diff --git a/internal/source/detect_test.go b/internal/source/detect_test.go new file mode 100644 index 0000000..883d5e9 --- /dev/null +++ b/internal/source/detect_test.go @@ -0,0 +1,304 @@ +package source + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestDetectShellWindowsCaseInsensitive(t *testing.T) { + // Windows reports the shell with mixed casing (e.g. "Explorer.EXE"). Shell + // detection must be case-insensitive, otherwise desktop-launched apps fall + // through to SourceUnknown and pick up a spurious no-supervisor warning. + for _, shell := range []string{"Explorer.EXE", "cmd.EXE", "PowerShell.exe"} { + ancestry := []model.Process{ + {PID: 100, Command: shell}, + {PID: 200, PPID: 100, Command: "Claude.exe"}, + } + if got := Detect(ancestry).Type; got != model.SourceShell { + t.Errorf("Detect with %q ancestor = %v; want SourceShell", shell, got) + } + if slices.Contains(Warnings(ancestry, 0), "No known supervisor or service manager detected") { + t.Errorf("%q ancestor should not raise the no-supervisor warning", shell) + } + } +} + +func TestDetectWindowsSystemKernel(t *testing.T) { + // A process rooted at the Windows System process (PID 4) is a kernel/system + // process, not an unsupervised one. It must resolve to an init source and + // must not raise the no-supervisor warning (the Memory Compression false + // positive). + ancestry := []model.Process{ + {PID: 4, Command: "System"}, + {PID: 4108, PPID: 4, Command: "Memory Compression"}, + } + if got := Detect(ancestry).Type; got != model.SourceInit { + t.Errorf("Detect with System (pid 4) root = %v; want SourceInit", got) + } + if slices.Contains(Warnings(ancestry, 0), "No known supervisor or service manager detected") { + t.Errorf("System-rooted process should not raise the no-supervisor warning") + } +} + +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, 0) + 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, 0) + 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, 0) + 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, 0) + }) +} + +func TestWarningsDetectsDeletedExecutable(t *testing.T) { + p := []model.Process{ + { + PID: 123, + Command: "nginx", + StartedAt: time.Now(), + ExeDeleted: true, + }, + } + + warnings := Warnings(p, 0) + 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 + wantWorkaround string // empty means no workaround should be set + }{ + { + state: "TIME_WAIT", + wantExplanation: "The local OS is holding the port in a protocol-wait state to ensure all packets are received.", + wantWorkaround: "Wait ~60s for the OS to release it, or enable SO_REUSEADDR in your code.", + }, + { + state: "CLOSE_WAIT", + wantExplanation: "The remote end has closed the connection, but the local application hasn't responded.", + wantWorkaround: "This usually indicates a resource leak in the application. Restart the process.", + }, + { + state: "LISTEN", + wantExplanation: "The process is actively waiting for incoming connections.", + wantWorkaround: "", // no workaround for a healthy listener + }, + { + state: "ESTABLISHED", + wantExplanation: "The connection is active and data can be transferred.", + wantWorkaround: "", + }, + { + state: "FIN_WAIT_1", + wantExplanation: "The connection is in the process of being closed.", + wantWorkaround: "", + }, + { + state: "FIN_WAIT_2", + wantExplanation: "The connection is in the process of being closed.", + wantWorkaround: "", + }, + } + + 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) + } + if si.Workaround != tt.wantWorkaround { + t.Errorf("state %s: got workaround %q, want %q", tt.state, si.Workaround, tt.wantWorkaround) + } + } +} + +// TestEnrichSocketInfoUnknownState ensures unknown states pass through +// without panic and without populating either string. Forward-compatible +// states added by newer kernels (e.g. NEW_SYN_RECV) must not corrupt the +// record. +func TestEnrichSocketInfoUnknownState(t *testing.T) { + si := &model.SocketInfo{State: "MYSTERY"} + EnrichSocketInfo(si) + if si.Explanation != "" || si.Workaround != "" { + t.Errorf("unknown state should leave fields blank, got explanation=%q workaround=%q", + si.Explanation, si.Workaround) + } +} + +// TestEnrichSocketInfoNilSafe ensures EnrichSocketInfo is safe to call on a +// nil pointer (some callers pass *SocketInfo conditionally). +func TestEnrichSocketInfoNilSafe(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("EnrichSocketInfo(nil) panicked: %v", r) + } + }() + EnrichSocketInfo(nil) +} diff --git a/internal/source/helpers_test.go b/internal/source/helpers_test.go new file mode 100644 index 0000000..69e3b93 --- /dev/null +++ b/internal/source/helpers_test.go @@ -0,0 +1,45 @@ +package source + +import ( + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestFindEnvVar(t *testing.T) { + ancestry := []model.Process{ + {PID: 1, Command: "systemd", Env: []string{"PATH=/usr/bin"}}, + {PID: 100, Command: "bash", Env: []string{"FOO=bar", "BAZ=qux"}}, + } + + // The target (last element) is searched first. + if got := findEnvVar(ancestry, "FOO"); got != "bar" { + t.Errorf("findEnvVar(FOO) = %q, want bar", got) + } + // A variable only the ancestor sets is still found. + if got := findEnvVar(ancestry, "PATH"); got != "/usr/bin" { + t.Errorf("findEnvVar(PATH) = %q, want /usr/bin", got) + } + // A missing variable yields "". + if got := findEnvVar(ancestry, "MISSING"); got != "" { + t.Errorf("findEnvVar(MISSING) = %q, want empty", got) + } +} + +func TestDetectLXCRuntime(t *testing.T) { + tests := []struct { + cmd string + want string + }{ + {"incusd", "incus"}, + {"lxd", "lxd"}, + {"lxc-start", "lxc"}, + {"unrelated", "lxc"}, // fallback when no known manager is in the chain + } + for _, tt := range tests { + got := detectLXCRuntime([]model.Process{{Command: tt.cmd}}) + if got != tt.want { + t.Errorf("detectLXCRuntime(%q) = %q, want %q", tt.cmd, got, tt.want) + } + } +} diff --git a/internal/source/init.go b/internal/source/init.go new file mode 100644 index 0000000..f38873a --- /dev/null +++ b/internal/source/init.go @@ -0,0 +1,60 @@ +package source + +import ( + "path/filepath" + "strconv" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// detectInit checks if the process is a direct descendant of the system's root +// process: PID 1 (init/systemd/OpenRC/... on Unix) or PID 4 "System" (the kernel +// on Windows). It acts as a catch-all so kernel/system processes resolve to an +// init source rather than appearing unsupervised. +func detectInit(ancestry []model.Process) *model.Source { + if len(ancestry) == 0 { + return nil + } + + root := ancestry[0] + isWindowsKernel := root.PID == 4 && strings.EqualFold(root.Command, "System") + if root.PID != 1 && !isWindowsKernel { + 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" + } + src := &model.Source{ + Type: model.SourceInit, + Name: initName, + Details: map[string]string{ + "pid": strconv.Itoa(root.PID), + "comm": root.Command, + }, + } + if isWindowsKernel { + src.Description = "Windows kernel (System process)" + } + return src + } + + return nil +} diff --git a/internal/source/launchd_darwin.go b/internal/source/launchd_darwin.go new file mode 100644 index 0000000..6327aa1 --- /dev/null +++ b/internal/source/launchd_darwin.go @@ -0,0 +1,81 @@ +//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 +} diff --git a/internal/source/launchd_freebsd.go b/internal/source/launchd_freebsd.go new file mode 100644 index 0000000..b5161de --- /dev/null +++ b/internal/source/launchd_freebsd.go @@ -0,0 +1,10 @@ +//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 +} diff --git a/internal/source/launchd_linux.go b/internal/source/launchd_linux.go new file mode 100644 index 0000000..1d30f23 --- /dev/null +++ b/internal/source/launchd_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package source + +import "github.com/pranshuparmar/witr/pkg/model" + +func detectLaunchd(_ []model.Process) *model.Source { + return nil +} diff --git a/internal/source/launchd_windows.go b/internal/source/launchd_windows.go new file mode 100644 index 0000000..bd05c32 --- /dev/null +++ b/internal/source/launchd_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package source + +import "github.com/pranshuparmar/witr/pkg/model" + +func detectLaunchd(ancestry []model.Process) *model.Source { + return nil +} diff --git a/internal/source/network.go b/internal/source/network.go new file mode 100644 index 0000000..6826ea0 --- /dev/null +++ b/internal/source/network.go @@ -0,0 +1,18 @@ +package source + +import "github.com/pranshuparmar/witr/pkg/model" + +// IsPublicBind reports whether any listening socket is bound to a public +// (any-address) interface. Non-listening sockets are ignored — an established +// outbound connection to a public address is not the same as exposing one. +func IsPublicBind(sockets []model.Socket) bool { + for _, s := range sockets { + if s.State != "LISTEN" { + continue + } + if s.Address == "0.0.0.0" || s.Address == "::" { + return true + } + } + return false +} diff --git a/internal/source/network_test.go b/internal/source/network_test.go new file mode 100644 index 0000000..fa1f599 --- /dev/null +++ b/internal/source/network_test.go @@ -0,0 +1,84 @@ +package source + +import ( + "testing" + + "github.com/pranshuparmar/witr/pkg/model" +) + +func TestIsPublicBind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sockets []model.Socket + want bool + }{ + { + name: "listening on IPv4 any-address is public", + sockets: []model.Socket{ + {Address: "0.0.0.0", Port: 443, State: "LISTEN"}, + }, + want: true, + }, + { + name: "listening on IPv6 any-address is public", + sockets: []model.Socket{ + {Address: "::", Port: 443, State: "LISTEN"}, + }, + want: true, + }, + { + name: "loopback listener is not public", + sockets: []model.Socket{ + {Address: "127.0.0.1", Port: 443, State: "LISTEN"}, + }, + want: false, + }, + { + name: "specific private address is not public", + sockets: []model.Socket{ + {Address: "192.168.1.5", Port: 443, State: "LISTEN"}, + }, + want: false, + }, + { + name: "ESTABLISHED to public address is NOT a public bind", + sockets: []model.Socket{ + {Address: "0.0.0.0", Port: 443, State: "ESTABLISHED"}, + }, + want: false, + }, + { + name: "outbound connection to 0.0.0.0 should not flag", + sockets: []model.Socket{ + {Address: "0.0.0.0", Port: 12345, State: "CLOSE_WAIT"}, + {Address: "0.0.0.0", Port: 23456, State: "TIME_WAIT"}, + }, + want: false, + }, + { + name: "mix: one public listener wins", + sockets: []model.Socket{ + {Address: "127.0.0.1", Port: 8080, State: "LISTEN"}, + {Address: "0.0.0.0", Port: 443, State: "LISTEN"}, + }, + want: true, + }, + { + name: "empty", + sockets: nil, + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsPublicBind(tt.sockets); got != tt.want { + t.Errorf("IsPublicBind(%+v) = %v, want %v", tt.sockets, got, tt.want) + } + }) + } +} diff --git a/internal/source/service_other.go b/internal/source/service_other.go new file mode 100644 index 0000000..1f3707e --- /dev/null +++ b/internal/source/service_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package source + +import "github.com/pranshuparmar/witr/pkg/model" + +func detectWindowsService(ancestry []model.Process) *model.Source { + return nil +} diff --git a/internal/source/service_windows.go b/internal/source/service_windows.go new file mode 100644 index 0000000..ae9e3f3 --- /dev/null +++ b/internal/source/service_windows.go @@ -0,0 +1,85 @@ +//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 "" +} diff --git a/internal/source/shell.go b/internal/source/shell.go new file mode 100644 index 0000000..eae157e --- /dev/null +++ b/internal/source/shell.go @@ -0,0 +1,152 @@ +package source + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// isShell reports whether name (a lowercased command basename) is an interactive +// shell or desktop launcher — a signal that a process was started by a user or +// script rather than a service manager. Shared by detectShell and detectInit so +// both agree on the definition. +func isShell(name string) bool { + switch name { + case "bash", "zsh", "sh", "fish", "csh", "tcsh", "ksh", "dash", "ash", + "cmd.exe", "powershell.exe", "pwsh.exe", "explorer.exe": + return true + } + return false +} + +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) + + // Windows reports executables with inconsistent casing (e.g. + // "Explorer.EXE", "PowerShell.exe"), so match shell names + // case-insensitively. + if isShell(strings.ToLower(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 "" +} diff --git a/internal/source/ssh.go b/internal/source/ssh.go new file mode 100644 index 0000000..ee0f374 --- /dev/null +++ b/internal/source/ssh.go @@ -0,0 +1,76 @@ +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, + } +} diff --git a/internal/source/supervisor.go b/internal/source/supervisor.go new file mode 100644 index 0000000..59ed731 --- /dev/null +++ b/internal/source/supervisor.go @@ -0,0 +1,99 @@ +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 isShell(strings.ToLower(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 "" +} diff --git a/internal/source/systemd_darwin.go b/internal/source/systemd_darwin.go new file mode 100644 index 0000000..5859acf --- /dev/null +++ b/internal/source/systemd_darwin.go @@ -0,0 +1,12 @@ +//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 } diff --git a/internal/source/systemd_freebsd.go b/internal/source/systemd_freebsd.go new file mode 100644 index 0000000..5d8a3ef --- /dev/null +++ b/internal/source/systemd_freebsd.go @@ -0,0 +1,13 @@ +//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 } diff --git a/internal/source/systemd_linux.go b/internal/source/systemd_linux.go new file mode 100644 index 0000000..32c939f --- /dev/null +++ b/internal/source/systemd_linux.go @@ -0,0 +1,277 @@ +//go:build linux + +package source + +import ( + "context" + "fmt" + "math" + "os" + "strconv" + "strings" + "time" + + sd "github.com/coreos/go-systemd/v22/dbus" + "github.com/pranshuparmar/witr/pkg/model" +) + +// dbusTimeout bounds each systemd D-Bus interaction so a hung bus can't stall +// witr. It is generous relative to a healthy bus (single-digit milliseconds). +const dbusTimeout = 2 * time.Second + +// 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 + } + + // The unit name comes for free from the process cgroup; description, unit + // file, restart count and timer schedule are best-effort enrichment over + // systemd's D-Bus API. + unitName := getUnitNameFromCgroup(ancestry[len(ancestry)-1].PID) + + src := &model.Source{ + Type: model.SourceSystemd, + Name: unitName, + Details: map[string]string{}, + } + enrichFromSystemd(src, unitName) + return src +} + +// enrichFromSystemd fills Description, UnitFile, NRestarts and (for timer- +// triggered services) the schedule via systemd's D-Bus API. Every step is +// best-effort: a missing bus, a permission error, or an unloaded unit just +// leaves the corresponding field empty rather than failing detection. This +// replaces forking `systemctl show` (2-3 processes per report) with a single +// short-lived D-Bus connection. +func enrichFromSystemd(src *model.Source, unitName string) { + if unitName == "" { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), dbusTimeout) + defer cancel() + + conn, err := sd.NewSystemConnectionContext(ctx) + if err != nil { + return // no usable bus — keep the cgroup-derived unit name only + } + defer conn.Close() + + if unit, err := conn.GetUnitPropertiesContext(ctx, unitName); err == nil { + src.Description = stringProp(unit, "Description") + if fp := stringProp(unit, "FragmentPath"); fp != "" { + src.UnitFile = fp + } else if sp := stringProp(unit, "SourcePath"); sp != "" { + src.UnitFile = sp + } + } + + if strings.HasSuffix(unitName, ".service") { + if svc, err := conn.GetUnitTypePropertiesContext(ctx, unitName, "Service"); err == nil { + src.Details["NRestarts"] = strconv.FormatUint(uint64(uint32Prop(svc, "NRestarts")), 10) + } + timerUnit := strings.TrimSuffix(unitName, ".service") + ".timer" + if sched := timerSchedule(ctx, conn, timerUnit); sched != "" { + src.Details["schedule"] = sched + } + } +} + +// timerSchedule renders a ", last: …, next: …" line for a .timer unit, +// or "" when the timer isn't loaded. +func timerSchedule(ctx context.Context, conn *sd.Conn, timerUnit string) string { + tp, err := conn.GetUnitTypePropertiesContext(ctx, timerUnit, "Timer") + if err != nil { + return "" + } + + spec := calendarSpec(tp["TimersCalendar"]) + if spec == "" { + spec = monotonicSpec(tp["TimersMonotonic"]) + } + if spec == "" { + return "" + } + + parts := []string{spec} + if last := usecToTime(uint64Prop(tp, "LastTriggerUSec")); !last.IsZero() { + parts = append(parts, "last: "+formatRelativeTime(last)) + } + if next := usecToTime(uint64Prop(tp, "NextElapseUSecRealtime")); !next.IsZero() { + parts = append(parts, "next: "+formatRelativeTime(next)) + } + return strings.Join(parts, ", ") +} + +// calendarSpec extracts the calendar expression from a TimersCalendar value, +// which D-Bus delivers as an array of (base, spec, next): e.g. "*-*-* 06,18:00:00". +func calendarSpec(v interface{}) string { + for _, e := range timerEntries(v) { + if len(e) >= 2 { + if spec, ok := e[1].(string); ok && spec != "" { + return spec + } + } + } + return "" +} + +// monotonicSpec renders a TimersMonotonic value (base, usec, next) as a human +// phrase like "every 1d" or "every boot + 15min". +func monotonicSpec(v interface{}) string { + for _, e := range timerEntries(v) { + if len(e) < 2 { + continue + } + base, _ := e[0].(string) + usec, _ := e[1].(uint64) + if usec == 0 { + continue + } + human := humanDuration(time.Duration(usec) * time.Microsecond) + switch { + case strings.HasPrefix(base, "OnBoot"): + return "every boot + " + human + case strings.HasPrefix(base, "OnUnitInactive"): + return "every " + human + " after idle" + default: // OnUnitActive, OnActive, OnStartup + return "every " + human + } + } + return "" +} + +// timerEntries normalizes a TimersCalendar/TimersMonotonic D-Bus value into a +// slice of struct fields, tolerating any decoding shape it can't read. +func timerEntries(v interface{}) [][]interface{} { + entries, _ := v.([][]interface{}) + return entries +} + +func humanDuration(d time.Duration) string { + switch { + case d >= 24*time.Hour: + days := int(d / (24 * time.Hour)) + if hrs := int(d % (24 * time.Hour) / time.Hour); hrs > 0 { + return fmt.Sprintf("%dd %dh", days, hrs) + } + return fmt.Sprintf("%dd", days) + case d >= time.Hour: + hrs := int(d / time.Hour) + if mins := int(d % time.Hour / time.Minute); mins > 0 { + return fmt.Sprintf("%dh %dmin", hrs, mins) + } + return fmt.Sprintf("%dh", hrs) + case d >= time.Minute: + mins := int(d / time.Minute) + if secs := int(d % time.Minute / time.Second); secs > 0 { + return fmt.Sprintf("%dmin %ds", mins, secs) + } + return fmt.Sprintf("%dmin", mins) + default: + return fmt.Sprintf("%ds", int(d.Seconds())) + } +} + +// usecToTime converts a systemd microseconds-since-epoch value to a time, +// treating 0 and the uint64 "infinity" sentinel as "no value". +func usecToTime(usec uint64) time.Time { + if usec == 0 || usec == math.MaxUint64 { + return time.Time{} + } + return time.UnixMicro(int64(usec)) +} + +func stringProp(m map[string]interface{}, key string) string { + s, _ := m[key].(string) + return s +} + +func uint32Prop(m map[string]interface{}, key string) uint32 { + n, _ := m[key].(uint32) + return n +} + +func uint64Prop(m map[string]interface{}, key string) uint64 { + n, _ := m[key].(uint64) + return n +} + +// 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 "" +} diff --git a/internal/source/systemd_linux_test.go b/internal/source/systemd_linux_test.go new file mode 100644 index 0000000..7ea3f03 --- /dev/null +++ b/internal/source/systemd_linux_test.go @@ -0,0 +1,106 @@ +//go:build linux + +package source + +import ( + "math" + "os" + "testing" + "time" +) + +func TestCalendarSpec(t *testing.T) { + // D-Bus delivers TimersCalendar as [][]interface{}{{base, spec, next}}. + v := [][]interface{}{{"OnCalendar", "*-*-* 06,18:00:00", uint64(123)}} + if got := calendarSpec(v); got != "*-*-* 06,18:00:00" { + t.Errorf("calendarSpec = %q, want the calendar expression", got) + } + if got := calendarSpec(nil); got != "" { + t.Errorf("calendarSpec(nil) = %q, want empty", got) + } +} + +func TestMonotonicSpec(t *testing.T) { + day := uint64((24 * time.Hour) / time.Microsecond) + tests := []struct { + base string + want string + }{ + {"OnUnitActiveUSec", "every 1d"}, + {"OnBootUSec", "every boot + 1d"}, + {"OnUnitInactiveUSec", "every 1d after idle"}, + } + for _, tt := range tests { + v := [][]interface{}{{tt.base, day, uint64(0)}} + if got := monotonicSpec(v); got != tt.want { + t.Errorf("monotonicSpec(%s) = %q, want %q", tt.base, got, tt.want) + } + } + if got := monotonicSpec(nil); got != "" { + t.Errorf("monotonicSpec(nil) = %q, want empty", got) + } +} + +func TestHumanDuration(t *testing.T) { + cases := map[time.Duration]string{ + 25 * time.Hour: "1d 1h", + 24 * time.Hour: "1d", + 90 * time.Minute: "1h 30min", + 90 * time.Second: "1min 30s", + 5 * time.Second: "5s", + } + for d, want := range cases { + if got := humanDuration(d); got != want { + t.Errorf("humanDuration(%v) = %q, want %q", d, got, want) + } + } +} + +func TestUsecToTime(t *testing.T) { + if !usecToTime(0).IsZero() { + t.Error("usecToTime(0) should be the zero time") + } + if !usecToTime(math.MaxUint64).IsZero() { + t.Error("usecToTime(MaxUint64) should be the zero time (systemd 'n/a' sentinel)") + } + got := usecToTime(uint64(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC).UnixMicro())) + if got.IsZero() || got.UTC().Year() != 2024 { + t.Errorf("usecToTime(real value) = %v, want a 2024 time", got) + } +} + +func TestFormatRelativeTime(t *testing.T) { + now := time.Now() + // Durations are buffered off the truncation boundaries so sub-second drift + // between now and the time.Since() call inside the function can't flip an + // "N min" bucket to "N-1". + cases := []struct { + d time.Duration // offset from now; negative = past, positive = future + want string + }{ + {-30 * time.Second, "<1 min ago"}, + {-(5*time.Minute + 30*time.Second), "5 min ago"}, + {-(3*time.Hour + 30*time.Minute), "3h ago"}, + {-50 * time.Hour, "2d ago"}, + {30 * time.Second, "in <1 min"}, + {5*time.Minute + 30*time.Second, "in 5 min"}, + {3*time.Hour + 30*time.Minute, "in 3h"}, + {50 * time.Hour, "in 2d"}, + } + for _, tc := range cases { + if got := formatRelativeTime(now.Add(tc.d)); got != tc.want { + t.Errorf("formatRelativeTime(now%+v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +func TestGetUnitNameFromCgroupSelf(t *testing.T) { + // The result depends on the host's cgroup layout (a .scope, a .service, or + // nothing), so we only exercise the read+parse without asserting a value. + _ = getUnitNameFromCgroup(os.Getpid()) + + // PID 0 has no cgroup file, so the read fails and we get "". + if got := getUnitNameFromCgroup(0); got != "" { + t.Errorf("getUnitNameFromCgroup(0) = %q, want empty", got) + } +} diff --git a/internal/source/systemd_windows.go b/internal/source/systemd_windows.go new file mode 100644 index 0000000..32d95ae --- /dev/null +++ b/internal/source/systemd_windows.go @@ -0,0 +1,12 @@ +//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 } diff --git a/internal/source/warnings_test.go b/internal/source/warnings_test.go new file mode 100644 index 0000000..86aee61 --- /dev/null +++ b/internal/source/warnings_test.go @@ -0,0 +1,300 @@ +package source + +import ( + "runtime" + "strings" + "testing" + "time" + + "github.com/pranshuparmar/witr/pkg/model" +) + +// baseProc returns a benign process record that produces no warnings by +// itself. Tests mutate one field at a time to exercise individual rules. +func baseProc() model.Process { + return model.Process{ + PID: 1234, + Command: "nginx", + User: "www-data", + StartedAt: time.Now().Add(-1 * time.Hour), + WorkingDir: "/var/www", + Health: "healthy", + } +} + +// wrap forces Warnings to use a known source type so we don't depend on +// the platform-specific Detect() and its real-system probing. +func wrap(p model.Process) []string { + parent := baseProc() + parent.PID = 1 + parent.Command = "systemd" + return Warnings([]model.Process{parent, p}, 0, model.SourceSystemd) +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if strings.Contains(h, needle) { + return true + } + } + return false +} + +func TestWarningsHealthStates(t *testing.T) { + t.Parallel() + + tests := []struct { + health string + want string + }{ + {"zombie", "zombie"}, + {"stopped", "stopped"}, + {"high-cpu", "high CPU"}, + {"high-mem", "high memory"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.health, func(t *testing.T) { + t.Parallel() + p := baseProc() + p.Health = tt.health + if !contains(wrap(p), tt.want) { + t.Errorf("expected warning containing %q for health=%q, got: %v", + tt.want, tt.health, wrap(p)) + } + }) + } +} + +func TestWarningsPublicBind(t *testing.T) { + t.Parallel() + + p := baseProc() + p.Sockets = []model.Socket{{Address: "0.0.0.0", Port: 443, State: "LISTEN"}} + if !contains(wrap(p), "public interface") { + t.Errorf("expected public-interface warning, got: %v", wrap(p)) + } +} + +func TestWarningsRootUser(t *testing.T) { + t.Parallel() + + p := baseProc() + p.User = "root" + if !contains(wrap(p), "running as root") { + t.Errorf("expected root warning, got: %v", wrap(p)) + } +} + +func TestWarningsDangerousCapabilities(t *testing.T) { + t.Parallel() + + p := baseProc() + p.Capabilities = []string{"CAP_NET_BIND_SERVICE", "CAP_SYS_PTRACE", "CAP_SYS_ADMIN"} + w := wrap(p) + + if !contains(w, "dangerous capabilities") { + t.Fatalf("expected dangerous-capabilities warning, got: %v", w) + } + // Only the dangerous ones should appear in the message. + if !contains(w, "CAP_SYS_PTRACE") || !contains(w, "CAP_SYS_ADMIN") { + t.Errorf("expected dangerous caps listed in warning, got: %v", w) + } + if contains(w, "CAP_NET_BIND_SERVICE") { + t.Errorf("benign cap should not appear in warning, got: %v", w) + } +} + +func TestWarningsBenignCapabilitiesSuppressed(t *testing.T) { + t.Parallel() + + p := baseProc() + p.Capabilities = []string{"CAP_NET_BIND_SERVICE"} + if contains(wrap(p), "dangerous capabilities") { + t.Errorf("benign capability triggered warning, got: %v", wrap(p)) + } +} + +func TestWarningsRootSuppressesCapabilitiesCheck(t *testing.T) { + t.Parallel() + + // Per the implementation, root already triggers its own warning and the + // capability check is skipped (root has them all anyway). + p := baseProc() + p.User = "root" + p.Capabilities = []string{"CAP_SYS_ADMIN"} + w := wrap(p) + if contains(w, "dangerous capabilities") { + t.Errorf("dangerous-capabilities warning should be suppressed for root, got: %v", w) + } + if !contains(w, "running as root") { + t.Errorf("root warning still expected, got: %v", w) + } +} + +func TestWarningsUnknownSupervisor(t *testing.T) { + t.Parallel() + + p := baseProc() + got := Warnings([]model.Process{p}, 0, model.SourceUnknown) + hasWarning := contains(got, "No known supervisor") + if runtime.GOOS == "windows" { + // Suppressed on Windows: ancestry truncates at orphaned processes, so + // "unknown source" is normal rather than a sign of no supervision. + if hasWarning { + t.Errorf("unknown-supervisor warning should be suppressed on Windows, got: %v", got) + } + return + } + if !hasWarning { + t.Errorf("expected unknown-supervisor warning, got: %v", got) + } +} + +func TestWarningsLongRunning(t *testing.T) { + t.Parallel() + + p := baseProc() + p.StartedAt = time.Now().Add(-100 * 24 * time.Hour) + if !contains(wrap(p), "over 90 days") { + t.Errorf("expected long-running warning, got: %v", wrap(p)) + } +} + +// A zero start time means we couldn't read it (e.g. a protected Windows +// process), not that the process is ancient — it must not trigger the +// long-running warning. Regression guard for issue #205. +func TestWarningsZeroStartTimeNotLongRunning(t *testing.T) { + t.Parallel() + + p := baseProc() + p.StartedAt = time.Time{} + if contains(wrap(p), "over 90 days") { + t.Errorf("zero start time should not trigger long-running warning, got: %v", wrap(p)) + } +} + +func TestWarningsSuspiciousWorkingDirs(t *testing.T) { + t.Parallel() + + for _, dir := range []string{"/", "/tmp", "/var/tmp"} { + dir := dir + t.Run(dir, func(t *testing.T) { + t.Parallel() + p := baseProc() + p.WorkingDir = dir + if !contains(wrap(p), "suspicious working directory") { + t.Errorf("expected suspicious-dir warning for %q, got: %v", dir, wrap(p)) + } + }) + } +} + +func TestWarningsContainerHealthcheck(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + healthcheck string + wantWarning bool + }{ + {"absent warns", "absent", true}, + {"present does not warn", "present", false}, + {"unknown does not warn", "", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + p := baseProc() + p.Container = "docker (abc123)" + p.ContainerHealthcheck = tc.healthcheck + if got := contains(wrap(p), "healthcheck"); got != tc.wantWarning { + t.Errorf("ContainerHealthcheck=%q: warning=%v, want %v (%v)", tc.healthcheck, got, tc.wantWarning, wrap(p)) + } + }) + } +} + +func TestWarningsSnapAndFlatpakSkipHealthcheck(t *testing.T) { + t.Parallel() + + // Snap and Flatpak don't use healthchecks — they shouldn't trigger this + // warning even though the Container field is set. + for _, container := range []string{"snap: discord", "flatpak: org.signal.Signal"} { + container := container + t.Run(container, func(t *testing.T) { + t.Parallel() + p := baseProc() + p.Container = container + if contains(wrap(p), "healthcheck") { + t.Errorf("healthcheck warning should not fire for %q, got: %v", container, wrap(p)) + } + }) + } +} + +func TestWarningsServiceNameMismatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + command string + service string + wantWarning bool + }{ + {"match", "nginx", "nginx.service", false}, + {"systemd suffix tolerated", "postgres", "postgresql.service", false}, // svcCore contains cmd substring + {"systemd template instance tolerated", "agetty", "getty@tty1.service", false}, + {"mismatch", "nginx", "redis.service", true}, + {"empty service skipped", "nginx", "", false}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := baseProc() + p.Command = tt.command + p.Service = tt.service + got := contains(wrap(p), "Service name and process name") + if got != tt.wantWarning { + t.Errorf("service mismatch warning for cmd=%q svc=%q: got=%v want=%v\n%v", + tt.command, tt.service, got, tt.wantWarning, wrap(p)) + } + }) + } +} + +func TestWarningsRestartCount(t *testing.T) { + t.Parallel() + + chain := []model.Process{{PID: 1, Command: "systemd"}, baseProc()} + + // The warning is driven by the real restart count (e.g. systemd NRestarts), + // not by the shape of the ancestry. + if got := Warnings(chain, 7, model.SourceSystemd); !contains(got, "restarted 7 times") { + t.Errorf("expected restart warning for 7 restarts, got: %v", got) + } + if got := Warnings(chain, 5, model.SourceSystemd); contains(got, "restarted") { + t.Errorf("no restart warning expected for 5 restarts, got: %v", got) + } +} + +func TestWarningsBenignProcProducesNoSpurious(t *testing.T) { + t.Parallel() + + got := wrap(baseProc()) + for _, w := range got { + // The base fixture is intentionally benign; any warning here is a + // false positive that should be investigated. + t.Errorf("benign fixture produced warning: %q", w) + } +} + +func TestWarningsEmptyInputReturnsNil(t *testing.T) { + t.Parallel() + + if got := Warnings(nil, 0); got != nil { + t.Errorf("Warnings(nil) = %v, want nil", got) + } +} diff --git a/internal/target/errors.go b/internal/target/errors.go new file mode 100644 index 0000000..2e9db25 --- /dev/null +++ b/internal/target/errors.go @@ -0,0 +1,18 @@ +package target + +import "errors" + +// Sentinel errors that resolvers return (directly or wrapped with %w) so callers +// branch on errors.Is rather than matching error-message text — control flow that +// depends on exact prose silently breaks when a message is reworded or differs +// across platforms. +var ( + // ErrSocketOwnerUnknown means a socket is bound to the port but no + // host-visible process owns it (systemd socket activation, a container + // runtime, etc.). It triggers the container/socket fallback. + ErrSocketOwnerUnknown = errors.New("socket found but owning process not detected") + + // ErrUnsupported means the operation is unavailable on this platform + // (e.g. resolving by file on Windows). + ErrUnsupported = errors.New("not supported on this platform") +) diff --git a/internal/target/errors_test.go b/internal/target/errors_test.go new file mode 100644 index 0000000..9f773f1 --- /dev/null +++ b/internal/target/errors_test.go @@ -0,0 +1,27 @@ +package target + +import ( + "errors" + "fmt" + "testing" +) + +// Resolvers return these sentinels (sometimes wrapped with %w) so callers branch +// on errors.Is rather than matching message text. This pins that the wrapping is +// transparent and the sentinels stay distinct. +func TestSentinelErrors(t *testing.T) { + t.Parallel() + + if errors.Is(ErrUnsupported, ErrSocketOwnerUnknown) { + t.Error("ErrUnsupported and ErrSocketOwnerUnknown must be distinct") + } + + // Mirrors how ResolveFile wraps ErrUnsupported with context. + wrapped := fmt.Errorf("finding process by file is %w", ErrUnsupported) + if !errors.Is(wrapped, ErrUnsupported) { + t.Errorf("errors.Is should see ErrUnsupported through wrapping; got %q", wrapped) + } + if errors.Is(wrapped, ErrSocketOwnerUnknown) { + t.Error("wrapped ErrUnsupported must not match ErrSocketOwnerUnknown") + } +} diff --git a/internal/target/file_darwin.go b/internal/target/file_darwin.go new file mode 100644 index 0000000..1b2089d --- /dev/null +++ b/internal/target/file_darwin.go @@ -0,0 +1,44 @@ +package target + +import ( + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +func ResolveFile(path string) ([]int, error) { + // Absolute path so a leading-dash path can't be parsed as an lsof option. + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + + cmd := exec.Command("lsof", "-F", "p", absPath) + 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 +} diff --git a/internal/target/file_freebsd.go b/internal/target/file_freebsd.go new file mode 100644 index 0000000..c2c2e92 --- /dev/null +++ b/internal/target/file_freebsd.go @@ -0,0 +1,44 @@ +package target + +import ( + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +func ResolveFile(path string) ([]int, error) { + // Absolute path so a leading-dash path can't be parsed as an fstat option. + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + + cmd := exec.Command("fstat", absPath) + 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 +} diff --git a/internal/target/file_linux.go b/internal/target/file_linux.go new file mode 100644 index 0000000..e2fb9fc --- /dev/null +++ b/internal/target/file_linux.go @@ -0,0 +1,62 @@ +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 +} diff --git a/internal/target/file_windows.go b/internal/target/file_windows.go new file mode 100644 index 0000000..2a370d3 --- /dev/null +++ b/internal/target/file_windows.go @@ -0,0 +1,121 @@ +//go:build windows + +package target + +import ( + "fmt" + "path/filepath" + "runtime" + "syscall" + "unsafe" +) + +// Restart Manager (rstrtmgr.dll) enumerates the processes holding a file open. +// The kernel copies results into our buffer, so there is no remote +// process-memory access. +var ( + modRstrtmgr = syscall.NewLazyDLL("rstrtmgr.dll") + procRmStartSession = modRstrtmgr.NewProc("RmStartSession") + procRmRegisterResources = modRstrtmgr.NewProc("RmRegisterResources") + procRmGetList = modRstrtmgr.NewProc("RmGetList") + procRmEndSession = modRstrtmgr.NewProc("RmEndSession") +) + +const ( + cchRmSessionKey = 32 // CCH_RM_SESSION_KEY + errorMoreData = 234 // ERROR_MORE_DATA +) + +type rmUniqueProcess struct { + ProcessID uint32 + ProcessStartTime syscall.Filetime +} + +// rmProcessInfo mirrors RM_PROCESS_INFO exactly. The array sizes are +// CCH_RM_MAX_APP_NAME+1 (256) and CCH_RM_MAX_SVC_NAME+1 (64); a wrong size would +// misread the kernel-filled buffer (see TestRmProcessInfoSize). +type rmProcessInfo struct { + Process rmUniqueProcess + AppName [256]uint16 + ServiceShortName [64]uint16 + ApplicationType uint32 + AppStatus uint32 + TSSessionID uint32 + Restartable int32 +} + +func ResolveFile(path string) ([]int, error) { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + pathPtr, err := syscall.UTF16PtrFromString(abs) + if err != nil { + return nil, fmt.Errorf("invalid path %q: %w", path, err) + } + + var session uint32 + sessionKey := make([]uint16, cchRmSessionKey+1) + if ret, _, _ := procRmStartSession.Call( + uintptr(unsafe.Pointer(&session)), + 0, + uintptr(unsafe.Pointer(&sessionKey[0])), + ); ret != 0 { + return nil, fmt.Errorf("RmStartSession failed (%d)", ret) + } + defer procRmEndSession.Call(uintptr(session)) + + // rgsFileNames is an array of WCHAR* — &pathPtr is a one-element array. + if ret, _, _ := procRmRegisterResources.Call( + uintptr(session), + 1, // nFiles + uintptr(unsafe.Pointer(&pathPtr)), + 0, 0, // nApplications, rgApplications + 0, 0, // nServices, rgsServiceNames + ); ret != 0 { + return nil, fmt.Errorf("RmRegisterResources failed (%d)", ret) + } + runtime.KeepAlive(pathPtr) + + // First RmGetList sizes the buffer (NULL list). + var needed, count, rebootReasons uint32 + ret, _, _ := procRmGetList.Call( + uintptr(session), + uintptr(unsafe.Pointer(&needed)), + uintptr(unsafe.Pointer(&count)), + 0, + uintptr(unsafe.Pointer(&rebootReasons)), + ) + if ret != 0 && ret != errorMoreData { + return nil, fmt.Errorf("RmGetList failed (%d)", ret) + } + if needed == 0 { + return nil, fmt.Errorf("no process is holding %s", path) + } + + infos := make([]rmProcessInfo, needed) + count = needed + if ret, _, _ := procRmGetList.Call( + uintptr(session), + uintptr(unsafe.Pointer(&needed)), + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&infos[0])), + uintptr(unsafe.Pointer(&rebootReasons)), + ); ret != 0 { + return nil, fmt.Errorf("RmGetList failed (%d)", ret) + } + + var pids []int + seen := make(map[int]bool) + for i := 0; i < int(count) && i < len(infos); i++ { + pid := int(infos[i].Process.ProcessID) + if pid > 0 && !seen[pid] { + pids = append(pids, pid) + seen[pid] = true + } + } + if len(pids) == 0 { + return nil, fmt.Errorf("no process is holding %s", path) + } + return pids, nil +} diff --git a/internal/target/file_windows_test.go b/internal/target/file_windows_test.go new file mode 100644 index 0000000..d71e4fc --- /dev/null +++ b/internal/target/file_windows_test.go @@ -0,0 +1,20 @@ +//go:build windows + +package target + +import ( + "testing" + "unsafe" +) + +// rmUniqueProcess / rmProcessInfo must match the Win32 structs byte-for-byte or +// RmGetList misreads the kernel-filled buffer. RM_PROCESS_INFO = 12 (unique +// process) + 512 (app name) + 128 (service name) + 16 (four 32-bit fields) = 668. +func TestRmProcessInfoSize(t *testing.T) { + if got := unsafe.Sizeof(rmUniqueProcess{}); got != 12 { + t.Errorf("sizeof(rmUniqueProcess) = %d, want 12", got) + } + if got := unsafe.Sizeof(rmProcessInfo{}); got != 668 { + t.Errorf("sizeof(rmProcessInfo) = %d, want 668", got) + } +} diff --git a/internal/target/integration_test.go b/internal/target/integration_test.go new file mode 100644 index 0000000..db8a56d --- /dev/null +++ b/internal/target/integration_test.go @@ -0,0 +1,190 @@ +//go:build linux || darwin || freebsd || windows + +package target + +import ( + "net" + "os" + "os/exec" + "runtime" + "slices" + "testing" + "time" +) + +// Integration smoke tests for the target package's platform-specific +// resolution paths. They spin up real OS state (a sleeper child, a TCP +// listener) the test can identify by PID, then confirm the resolver finds it. + +// startSleeper spawns a long-running child process suitable for name-resolution +// assertions. Returns the child's PID and process name; the caller must invoke +// the returned cleanup func to kill it. +func startSleeper(t *testing.T) (pid int, name string, cleanup func()) { + t.Helper() + + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + // `ping -n N 127.0.0.1` is a reliable cross-version Windows sleeper + // that doesn't require a TTY (unlike `timeout /nobreak`). + cmd = exec.Command("ping", "-n", "60", "127.0.0.1") + name = "ping" + default: + cmd = exec.Command("sleep", "60") + name = "sleep" + } + + if err := cmd.Start(); err != nil { + t.Skipf("could not spawn %s for integration test: %v", name, err) + } + return cmd.Process.Pid, name, func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } +} + +// TestIntegration_ResolveNameFindsSpawnedChild proves the name resolver talks +// to the real OS process table. We can't ResolveName against the test binary +// itself because every platform's name resolver excludes self+ancestors — +// that's a feature (so `witr bash` doesn't always match the user's own +// shell), but it forces this test to use a spawned child instead. +func TestIntegration_ResolveNameFindsSpawnedChild(t *testing.T) { + childPID, name, cleanup := startSleeper(t) + defer cleanup() + + // Give the OS a beat to register the child. ToolHelp32 / /proc updates + // are usually instant, but CI runners can be sluggish. + var pids []int + var lastErr error + for i := 0; i < 10; i++ { + var err error + pids, err = ResolveName(name, true) + if err == nil && slices.Contains(pids, childPID) { + return + } + lastErr = err + time.Sleep(100 * time.Millisecond) + } + t.Errorf("ResolveName(%q) did not find spawned child PID %d after retries; got %v (last err: %v)", + name, childPID, pids, lastErr) +} + +// TestIntegration_ResolveNameFindsGrep is a regression test for the removed +// "grep" name-matching exclusion. witr reads the process table directly (no +// `ps | grep` pipeline), so a real grep process must be resolvable by name — +// previously it was silently filtered out on every Unix platform. +func TestIntegration_ResolveNameFindsGrep(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("grep is not available on Windows") + } + if _, err := exec.LookPath("grep"); err != nil { + t.Skipf("grep not found in PATH: %v", err) + } + + // grep with no file argument blocks reading stdin; keeping the pipe open + // holds it alive for the duration of the lookup. + cmd := exec.Command("grep", "witr-regression-pattern") + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatalf("StdinPipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Skipf("could not spawn grep: %v", err) + } + childPID := cmd.Process.Pid + defer func() { + _ = stdin.Close() + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + var pids []int + var lastErr error + for i := 0; i < 10; i++ { + pids, lastErr = ResolveName("grep", true) + if lastErr == nil && slices.Contains(pids, childPID) { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Errorf("ResolveName(%q) did not find spawned grep PID %d after retries; got %v (last err: %v)", + "grep", childPID, pids, lastErr) +} + +// TestIntegration_ResolvePortFindsLoopbackListener binds a real TCP listener +// on a random localhost port and asserts ResolvePort attributes it to the +// test process. This drives the platform's port-resolution machinery end to +// end (netstat/lsof/sockstat/Win32 netstat plus /proc fd scan). +func TestIntegration_ResolvePortFindsLoopbackListener(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + self := os.Getpid() + + pids, err := ResolvePort(port) + if err != nil { + t.Fatalf("ResolvePort(%d): %v", port, err) + } + if !slices.Contains(pids, self) { + t.Errorf("ResolvePort(%d) = %v, missing self PID %d", port, pids, self) + } +} + +// TestIntegration_ResolvePortNonexistent asserts the resolver errors cleanly +// on a port nothing has bound. Picking a random ephemeral port and closing +// the listener gives us a port we know nobody else owns. +func TestIntegration_ResolvePortNonexistent(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() // free the port immediately + + // The kernel may keep the port in TIME_WAIT briefly. Loop a few times + // until the port is genuinely unowned. + for i := 0; i < 10; i++ { + _, err := ResolvePort(port) + if err != nil { + return // expected + } + time.Sleep(50 * time.Millisecond) + } + t.Errorf("ResolvePort(%d) returned nil error for an unbound port", port) +} + +// TestIntegration_ResolveFileSelf creates a file, holds it open, and confirms +// the platform's file resolver attributes it to the test process — exercising +// /proc/fd (Linux), lsof (macOS), fstat (FreeBSD), and the Restart Manager +// (Windows). macOS/FreeBSD lean on external tools that may be absent or quirky +// in minimal CI images, so a miss there is tolerated rather than failed. +func TestIntegration_ResolveFileSelf(t *testing.T) { + f, err := os.CreateTemp("", "witr-file-*.tmp") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + defer func() { _ = os.Remove(f.Name()) }() + defer f.Close() + if _, err := f.WriteString("witr"); err != nil { + t.Fatalf("write temp file: %v", err) + } + + self := os.Getpid() + var pids []int + var lastErr error + for i := 0; i < 10; i++ { + pids, lastErr = ResolveFile(f.Name()) + if lastErr == nil && slices.Contains(pids, self) { + return + } + time.Sleep(100 * time.Millisecond) + } + if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { + t.Skipf("ResolveFile on %s did not attribute the file to self (got %v, err %v); tolerated", runtime.GOOS, pids, lastErr) + } + t.Errorf("ResolveFile(%q) did not find self PID %d; got %v (last err: %v)", f.Name(), self, pids, lastErr) +} diff --git a/internal/target/name_darwin.go b/internal/target/name_darwin.go new file mode 100644 index 0000000..e4e29bb --- /dev/null +++ b/internal/target/name_darwin.go @@ -0,0 +1,171 @@ +//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 { + procPIDs = append(procPIDs, pid) + continue + } + + // Match against full command line + if exact { + match = matchesExactToken(args, lowerName) + if match { + procPIDs = append(procPIDs, pid) + } + } else { + if strings.Contains(args, lowerName) { + 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/