Compare commits
7 Commits
feat/home-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b10f442635 | |||
| 928ffa8934 | |||
|
|
57d592408d | ||
|
|
df15770a94 | ||
|
|
bdc4de0229 | ||
|
|
7c757f2769 | ||
|
|
d792e10d3c |
51
.gitea/workflows/release.yml
Normal file
51
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build -ldflags="-s -w" -o snitch .
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITEATOKEN: ${{ secrets.GITEATOKEN }}
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VER="${VERSION#v}"
|
||||
|
||||
# Package binary
|
||||
tar czf "snitch_${VER}_linux_amd64.tar.gz" snitch
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -s -X POST "https://gitea.bitua.io/api/v1/repos/bitua/snitch/releases" \
|
||||
-H "Authorization: token ${GITEATOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"${VERSION}\"}" | jq -r .id)
|
||||
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Created release ID: $RELEASE_ID"
|
||||
|
||||
# Upload asset
|
||||
curl -s -X POST "https://gitea.bitua.io/api/v1/repos/bitua/snitch/releases/${RELEASE_ID}/assets?name=snitch_${VER}_linux_amd64.tar.gz" \
|
||||
-H "Authorization: token ${GITEATOKEN}" \
|
||||
-F "attachment=@snitch_${VER}_linux_amd64.tar.gz"
|
||||
|
||||
echo "Release ${VERSION} complete!"
|
||||
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
thanks for taking the time to fill out this bug report!
|
||||
please provide as much detail as possible to help us investigate.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear description of what the bug is.
|
||||
placeholder: What happened? What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this issue?
|
||||
placeholder: |
|
||||
1. run `snitch ...`
|
||||
2. press '...'
|
||||
3. see error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened? Include any error messages.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of snitch are you running? (`snitch version`)
|
||||
placeholder: "v0.1.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS Version
|
||||
description: e.g., Ubuntu 22.04, macOS 14.1, Arch Linux
|
||||
placeholder: "Ubuntu 22.04"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
label: Installation Method
|
||||
options:
|
||||
- Homebrew
|
||||
- go install
|
||||
- Nix/NixOS
|
||||
- AUR
|
||||
- Binary download
|
||||
- Install script
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: Paste your `~/.config/snitch/snitch.toml` if relevant (optional)
|
||||
render: toml
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Terminal Output
|
||||
description: Paste any relevant terminal output or error messages
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem? Screenshots, related issues, etc.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Question / Discussion
|
||||
url: https://github.com/karol-broda/snitch/discussions
|
||||
about: Ask questions, share ideas, or discuss snitch usage
|
||||
- name: Documentation
|
||||
url: https://github.com/karol-broda/snitch#readme
|
||||
about: Check the README for usage and configuration docs
|
||||
|
||||
69
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
69
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
title: "[feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
thanks for suggesting a feature! please describe your idea clearly.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Use Case
|
||||
description: What problem does this solve? What are you trying to accomplish?
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe your proposed solution or feature
|
||||
placeholder: I would like snitch to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: What part of snitch does this affect?
|
||||
options:
|
||||
- TUI (interactive mode)
|
||||
- CLI output (ls, json, watch)
|
||||
- Filtering / Sorting
|
||||
- DNS resolution
|
||||
- Configuration
|
||||
- Installation / Packaging
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, mockups, or screenshots about the feature request
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Contribution
|
||||
description: Would you be interested in contributing this feature?
|
||||
options:
|
||||
- label: I'd be willing to submit a PR for this feature
|
||||
|
||||
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
## Description
|
||||
|
||||
<!-- describe what this PR does -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- link any related issues: fixes #123, closes #456 -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] bug fix
|
||||
- [ ] new feature
|
||||
- [ ] breaking change
|
||||
- [ ] documentation
|
||||
- [ ] refactoring
|
||||
- [ ] other: <!-- describe -->
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
<!-- required: select one -->
|
||||
|
||||
- [ ] `ai: none` — no ai assistance used
|
||||
- [ ] `ai: assisted` — ai helped with portions (describe below)
|
||||
- [ ] `ai: generated` — significant portions were ai-generated (describe below)
|
||||
|
||||
<!-- if ai-assisted or ai-generated, briefly describe what was ai-generated: -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] i have tested these changes locally
|
||||
- [ ] i have run `make test` and all tests pass
|
||||
- [ ] i have run `make lint` and fixed any issues
|
||||
- [ ] i have updated documentation if needed
|
||||
- [ ] my code follows the existing style of the project
|
||||
|
||||
## Screenshots / Terminal Output
|
||||
|
||||
<!-- if applicable, add screenshots or terminal output showing the change -->
|
||||
|
||||
76
.github/workflows/release.yaml
vendored
76
.github/workflows/release.yaml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release-linux:
|
||||
@@ -48,3 +49,78 @@ jobs:
|
||||
args: release --clean --config .goreleaser-darwin.yaml --skip=validate
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
release-containers:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [alpine, debian, ubuntu, scratch]
|
||||
include:
|
||||
- variant: alpine
|
||||
is_default: true
|
||||
- variant: debian
|
||||
is_default: false
|
||||
- variant: ubuntu
|
||||
is_default: false
|
||||
- variant: scratch
|
||||
is_default: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: DeterminateSystems/nix-installer-action@v17
|
||||
|
||||
- uses: nix-community/cache-nix-action@v6
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||
|
||||
- name: login to ghcr
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: build container
|
||||
run: nix build ".#snitch-${{ matrix.variant }}" --print-out-paths
|
||||
|
||||
- name: load and push container
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
REPO: ghcr.io/${{ github.repository_owner }}/snitch
|
||||
VARIANT: ${{ matrix.variant }}
|
||||
IS_DEFAULT: ${{ matrix.is_default }}
|
||||
run: |
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
docker load < result
|
||||
|
||||
IMAGE_TAG=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep "snitch:.*-${VARIANT}" | head -1)
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "error: could not find loaded image for ${VARIANT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker tag "$IMAGE_TAG" "${REPO}:${VERSION}-${VARIANT}"
|
||||
docker tag "$IMAGE_TAG" "${REPO}:latest-${VARIANT}"
|
||||
docker push "${REPO}:${VERSION}-${VARIANT}"
|
||||
docker push "${REPO}:latest-${VARIANT}"
|
||||
|
||||
if [ "$IS_DEFAULT" = "true" ]; then
|
||||
docker tag "$IMAGE_TAG" "${REPO}:${VERSION}"
|
||||
docker tag "$IMAGE_TAG" "${REPO}:latest"
|
||||
docker push "${REPO}:${VERSION}"
|
||||
docker push "${REPO}:latest"
|
||||
fi
|
||||
|
||||
- name: summary
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
REPO: ghcr.io/${{ github.repository_owner }}/snitch
|
||||
VARIANT: ${{ matrix.variant }}
|
||||
run: |
|
||||
VERSION="${VERSION#v}"
|
||||
echo "pushed ${REPO}:${VERSION}-${VARIANT}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -82,6 +82,7 @@ aurs:
|
||||
package: |-
|
||||
install -Dm755 "./snitch" "${pkgdir}/usr/bin/snitch"
|
||||
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/snitch/LICENSE"
|
||||
install -Dm644 "./README.md" "${pkgdir}/usr/share/doc/snitch/README.md"
|
||||
commit_msg_template: "Update to {{ .Tag }}"
|
||||
skip_upload: auto
|
||||
|
||||
|
||||
85
CODE_OF_CONDUCT.md
Normal file
85
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
# 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, caste, color, 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 by opening a [GitHub issue](https://github.com/karol-broda/snitch/issues) or contacting the maintainer directly. 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.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
||||
170
CONTRIBUTING.md
Normal file
170
CONTRIBUTING.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Contributing to snitch
|
||||
|
||||
thanks for your interest in contributing to snitch! this document outlines how to get started.
|
||||
|
||||
## development setup
|
||||
|
||||
### prerequisites
|
||||
|
||||
- go 1.21 or later
|
||||
- make (optional, but recommended)
|
||||
- linux or macos
|
||||
|
||||
### building from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/karol-broda/snitch.git
|
||||
cd snitch
|
||||
|
||||
# build
|
||||
make build
|
||||
# or
|
||||
go build -o snitch .
|
||||
|
||||
# run
|
||||
./snitch
|
||||
```
|
||||
|
||||
### running tests
|
||||
|
||||
```bash
|
||||
make test
|
||||
# or
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### linting
|
||||
|
||||
```bash
|
||||
make lint
|
||||
# requires golangci-lint
|
||||
```
|
||||
|
||||
## making changes
|
||||
|
||||
### branch naming
|
||||
|
||||
use descriptive branch names following the [conventional branch naming](https://conventional-branch.github.io/) pattern:
|
||||
|
||||
- `fix/description` for bug fixes
|
||||
- `feat/description` for new features
|
||||
- `docs/description` for documentation changes
|
||||
- `refactor/description` for refactoring
|
||||
- `chore/description` for maintenance tasks
|
||||
|
||||
### code style
|
||||
|
||||
- follow existing code patterns and conventions
|
||||
- avoid deep nesting; refactor for readability
|
||||
- use explicit checks rather than implicit boolean coercion
|
||||
- keep functions focused on a single responsibility
|
||||
- write meaningful variable names
|
||||
- add comments only when they clarify non-obvious behavior
|
||||
|
||||
### commits
|
||||
|
||||
this project follows [conventional commits](https://www.conventionalcommits.org/). format:
|
||||
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
types: `fix`, `feat`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
|
||||
|
||||
examples:
|
||||
- `fix: resolve crash on empty input`
|
||||
- `feat(tui): add vim-style navigation`
|
||||
- `docs: update installation instructions`
|
||||
- `fix!: change default config path` (breaking change)
|
||||
|
||||
## ai-assisted contributions
|
||||
|
||||
ai tools (copilot, chatgpt, claude, cursor, etc.) are welcome, but i require transparency.
|
||||
|
||||
### disclosure requirements
|
||||
|
||||
**you must disclose ai involvement** in your pull request. add one of the following to your PR description:
|
||||
|
||||
- `ai: none` — no ai assistance used
|
||||
- `ai: assisted` — ai helped with portions (specify what)
|
||||
- `ai: generated` — significant portions were ai-generated
|
||||
|
||||
for commits with substantial ai involvement, use a git trailer:
|
||||
|
||||
```
|
||||
feat: add new filtering option
|
||||
|
||||
Co-authored-by: AI Assistant <ai@example.com>
|
||||
```
|
||||
|
||||
### your responsibilities
|
||||
|
||||
- **you own the code** — you are accountable for all submitted code, regardless of how it was produced
|
||||
- **you must understand it** — don't submit code you can't explain or debug
|
||||
- **you must test it** — verify the code works as intended before submitting
|
||||
- **you must review it** — check for correctness, security issues, and code style compliance
|
||||
|
||||
### what i check
|
||||
|
||||
ai-generated code often has patterns i look for:
|
||||
- overly verbose or generic variable names
|
||||
- unnecessary abstractions or over-engineering
|
||||
- hallucinated apis or non-existent functions
|
||||
- inconsistent style with the rest of the codebase
|
||||
|
||||
i may ask clarifying questions or request changes if code appears to be unreviewed ai output.
|
||||
|
||||
### why i require disclosure
|
||||
|
||||
- maintains trust and transparency in the project
|
||||
- helps reviewers understand context and potential issues
|
||||
- ensures contributors remain engaged with their submissions
|
||||
- respects the collaborative nature of open source
|
||||
|
||||
## submitting changes
|
||||
|
||||
1. fork the repository
|
||||
2. create a feature branch from `master`
|
||||
3. make your changes
|
||||
4. run tests: `make test`
|
||||
5. run linter: `make lint`
|
||||
6. push to your fork
|
||||
7. open a pull request
|
||||
|
||||
### pull request guidelines
|
||||
|
||||
- fill out the PR template
|
||||
- link any related issues
|
||||
- keep PRs focused on a single change
|
||||
- respond to review feedback promptly
|
||||
|
||||
## reporting bugs
|
||||
|
||||
use the [bug report template](https://github.com/karol-broda/snitch/issues/new?template=bug_report.yml) and include:
|
||||
|
||||
- snitch version (`snitch version`)
|
||||
- operating system and version
|
||||
- steps to reproduce
|
||||
- expected vs actual behavior
|
||||
|
||||
## requesting features
|
||||
|
||||
use the [feature request template](https://github.com/karol-broda/snitch/issues/new?template=feature_request.yml) and describe:
|
||||
|
||||
- the problem you're trying to solve
|
||||
- your proposed solution
|
||||
- any alternatives you've considered
|
||||
|
||||
## getting help
|
||||
|
||||
- open a [discussion](https://github.com/karol-broda/snitch/discussions) for questions
|
||||
- check existing [issues](https://github.com/karol-broda/snitch/issues) before opening new ones
|
||||
|
||||
## license
|
||||
|
||||
by contributing, you agree that your contributions will be licensed under the project's MIT license.
|
||||
|
||||
80
README.md
80
README.md
@@ -44,6 +44,45 @@ nix profile install github:karol-broda/snitch
|
||||
# then use: inputs.snitch.packages.${system}.default
|
||||
```
|
||||
|
||||
### home-manager (flake)
|
||||
|
||||
add snitch to your flake inputs and import the home-manager module:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
home-manager.url = "github:nix-community/home-manager";
|
||||
snitch.url = "github:karol-broda/snitch";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, home-manager, snitch, ... }: {
|
||||
homeConfigurations."user" = home-manager.lib.homeManagerConfiguration {
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
modules = [
|
||||
snitch.homeManagerModules.default
|
||||
{
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
# optional: use the flake's package instead of nixpkgs
|
||||
# package = snitch.packages.x86_64-linux.default;
|
||||
settings = {
|
||||
defaults = {
|
||||
theme = "catppuccin-mocha";
|
||||
interval = "2s";
|
||||
resolve = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
available themes: `ansi`, `catppuccin-mocha`, `catppuccin-macchiato`, `catppuccin-frappe`, `catppuccin-latte`, `gruvbox-dark`, `gruvbox-light`, `dracula`, `nord`, `tokyo-night`, `tokyo-night-storm`, `tokyo-night-light`, `solarized-dark`, `solarized-light`, `one-dark`, `mono`
|
||||
|
||||
### arch linux (aur)
|
||||
|
||||
```bash
|
||||
@@ -68,6 +107,47 @@ curl -sSL https://raw.githubusercontent.com/karol-broda/snitch/master/install.sh
|
||||
|
||||
> **macos:** the install script automatically removes the quarantine attribute (`com.apple.quarantine`) from the binary to allow it to run without gatekeeper warnings. to disable this, set `KEEP_QUARANTINE=1`.
|
||||
|
||||
### docker
|
||||
|
||||
pre-built oci images available from github container registry:
|
||||
|
||||
```bash
|
||||
# pull from ghcr.io
|
||||
docker pull ghcr.io/karol-broda/snitch:latest # alpine (default)
|
||||
docker pull ghcr.io/karol-broda/snitch:latest-alpine # alpine (~17MB)
|
||||
docker pull ghcr.io/karol-broda/snitch:latest-scratch # minimal, binary only (~9MB)
|
||||
docker pull ghcr.io/karol-broda/snitch:latest-debian # debian trixie
|
||||
docker pull ghcr.io/karol-broda/snitch:latest-ubuntu # ubuntu 24.04
|
||||
|
||||
# or use a specific version
|
||||
docker pull ghcr.io/karol-broda/snitch:0.2.0-alpine
|
||||
```
|
||||
|
||||
alternatively, build locally via nix flake:
|
||||
|
||||
```bash
|
||||
nix build github:karol-broda/snitch#snitch-alpine
|
||||
docker load < result
|
||||
```
|
||||
|
||||
**running the container:**
|
||||
|
||||
```bash
|
||||
# basic usage - sees host sockets but not process names
|
||||
docker run --rm --net=host snitch:latest ls
|
||||
|
||||
# full info - includes PID, process name, user
|
||||
docker run --rm --net=host --pid=host --cap-add=SYS_PTRACE snitch:latest ls
|
||||
```
|
||||
|
||||
| flag | purpose |
|
||||
|------|---------|
|
||||
| `--net=host` | share host network namespace (required to see host connections) |
|
||||
| `--pid=host` | share host pid namespace (needed for process info) |
|
||||
| `--cap-add=SYS_PTRACE` | read process details from `/proc/<pid>` |
|
||||
|
||||
> **note:** `CAP_NET_ADMIN` and `CAP_NET_RAW` are not required. snitch reads from `/proc/net/*` which doesn't need special network capabilities.
|
||||
|
||||
### binary
|
||||
|
||||
download from [releases](https://github.com/karol-broda/snitch/releases):
|
||||
|
||||
56
SECURITY.md
Normal file
56
SECURITY.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| latest | :white_check_mark: |
|
||||
| < latest| :x: |
|
||||
|
||||
i recommend always using the latest version of snitch.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
if you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **do not** open a public issue for security vulnerabilities
|
||||
2. email the maintainer directly or use github's [private vulnerability reporting](https://github.com/karol-broda/snitch/security/advisories/new)
|
||||
3. include as much detail as possible:
|
||||
- description of the vulnerability
|
||||
- steps to reproduce
|
||||
- potential impact
|
||||
- suggested fix (if any)
|
||||
|
||||
## What to Expect
|
||||
|
||||
- acknowledgment of your report within 48 hours
|
||||
- regular updates on the progress of addressing the issue
|
||||
- credit in the release notes (unless you prefer to remain anonymous)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
snitch reads network socket information from the operating system:
|
||||
|
||||
- **linux**: reads from `/proc/net/*` which requires appropriate permissions
|
||||
- **macos**: uses system APIs that may require elevated privileges
|
||||
|
||||
snitch does not:
|
||||
- make network connections (except for `snitch upgrade` which fetches from github)
|
||||
- write to system files
|
||||
- collect or transmit any data
|
||||
|
||||
## Scope
|
||||
|
||||
the following are considered in-scope for security reports:
|
||||
|
||||
- vulnerabilities in snitch code
|
||||
- insecure defaults or configurations
|
||||
- privilege escalation issues
|
||||
- information disclosure beyond intended functionality
|
||||
|
||||
out of scope:
|
||||
|
||||
- social engineering attacks
|
||||
- issues in dependencies (report to the upstream project)
|
||||
- issues requiring physical access to the machine
|
||||
|
||||
72
cmd/ls.go
72
cmd/ls.go
@@ -27,6 +27,7 @@ import (
|
||||
// ls-specific flags
|
||||
var (
|
||||
outputFormat string
|
||||
outputFile string
|
||||
noHeaders bool
|
||||
showTimestamp bool
|
||||
sortBy string
|
||||
@@ -72,9 +73,77 @@ func runListCommand(outputFormat string, args []string) {
|
||||
selectedFields = strings.Split(fields, ",")
|
||||
}
|
||||
|
||||
// handle file output
|
||||
if outputFile != "" {
|
||||
writeToFile(rt.Connections, outputFile, selectedFields)
|
||||
return
|
||||
}
|
||||
|
||||
renderList(rt.Connections, outputFormat, selectedFields)
|
||||
}
|
||||
|
||||
func writeToFile(connections []collector.Connection, filename string, selectedFields []string) {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create file: %v", err)
|
||||
}
|
||||
defer errutil.Close(file)
|
||||
|
||||
// determine format from extension
|
||||
format := "csv"
|
||||
lowerFilename := strings.ToLower(filename)
|
||||
if strings.HasSuffix(lowerFilename, ".json") {
|
||||
format = "json"
|
||||
} else if strings.HasSuffix(lowerFilename, ".tsv") {
|
||||
format = "tsv"
|
||||
}
|
||||
|
||||
if len(selectedFields) == 0 {
|
||||
selectedFields = []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}
|
||||
if showTimestamp {
|
||||
selectedFields = append([]string{"ts"}, selectedFields...)
|
||||
}
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(connections); err != nil {
|
||||
log.Fatalf("failed to write JSON: %v", err)
|
||||
}
|
||||
case "tsv":
|
||||
writeDelimited(file, connections, "\t", !noHeaders, selectedFields)
|
||||
default:
|
||||
writeDelimited(file, connections, ",", !noHeaders, selectedFields)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "exported %d connections to %s\n", len(connections), filename)
|
||||
}
|
||||
|
||||
func writeDelimited(w io.Writer, connections []collector.Connection, delimiter string, headers bool, selectedFields []string) {
|
||||
if headers {
|
||||
headerRow := make([]string, len(selectedFields))
|
||||
for i, field := range selectedFields {
|
||||
headerRow[i] = strings.ToUpper(field)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, strings.Join(headerRow, delimiter))
|
||||
}
|
||||
|
||||
for _, conn := range connections {
|
||||
fieldMap := getFieldMap(conn)
|
||||
row := make([]string, len(selectedFields))
|
||||
for i, field := range selectedFields {
|
||||
val := fieldMap[field]
|
||||
if delimiter == "," && (strings.Contains(val, ",") || strings.Contains(val, "\"") || strings.Contains(val, "\n")) {
|
||||
val = "\"" + strings.ReplaceAll(val, "\"", "\"\"") + "\""
|
||||
}
|
||||
row[i] = val
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, strings.Join(row, delimiter))
|
||||
}
|
||||
}
|
||||
|
||||
func renderList(connections []collector.Connection, format string, selectedFields []string) {
|
||||
switch format {
|
||||
case "json":
|
||||
@@ -122,6 +191,8 @@ func getFieldMap(c collector.Connection) map[string]string {
|
||||
return map[string]string{
|
||||
"pid": strconv.Itoa(c.PID),
|
||||
"process": c.Process,
|
||||
"cmdline": c.Cmdline,
|
||||
"cwd": c.Cwd,
|
||||
"user": c.User,
|
||||
"uid": strconv.Itoa(c.UID),
|
||||
"proto": c.Proto,
|
||||
@@ -395,6 +466,7 @@ func init() {
|
||||
|
||||
// ls-specific flags
|
||||
lsCmd.Flags().StringVarP(&outputFormat, "output", "o", cfg.Defaults.OutputFormat, "Output format (table, wide, json, csv)")
|
||||
lsCmd.Flags().StringVarP(&outputFile, "output-file", "O", "", "Write output to file (format detected from extension: .csv, .tsv, .json)")
|
||||
lsCmd.Flags().BoolVar(&noHeaders, "no-headers", cfg.Defaults.NoHeaders, "Omit headers for table/csv output")
|
||||
lsCmd.Flags().BoolVar(&showTimestamp, "ts", false, "Include timestamp in output")
|
||||
lsCmd.Flags().StringVarP(&sortBy, "sort", "s", cfg.Defaults.SortBy, "Sort by column (e.g., pid:desc)")
|
||||
|
||||
44
flake.nix
44
flake.nix
@@ -80,11 +80,18 @@
|
||||
in
|
||||
{
|
||||
packages = eachSystem (system:
|
||||
let pkgs = pkgsFor system; in
|
||||
{
|
||||
default = mkSnitch pkgs;
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
snitch = mkSnitch pkgs;
|
||||
}
|
||||
# containers only available on linux
|
||||
containers = if pkgs.stdenv.isLinux
|
||||
then import ./nix/containers.nix { inherit pkgs snitch; }
|
||||
else { };
|
||||
in
|
||||
{
|
||||
default = snitch;
|
||||
inherit snitch;
|
||||
} // containers
|
||||
);
|
||||
|
||||
devShells = eachSystem (system:
|
||||
@@ -94,7 +101,7 @@
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [ go pkgs.git pkgs.vhs ];
|
||||
packages = [ go pkgs.git pkgs.vhs pkgs.nix-prefetch-docker ];
|
||||
env.GOTOOLCHAIN = "local";
|
||||
shellHook = ''
|
||||
echo "go toolchain: $(go version)"
|
||||
@@ -106,5 +113,32 @@
|
||||
overlays.default = final: _prev: {
|
||||
snitch = mkSnitch final;
|
||||
};
|
||||
|
||||
homeManagerModules.default = import ./nix/hm-module.nix;
|
||||
homeManagerModules.snitch = self.homeManagerModules.default;
|
||||
|
||||
# alias for flake-parts compatibility
|
||||
homeModules.default = self.homeManagerModules.default;
|
||||
homeModules.snitch = self.homeManagerModules.default;
|
||||
|
||||
checks = eachSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ self.overlays.default ];
|
||||
};
|
||||
in
|
||||
{
|
||||
# home manager module tests
|
||||
hm-module = import ./nix/tests/hm-module-test.nix {
|
||||
inherit pkgs;
|
||||
lib = pkgs.lib;
|
||||
hmModule = self.homeManagerModules.default;
|
||||
};
|
||||
|
||||
# package builds correctly
|
||||
package = self.packages.${system}.default;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,19 @@ static const char* get_username(int uid) {
|
||||
return pw->pw_name;
|
||||
}
|
||||
|
||||
// get current working directory for a process
|
||||
static int get_proc_cwd(int pid, char *path, int pathlen) {
|
||||
struct proc_vnodepathinfo vpi;
|
||||
int ret = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &vpi, sizeof(vpi));
|
||||
if (ret <= 0) {
|
||||
path[0] = '\0';
|
||||
return -1;
|
||||
}
|
||||
strncpy(path, vpi.pvi_cdir.vip_path, pathlen - 1);
|
||||
path[pathlen - 1] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
// socket info extraction - handles the union properly in C
|
||||
typedef struct {
|
||||
int family;
|
||||
@@ -164,6 +177,7 @@ func listAllPids() ([]int, error) {
|
||||
|
||||
func getConnectionsForPid(pid int) ([]Connection, error) {
|
||||
procName := getProcessName(pid)
|
||||
cwd := getProcessCwd(pid)
|
||||
uid := int(C.get_proc_uid(C.int(pid)))
|
||||
user := ""
|
||||
if uid >= 0 {
|
||||
@@ -198,7 +212,7 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
conn, ok := getSocketInfo(pid, int(fdInfo.proc_fd), procName, uid, user)
|
||||
conn, ok := getSocketInfo(pid, int(fdInfo.proc_fd), procName, cwd, uid, user)
|
||||
if ok {
|
||||
connections = append(connections, conn)
|
||||
}
|
||||
@@ -207,7 +221,7 @@ func getConnectionsForPid(pid int) ([]Connection, error) {
|
||||
return connections, nil
|
||||
}
|
||||
|
||||
func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connection, bool) {
|
||||
func getSocketInfo(pid, fd int, procName, cwd string, uid int, user string) (Connection, bool) {
|
||||
var info C.socket_info_t
|
||||
|
||||
ret := C.get_socket_info(C.int(pid), C.int(fd), &info)
|
||||
@@ -276,6 +290,7 @@ func getSocketInfo(pid, fd int, procName string, uid int, user string) (Connecti
|
||||
Rport: int(info.rport),
|
||||
PID: pid,
|
||||
Process: procName,
|
||||
Cwd: cwd,
|
||||
UID: uid,
|
||||
User: user,
|
||||
Interface: guessNetworkInterface(laddr),
|
||||
@@ -293,6 +308,15 @@ func getProcessName(pid int) string {
|
||||
return C.GoString(&name[0])
|
||||
}
|
||||
|
||||
func getProcessCwd(pid int) string {
|
||||
var path [1024]C.char
|
||||
ret := C.get_proc_cwd(C.int(pid), &path[0], 1024)
|
||||
if ret != 0 {
|
||||
return ""
|
||||
}
|
||||
return C.GoString(&path[0])
|
||||
}
|
||||
|
||||
func ipv4ToString(addr uint32) string {
|
||||
ip := make(net.IP, 4)
|
||||
ip[0] = byte(addr)
|
||||
|
||||
@@ -125,6 +125,8 @@ func GetAllConnections() ([]Connection, error) {
|
||||
type processInfo struct {
|
||||
pid int
|
||||
command string
|
||||
cmdline string
|
||||
cwd string
|
||||
uid int
|
||||
user string
|
||||
}
|
||||
@@ -248,34 +250,45 @@ func scanProcessSockets(pid int) []inodeEntry {
|
||||
|
||||
func getProcessInfo(pid int) (*processInfo, error) {
|
||||
info := &processInfo{pid: pid}
|
||||
pidStr := strconv.Itoa(pid)
|
||||
|
||||
commPath := filepath.Join("/proc", strconv.Itoa(pid), "comm")
|
||||
commPath := filepath.Join("/proc", pidStr, "comm")
|
||||
commData, err := os.ReadFile(commPath)
|
||||
if err == nil && len(commData) > 0 {
|
||||
info.command = strings.TrimSpace(string(commData))
|
||||
}
|
||||
|
||||
if info.command == "" {
|
||||
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
|
||||
cmdlineData, err := os.ReadFile(cmdlinePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cmdlineData) > 0 {
|
||||
parts := bytes.Split(cmdlineData, []byte{0})
|
||||
if len(parts) > 0 && len(parts[0]) > 0 {
|
||||
fullPath := string(parts[0])
|
||||
baseName := filepath.Base(fullPath)
|
||||
if strings.Contains(baseName, " ") {
|
||||
baseName = strings.Fields(baseName)[0]
|
||||
}
|
||||
info.command = baseName
|
||||
cmdlinePath := filepath.Join("/proc", pidStr, "cmdline")
|
||||
cmdlineData, err := os.ReadFile(cmdlinePath)
|
||||
if err == nil && len(cmdlineData) > 0 {
|
||||
parts := bytes.Split(cmdlineData, []byte{0})
|
||||
var args []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 {
|
||||
args = append(args, string(p))
|
||||
}
|
||||
}
|
||||
info.cmdline = strings.Join(args, " ")
|
||||
|
||||
if info.command == "" && len(parts) > 0 && len(parts[0]) > 0 {
|
||||
fullPath := string(parts[0])
|
||||
baseName := filepath.Base(fullPath)
|
||||
if strings.Contains(baseName, " ") {
|
||||
baseName = strings.Fields(baseName)[0]
|
||||
}
|
||||
info.command = baseName
|
||||
}
|
||||
} else if info.command == "" {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statusPath := filepath.Join("/proc", strconv.Itoa(pid), "status")
|
||||
cwdPath := filepath.Join("/proc", pidStr, "cwd")
|
||||
cwdLink, err := os.Readlink(cwdPath)
|
||||
if err == nil {
|
||||
info.cwd = cwdLink
|
||||
}
|
||||
|
||||
statusPath := filepath.Join("/proc", pidStr, "status")
|
||||
statusFile, err := os.Open(statusPath)
|
||||
if err != nil {
|
||||
return info, nil
|
||||
@@ -361,6 +374,8 @@ func parseProcNet(path, proto string, ipVersion int, inodeMap map[int64]*process
|
||||
if procInfo, exists := inodeMap[inode]; exists {
|
||||
conn.PID = procInfo.pid
|
||||
conn.Process = procInfo.command
|
||||
conn.Cmdline = procInfo.cmdline
|
||||
conn.Cwd = procInfo.cwd
|
||||
conn.UID = procInfo.uid
|
||||
conn.User = procInfo.user
|
||||
}
|
||||
|
||||
@@ -114,4 +114,60 @@ func BenchmarkBuildInodeMap(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = buildInodeToProcessMap()
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionHasCmdlineAndCwd(t *testing.T) {
|
||||
conns, err := GetConnections()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConnections() returned an error: %v", err)
|
||||
}
|
||||
|
||||
if len(conns) == 0 {
|
||||
t.Skip("no connections to test")
|
||||
}
|
||||
|
||||
// find a connection with a PID (owned by some process)
|
||||
var connWithProcess *Connection
|
||||
for i := range conns {
|
||||
if conns[i].PID > 0 {
|
||||
connWithProcess = &conns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if connWithProcess == nil {
|
||||
t.Skip("no connections with associated process found")
|
||||
}
|
||||
|
||||
t.Logf("testing connection: pid=%d process=%s", connWithProcess.PID, connWithProcess.Process)
|
||||
|
||||
// cmdline and cwd should be populated for connections with PIDs
|
||||
// note: they might be empty if we don't have permission to read them
|
||||
if connWithProcess.Cmdline != "" {
|
||||
t.Logf("cmdline: %s", connWithProcess.Cmdline)
|
||||
} else {
|
||||
t.Logf("cmdline is empty (might be permission issue)")
|
||||
}
|
||||
|
||||
if connWithProcess.Cwd != "" {
|
||||
t.Logf("cwd: %s", connWithProcess.Cwd)
|
||||
} else {
|
||||
t.Logf("cwd is empty (might be permission issue)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessInfoPopulatesCmdlineAndCwd(t *testing.T) {
|
||||
// test that getProcessInfo correctly populates cmdline and cwd for our own process
|
||||
info, err := getProcessInfo(1) // init process (usually has cwd of /)
|
||||
if err != nil {
|
||||
t.Logf("could not get process info for pid 1: %v", err)
|
||||
t.Skip("skipping - may not have permission")
|
||||
}
|
||||
|
||||
t.Logf("pid 1 info: command=%s cmdline=%s cwd=%s", info.command, info.cmdline, info.cwd)
|
||||
|
||||
// at minimum, we should have a command name
|
||||
if info.command == "" && info.cmdline == "" {
|
||||
t.Error("expected either command or cmdline to be populated")
|
||||
}
|
||||
}
|
||||
@@ -128,3 +128,75 @@ func TestSortByTimestamp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByRemoteAddr(t *testing.T) {
|
||||
conns := []Connection{
|
||||
{Raddr: "192.168.1.100", Rport: 443},
|
||||
{Raddr: "10.0.0.1", Rport: 80},
|
||||
{Raddr: "172.16.0.50", Rport: 8080},
|
||||
}
|
||||
|
||||
t.Run("sort by raddr ascending", func(t *testing.T) {
|
||||
c := make([]Connection, len(conns))
|
||||
copy(c, conns)
|
||||
|
||||
SortConnections(c, SortOptions{Field: SortByRaddr, Direction: SortAsc})
|
||||
|
||||
if c[0].Raddr != "10.0.0.1" {
|
||||
t.Errorf("expected '10.0.0.1' first, got '%s'", c[0].Raddr)
|
||||
}
|
||||
if c[1].Raddr != "172.16.0.50" {
|
||||
t.Errorf("expected '172.16.0.50' second, got '%s'", c[1].Raddr)
|
||||
}
|
||||
if c[2].Raddr != "192.168.1.100" {
|
||||
t.Errorf("expected '192.168.1.100' last, got '%s'", c[2].Raddr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sort by raddr descending", func(t *testing.T) {
|
||||
c := make([]Connection, len(conns))
|
||||
copy(c, conns)
|
||||
|
||||
SortConnections(c, SortOptions{Field: SortByRaddr, Direction: SortDesc})
|
||||
|
||||
if c[0].Raddr != "192.168.1.100" {
|
||||
t.Errorf("expected '192.168.1.100' first, got '%s'", c[0].Raddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSortByRemotePort(t *testing.T) {
|
||||
conns := []Connection{
|
||||
{Raddr: "192.168.1.1", Rport: 443},
|
||||
{Raddr: "192.168.1.2", Rport: 80},
|
||||
{Raddr: "192.168.1.3", Rport: 8080},
|
||||
}
|
||||
|
||||
t.Run("sort by rport ascending", func(t *testing.T) {
|
||||
c := make([]Connection, len(conns))
|
||||
copy(c, conns)
|
||||
|
||||
SortConnections(c, SortOptions{Field: SortByRport, Direction: SortAsc})
|
||||
|
||||
if c[0].Rport != 80 {
|
||||
t.Errorf("expected port 80 first, got %d", c[0].Rport)
|
||||
}
|
||||
if c[1].Rport != 443 {
|
||||
t.Errorf("expected port 443 second, got %d", c[1].Rport)
|
||||
}
|
||||
if c[2].Rport != 8080 {
|
||||
t.Errorf("expected port 8080 last, got %d", c[2].Rport)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sort by rport descending", func(t *testing.T) {
|
||||
c := make([]Connection, len(conns))
|
||||
copy(c, conns)
|
||||
|
||||
SortConnections(c, SortOptions{Field: SortByRport, Direction: SortDesc})
|
||||
|
||||
if c[0].Rport != 8080 {
|
||||
t.Errorf("expected port 8080 first, got %d", c[0].Rport)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ type Connection struct {
|
||||
TS time.Time `json:"ts"`
|
||||
PID int `json:"pid"`
|
||||
Process string `json:"process"`
|
||||
Cmdline string `json:"cmdline,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
User string `json:"user"`
|
||||
UID int `json:"uid"`
|
||||
Proto string `json:"proto"`
|
||||
|
||||
@@ -38,6 +38,10 @@ func sortFieldLabel(f collector.SortField) string {
|
||||
return "state"
|
||||
case collector.SortByProto:
|
||||
return "proto"
|
||||
case collector.SortByRaddr:
|
||||
return "raddr"
|
||||
case collector.SortByRport:
|
||||
return "rport"
|
||||
default:
|
||||
return "port"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/karol-broda/snitch/internal/collector"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/karol-broda/snitch/internal/collector"
|
||||
)
|
||||
|
||||
func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
@@ -14,6 +16,11 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.handleSearchKey(msg)
|
||||
}
|
||||
|
||||
// export modal captures all input
|
||||
if m.showExportModal {
|
||||
return m.handleExportKey(msg)
|
||||
}
|
||||
|
||||
// kill confirmation dialog
|
||||
if m.showKillConfirm {
|
||||
return m.handleKillConfirmKey(msg)
|
||||
@@ -52,6 +59,82 @@ func (m model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleExportKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.showExportModal = false
|
||||
m.exportFilename = ""
|
||||
m.exportFormat = ""
|
||||
m.exportError = ""
|
||||
|
||||
case "tab":
|
||||
// toggle format
|
||||
if m.exportFormat == "tsv" {
|
||||
m.exportFormat = "csv"
|
||||
} else {
|
||||
m.exportFormat = "tsv"
|
||||
}
|
||||
m.exportError = ""
|
||||
|
||||
case "enter":
|
||||
// build final filename with extension
|
||||
filename := m.exportFilename
|
||||
if filename == "" {
|
||||
filename = "connections"
|
||||
}
|
||||
|
||||
ext := ".csv"
|
||||
if m.exportFormat == "tsv" {
|
||||
ext = ".tsv"
|
||||
}
|
||||
|
||||
// only add extension if not already present
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".csv") &&
|
||||
!strings.HasSuffix(strings.ToLower(filename), ".tsv") {
|
||||
filename = filename + ext
|
||||
}
|
||||
m.exportFilename = filename
|
||||
|
||||
err := m.exportConnections()
|
||||
if err != nil {
|
||||
m.exportError = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
visible := m.visibleConnections()
|
||||
m.statusMessage = fmt.Sprintf("%s exported %d connections to %s", SymbolSuccess, len(visible), filename)
|
||||
m.statusExpiry = time.Now().Add(3 * time.Second)
|
||||
m.showExportModal = false
|
||||
m.exportFilename = ""
|
||||
m.exportFormat = ""
|
||||
m.exportError = ""
|
||||
return m, clearStatusAfter(3 * time.Second)
|
||||
|
||||
case "backspace":
|
||||
if len(m.exportFilename) > 0 {
|
||||
m.exportFilename = m.exportFilename[:len(m.exportFilename)-1]
|
||||
}
|
||||
m.exportError = ""
|
||||
|
||||
default:
|
||||
// only accept valid filename characters
|
||||
char := msg.String()
|
||||
if len(char) == 1 && isValidFilenameChar(char[0]) {
|
||||
m.exportFilename += char
|
||||
m.exportError = ""
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func isValidFilenameChar(c byte) bool {
|
||||
// allow alphanumeric, dash, underscore, dot
|
||||
return (c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.'
|
||||
}
|
||||
|
||||
func (m model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "enter", "q":
|
||||
@@ -157,6 +240,13 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.searchActive = true
|
||||
m.searchQuery = ""
|
||||
|
||||
// export
|
||||
case "x":
|
||||
m.showExportModal = true
|
||||
m.exportFilename = ""
|
||||
m.exportFormat = "csv"
|
||||
m.exportError = ""
|
||||
|
||||
// actions
|
||||
case "enter", " ":
|
||||
visible := m.visibleConnections()
|
||||
@@ -276,6 +366,8 @@ func (m *model) cycleSort() {
|
||||
collector.SortByPID,
|
||||
collector.SortByState,
|
||||
collector.SortByProto,
|
||||
collector.SortByRaddr,
|
||||
collector.SortByRport,
|
||||
}
|
||||
|
||||
for i, f := range fields {
|
||||
|
||||
@@ -2,6 +2,9 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -54,6 +57,12 @@ type model struct {
|
||||
statusMessage string
|
||||
statusExpiry time.Time
|
||||
|
||||
// export modal
|
||||
showExportModal bool
|
||||
exportFilename string
|
||||
exportFormat string // "csv" or "tsv"
|
||||
exportError string
|
||||
|
||||
// state persistence
|
||||
rememberState bool
|
||||
}
|
||||
@@ -214,6 +223,11 @@ func (m model) View() string {
|
||||
return m.overlayModal(main, m.renderKillModal())
|
||||
}
|
||||
|
||||
// overlay export modal on top of main view
|
||||
if m.showExportModal {
|
||||
return m.overlayModal(main, m.renderExportModal())
|
||||
}
|
||||
|
||||
return main
|
||||
}
|
||||
|
||||
@@ -289,12 +303,19 @@ func (m model) matchesFilters(c collector.Connection) bool {
|
||||
}
|
||||
|
||||
func (m model) matchesSearch(c collector.Connection) bool {
|
||||
lportStr := strconv.Itoa(c.Lport)
|
||||
rportStr := strconv.Itoa(c.Rport)
|
||||
pidStr := strconv.Itoa(c.PID)
|
||||
|
||||
return containsIgnoreCase(c.Process, m.searchQuery) ||
|
||||
containsIgnoreCase(c.Laddr, m.searchQuery) ||
|
||||
containsIgnoreCase(c.Raddr, m.searchQuery) ||
|
||||
containsIgnoreCase(c.User, m.searchQuery) ||
|
||||
containsIgnoreCase(c.Proto, m.searchQuery) ||
|
||||
containsIgnoreCase(c.State, m.searchQuery)
|
||||
containsIgnoreCase(c.State, m.searchQuery) ||
|
||||
containsIgnoreCase(lportStr, m.searchQuery) ||
|
||||
containsIgnoreCase(rportStr, m.searchQuery) ||
|
||||
containsIgnoreCase(pidStr, m.searchQuery)
|
||||
}
|
||||
|
||||
func (m model) isWatched(pid int) bool {
|
||||
@@ -340,3 +361,62 @@ func (m model) saveState() {
|
||||
state.SaveAsync(m.currentState())
|
||||
}
|
||||
}
|
||||
|
||||
// exportConnections writes visible connections to a file in csv or tsv format
|
||||
func (m model) exportConnections() error {
|
||||
visible := m.visibleConnections()
|
||||
|
||||
if len(visible) == 0 {
|
||||
return fmt.Errorf("no connections to export")
|
||||
}
|
||||
|
||||
file, err := os.Create(m.exportFilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// determine delimiter from format selection or filename
|
||||
delimiter := ","
|
||||
if m.exportFormat == "tsv" || strings.HasSuffix(strings.ToLower(m.exportFilename), ".tsv") {
|
||||
delimiter = "\t"
|
||||
}
|
||||
|
||||
header := []string{"PID", "PROCESS", "USER", "PROTO", "STATE", "LADDR", "LPORT", "RADDR", "RPORT"}
|
||||
_, err = file.WriteString(strings.Join(header, delimiter) + "\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range visible {
|
||||
// escape fields that might contain delimiter
|
||||
process := escapeField(c.Process, delimiter)
|
||||
user := escapeField(c.User, delimiter)
|
||||
|
||||
row := []string{
|
||||
strconv.Itoa(c.PID),
|
||||
process,
|
||||
user,
|
||||
c.Proto,
|
||||
c.State,
|
||||
c.Laddr,
|
||||
strconv.Itoa(c.Lport),
|
||||
c.Raddr,
|
||||
strconv.Itoa(c.Rport),
|
||||
}
|
||||
_, err = file.WriteString(strings.Join(row, delimiter) + "\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// escapeField quotes a field if it contains the delimiter or quotes
|
||||
func escapeField(s, delimiter string) string {
|
||||
if strings.Contains(s, delimiter) || strings.Contains(s, "\"") || strings.Contains(s, "\n") {
|
||||
return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/karol-broda/snitch/internal/collector"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/exp/teatest"
|
||||
"github.com/karol-broda/snitch/internal/collector"
|
||||
)
|
||||
|
||||
func TestTUI_InitialState(t *testing.T) {
|
||||
@@ -430,3 +433,346 @@ func TestTUI_FormatRemoteHelper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_MatchesSearchPort(t *testing.T) {
|
||||
m := New(Options{Theme: "dark"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
searchQuery string
|
||||
conn collector.Connection
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "matches local port",
|
||||
searchQuery: "3000",
|
||||
conn: collector.Connection{Lport: 3000},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "matches remote port",
|
||||
searchQuery: "443",
|
||||
conn: collector.Connection{Rport: 443},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "matches pid",
|
||||
searchQuery: "1234",
|
||||
conn: collector.Connection{PID: 1234},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "partial port match",
|
||||
searchQuery: "80",
|
||||
conn: collector.Connection{Lport: 8080},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
searchQuery: "9999",
|
||||
conn: collector.Connection{Lport: 80, Rport: 443, PID: 1234},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m.searchQuery = tc.searchQuery
|
||||
result := m.matchesSearch(tc.conn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("matchesSearch() = %v, want %v", result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_SortCycleIncludesRemote(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
// start at default (Lport)
|
||||
if m.sortField != collector.SortByLport {
|
||||
t.Fatalf("expected initial sort field to be lport, got %v", m.sortField)
|
||||
}
|
||||
|
||||
// cycle through all fields and verify raddr and rport are included
|
||||
foundRaddr := false
|
||||
foundRport := false
|
||||
seenFields := make(map[collector.SortField]bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
m.cycleSort()
|
||||
seenFields[m.sortField] = true
|
||||
|
||||
if m.sortField == collector.SortByRaddr {
|
||||
foundRaddr = true
|
||||
}
|
||||
if m.sortField == collector.SortByRport {
|
||||
foundRport = true
|
||||
}
|
||||
|
||||
if foundRaddr && foundRport {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundRaddr {
|
||||
t.Error("expected sort cycle to include SortByRaddr")
|
||||
}
|
||||
if !foundRport {
|
||||
t.Error("expected sort cycle to include SortByRport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportModal(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
|
||||
// initially export modal should not be shown
|
||||
if m.showExportModal {
|
||||
t.Fatal("expected showExportModal to be false initially")
|
||||
}
|
||||
|
||||
// press 'x' to open export modal
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
||||
m = newModel.(model)
|
||||
|
||||
if !m.showExportModal {
|
||||
t.Error("expected showExportModal to be true after pressing 'x'")
|
||||
}
|
||||
|
||||
// type filename
|
||||
for _, c := range "test.csv" {
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}})
|
||||
m = newModel.(model)
|
||||
}
|
||||
|
||||
if m.exportFilename != "test.csv" {
|
||||
t.Errorf("expected exportFilename to be 'test.csv', got '%s'", m.exportFilename)
|
||||
}
|
||||
|
||||
// escape should close modal
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.showExportModal {
|
||||
t.Error("expected showExportModal to be false after escape")
|
||||
}
|
||||
if m.exportFilename != "" {
|
||||
t.Error("expected exportFilename to be cleared after escape")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportModalDefaultFilename(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
|
||||
// add test data
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1234, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
||||
}
|
||||
|
||||
// open export modal
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
||||
m = newModel.(model)
|
||||
|
||||
// render export modal should show default filename hint
|
||||
view := m.View()
|
||||
if view == "" {
|
||||
t.Error("expected non-empty view with export modal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportModalBackspace(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
|
||||
// open export modal
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
||||
m = newModel.(model)
|
||||
|
||||
// type filename
|
||||
for _, c := range "test.csv" {
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}})
|
||||
m = newModel.(model)
|
||||
}
|
||||
|
||||
// backspace should remove last character
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.exportFilename != "test.cs" {
|
||||
t.Errorf("expected 'test.cs' after backspace, got '%s'", m.exportFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportConnectionsCSV(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1234, Process: "nginx", User: "www-data", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80, Raddr: "*", Rport: 0},
|
||||
{PID: 5678, Process: "node", User: "node", Proto: "tcp", State: "ESTABLISHED", Laddr: "192.168.1.1", Lport: 3000, Raddr: "10.0.0.1", Rport: 443},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
csvPath := filepath.Join(tmpDir, "test_export.csv")
|
||||
m.exportFilename = csvPath
|
||||
|
||||
err := m.exportConnections()
|
||||
if err != nil {
|
||||
t.Fatalf("exportConnections() failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(csvPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read exported file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Errorf("expected 3 lines (header + 2 data), got %d", len(lines))
|
||||
}
|
||||
|
||||
if !strings.Contains(lines[0], "PID") || !strings.Contains(lines[0], "PROCESS") {
|
||||
t.Error("header line should contain PID and PROCESS")
|
||||
}
|
||||
|
||||
if !strings.Contains(lines[1], "nginx") || !strings.Contains(lines[1], "1234") {
|
||||
t.Error("first data line should contain nginx and 1234")
|
||||
}
|
||||
|
||||
if !strings.Contains(lines[2], "node") || !strings.Contains(lines[2], "5678") {
|
||||
t.Error("second data line should contain node and 5678")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportConnectionsTSV(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1234, Process: "nginx", User: "www-data", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80, Raddr: "*", Rport: 0},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tsvPath := filepath.Join(tmpDir, "test_export.tsv")
|
||||
m.exportFilename = tsvPath
|
||||
|
||||
err := m.exportConnections()
|
||||
if err != nil {
|
||||
t.Fatalf("exportConnections() failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(tsvPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read exported file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
|
||||
// TSV should use tabs
|
||||
if !strings.Contains(lines[0], "\t") {
|
||||
t.Error("TSV file should use tabs as delimiters")
|
||||
}
|
||||
|
||||
// CSV delimiter should not be present between fields
|
||||
fields := strings.Split(lines[1], "\t")
|
||||
if len(fields) < 9 {
|
||||
t.Errorf("expected at least 9 tab-separated fields, got %d", len(fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportWithFilters(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.showTCP = true
|
||||
m.showUDP = false
|
||||
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1, Process: "tcp_proc", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
||||
{PID: 2, Process: "udp_proc", Proto: "udp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 53},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
csvPath := filepath.Join(tmpDir, "filtered_export.csv")
|
||||
m.exportFilename = csvPath
|
||||
|
||||
err := m.exportConnections()
|
||||
if err != nil {
|
||||
t.Fatalf("exportConnections() failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(csvPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read exported file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
|
||||
// should only have header + 1 TCP connection (UDP filtered out)
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("expected 2 lines (header + 1 TCP), got %d", len(lines))
|
||||
}
|
||||
|
||||
if strings.Contains(string(content), "udp_proc") {
|
||||
t.Error("UDP connection should not be exported when UDP filter is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportFormatToggle(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
|
||||
// open export modal
|
||||
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
||||
m = newModel.(model)
|
||||
|
||||
// default format should be csv
|
||||
if m.exportFormat != "csv" {
|
||||
t.Errorf("expected default format 'csv', got '%s'", m.exportFormat)
|
||||
}
|
||||
|
||||
// tab should toggle to tsv
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.exportFormat != "tsv" {
|
||||
t.Errorf("expected format 'tsv' after tab, got '%s'", m.exportFormat)
|
||||
}
|
||||
|
||||
// tab again should toggle back to csv
|
||||
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = newModel.(model)
|
||||
|
||||
if m.exportFormat != "csv" {
|
||||
t.Errorf("expected format 'csv' after second tab, got '%s'", m.exportFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUI_ExportModalRenderWithStats(t *testing.T) {
|
||||
m := New(Options{Theme: "dark", Interval: time.Hour})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
|
||||
m.connections = []collector.Connection{
|
||||
{PID: 1, Process: "nginx", Proto: "tcp", State: "LISTEN", Laddr: "0.0.0.0", Lport: 80},
|
||||
{PID: 2, Process: "postgres", Proto: "tcp", State: "LISTEN", Laddr: "127.0.0.1", Lport: 5432},
|
||||
{PID: 3, Process: "node", Proto: "tcp", State: "ESTABLISHED", Laddr: "192.168.1.1", Lport: 3000},
|
||||
}
|
||||
|
||||
m.showExportModal = true
|
||||
m.exportFormat = "csv"
|
||||
|
||||
view := m.View()
|
||||
|
||||
// modal should contain summary info
|
||||
if !strings.Contains(view, "3") {
|
||||
t.Error("modal should show connection count")
|
||||
}
|
||||
|
||||
// modal should show format options
|
||||
if !strings.Contains(view, "CSV") || !strings.Contains(view, "TSV") {
|
||||
t.Error("modal should show format options")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
BoxCross = string('\u253C') // light vertical and horizontal
|
||||
|
||||
// misc
|
||||
SymbolDash = string('\u2013') // en dash
|
||||
SymbolDash = string('\u2013') // en dash
|
||||
SymbolExport = string('\u21E5') // rightwards arrow to bar
|
||||
SymbolPrompt = string('\u276F') // heavy right-pointing angle quotation mark ornament
|
||||
)
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ func (m model) renderStatusLine() string {
|
||||
return " " + m.theme.Styles.Warning.Render(m.statusMessage)
|
||||
}
|
||||
|
||||
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state n/N dns w watch K kill s sort / search ? help q quit")
|
||||
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state n/N dns w watch K kill s sort / search x export ? help q quit")
|
||||
|
||||
// show watched count if any
|
||||
if m.watchedCount() > 0 {
|
||||
@@ -271,6 +271,7 @@ func (m model) renderHelp() string {
|
||||
other
|
||||
─────
|
||||
/ search
|
||||
x export to csv/tsv (enter filename)
|
||||
r refresh now
|
||||
q quit
|
||||
|
||||
@@ -301,6 +302,8 @@ func (m model) renderDetail() string {
|
||||
value string
|
||||
}{
|
||||
{"process", c.Process},
|
||||
{"cmdline", c.Cmdline},
|
||||
{"cwd", c.Cwd},
|
||||
{"pid", fmt.Sprintf("%d", c.PID)},
|
||||
{"user", c.User},
|
||||
{"protocol", c.Proto},
|
||||
@@ -368,6 +371,119 @@ func (m model) renderKillModal() string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m model) renderExportModal() string {
|
||||
visible := m.visibleConnections()
|
||||
|
||||
// count protocols and states for preview
|
||||
tcpCount, udpCount := 0, 0
|
||||
listenCount, estabCount, otherCount := 0, 0, 0
|
||||
for _, c := range visible {
|
||||
if c.Proto == "tcp" || c.Proto == "tcp6" {
|
||||
tcpCount++
|
||||
} else {
|
||||
udpCount++
|
||||
}
|
||||
switch c.State {
|
||||
case "LISTEN":
|
||||
listenCount++
|
||||
case "ESTABLISHED":
|
||||
estabCount++
|
||||
default:
|
||||
otherCount++
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// header
|
||||
lines = append(lines, "")
|
||||
headerText := " " + SymbolExport + " EXPORT CONNECTIONS "
|
||||
lines = append(lines, m.theme.Styles.Header.Render(headerText))
|
||||
lines = append(lines, m.theme.Styles.Border.Render(" "+strings.Repeat(BoxHorizontal, 36)))
|
||||
lines = append(lines, "")
|
||||
|
||||
// stats preview section
|
||||
lines = append(lines, m.theme.Styles.Normal.Render(" "+SymbolBullet+" summary"))
|
||||
lines = append(lines, fmt.Sprintf(" total: %s",
|
||||
m.theme.Styles.Success.Render(fmt.Sprintf("%d connections", len(visible)))))
|
||||
|
||||
protoSummary := fmt.Sprintf(" proto: %s tcp %s udp",
|
||||
m.theme.Styles.GetProtoStyle("tcp").Render(fmt.Sprintf("%d", tcpCount)),
|
||||
m.theme.Styles.GetProtoStyle("udp").Render(fmt.Sprintf("%d", udpCount)))
|
||||
lines = append(lines, protoSummary)
|
||||
|
||||
stateSummary := fmt.Sprintf(" state: %s listen %s estab %s other",
|
||||
m.theme.Styles.GetStateStyle("LISTEN").Render(fmt.Sprintf("%d", listenCount)),
|
||||
m.theme.Styles.GetStateStyle("ESTABLISHED").Render(fmt.Sprintf("%d", estabCount)),
|
||||
m.theme.Styles.Normal.Render(fmt.Sprintf("%d", otherCount)))
|
||||
lines = append(lines, stateSummary)
|
||||
lines = append(lines, "")
|
||||
|
||||
// format selection
|
||||
lines = append(lines, m.theme.Styles.Normal.Render(" "+SymbolBullet+" format"))
|
||||
|
||||
csvStyle := m.theme.Styles.Normal
|
||||
tsvStyle := m.theme.Styles.Normal
|
||||
csvIndicator := " "
|
||||
tsvIndicator := " "
|
||||
|
||||
if m.exportFormat == "tsv" {
|
||||
tsvStyle = m.theme.Styles.Success
|
||||
tsvIndicator = m.theme.Styles.Success.Render(SymbolSelected + " ")
|
||||
} else {
|
||||
csvStyle = m.theme.Styles.Success
|
||||
csvIndicator = m.theme.Styles.Success.Render(SymbolSelected + " ")
|
||||
}
|
||||
|
||||
formatLine := fmt.Sprintf(" %s%s %s%s",
|
||||
csvIndicator, csvStyle.Render("CSV (comma)"),
|
||||
tsvIndicator, tsvStyle.Render("TSV (tab)"))
|
||||
lines = append(lines, formatLine)
|
||||
lines = append(lines, m.theme.Styles.Border.Render(" "+strings.Repeat(BoxHorizontal, 8)+" press "+m.theme.Styles.Warning.Render("tab")+" to toggle"))
|
||||
lines = append(lines, "")
|
||||
|
||||
// filename input
|
||||
lines = append(lines, m.theme.Styles.Normal.Render(" "+SymbolBullet+" filename"))
|
||||
|
||||
ext := ".csv"
|
||||
if m.exportFormat == "tsv" {
|
||||
ext = ".tsv"
|
||||
}
|
||||
|
||||
filenameDisplay := m.exportFilename
|
||||
if filenameDisplay == "" {
|
||||
filenameDisplay = "connections"
|
||||
}
|
||||
|
||||
inputBox := fmt.Sprintf(" %s %s%s",
|
||||
m.theme.Styles.Success.Render(SymbolPrompt),
|
||||
m.theme.Styles.Warning.Render(filenameDisplay),
|
||||
m.theme.Styles.Success.Render(ext+"▌"))
|
||||
lines = append(lines, inputBox)
|
||||
lines = append(lines, "")
|
||||
|
||||
// error display
|
||||
if m.exportError != "" {
|
||||
lines = append(lines, m.theme.Styles.Error.Render(fmt.Sprintf(" %s %s", SymbolWarning, m.exportError)))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// preview of fields
|
||||
lines = append(lines, m.theme.Styles.Border.Render(" "+strings.Repeat(BoxHorizontal, 36)))
|
||||
fieldsPreview := " fields: PID, PROCESS, USER, PROTO, STATE, LADDR, LPORT, RADDR, RPORT"
|
||||
lines = append(lines, m.theme.Styles.Normal.Render(truncate(fieldsPreview, 40)))
|
||||
lines = append(lines, "")
|
||||
|
||||
// action buttons
|
||||
lines = append(lines, fmt.Sprintf(" %s export %s toggle format %s cancel",
|
||||
m.theme.Styles.Success.Render("[enter]"),
|
||||
m.theme.Styles.Warning.Render("[tab]"),
|
||||
m.theme.Styles.Error.Render("[esc]")))
|
||||
lines = append(lines, "")
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m model) overlayModal(background, modal string) string {
|
||||
bgLines := strings.Split(background, "\n")
|
||||
modalLines := strings.Split(modal, "\n")
|
||||
|
||||
121
nix/containers.nix
Normal file
121
nix/containers.nix
Normal file
@@ -0,0 +1,121 @@
|
||||
# oci container definitions for snitch
|
||||
# builds containers based on different base images: alpine, debian trixie, ubuntu
|
||||
#
|
||||
# base images are pinned by imageDigest (immutable content hash), not by tag.
|
||||
# even if the upstream tag gets a new image, builds remain reproducible.
|
||||
#
|
||||
# to update base image hashes, run:
|
||||
# nix-prefetch-docker --image-name alpine --image-tag 3.21
|
||||
# nix-prefetch-docker --image-name debian --image-tag trixie-slim
|
||||
# nix-prefetch-docker --image-name ubuntu --image-tag 24.04
|
||||
#
|
||||
# this outputs both imageDigest and sha256 values needed below
|
||||
{ pkgs, snitch }:
|
||||
let
|
||||
commonConfig = {
|
||||
name = "snitch";
|
||||
tag = snitch.version;
|
||||
config = {
|
||||
Entrypoint = [ "${snitch}/bin/snitch" ];
|
||||
Env = [ "PATH=/bin" ];
|
||||
Labels = {
|
||||
"org.opencontainers.image.title" = "snitch";
|
||||
"org.opencontainers.image.description" = "a friendlier ss/netstat for humans";
|
||||
"org.opencontainers.image.source" = "https://github.com/karol-broda/snitch";
|
||||
"org.opencontainers.image.licenses" = "MIT";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# alpine-based container
|
||||
alpine = pkgs.dockerTools.pullImage {
|
||||
imageName = "alpine";
|
||||
imageDigest = "sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c";
|
||||
sha256 = "sha256-WNbRh44zld3lZtKARhdeWFte9JKgD2bgCuKzETWgGr8=";
|
||||
finalImageName = "alpine";
|
||||
finalImageTag = "3.21";
|
||||
};
|
||||
|
||||
# debian trixie (testing) based container
|
||||
debianTrixie = pkgs.dockerTools.pullImage {
|
||||
imageName = "debian";
|
||||
imageDigest = "sha256:e711a7b30ec1261130d0a121050b4ed81d7fb28aeabcf4ea0c7876d4e9f5aca2";
|
||||
sha256 = "sha256-W/9A7aaPXFCmmg+NTSrFYL+QylsAgfnvkLldyI18tqU=";
|
||||
finalImageName = "debian";
|
||||
finalImageTag = "trixie-slim";
|
||||
};
|
||||
|
||||
# ubuntu based container
|
||||
ubuntu = pkgs.dockerTools.pullImage {
|
||||
imageName = "ubuntu";
|
||||
imageDigest = "sha256:c35e29c9450151419d9448b0fd75374fec4fff364a27f176fb458d472dfc9e54";
|
||||
sha256 = "sha256-0j8xM+mECrBBHv7ZqofiRaeSoOXFBtLYjgnKivQztS0=";
|
||||
finalImageName = "ubuntu";
|
||||
finalImageTag = "24.04";
|
||||
};
|
||||
|
||||
# scratch container (minimal, just the snitch binary)
|
||||
scratch = pkgs.dockerTools.buildImage {
|
||||
name = "snitch";
|
||||
tag = "${snitch.version}-scratch";
|
||||
copyToRoot = pkgs.buildEnv {
|
||||
name = "snitch-root";
|
||||
paths = [ snitch ];
|
||||
pathsToLink = [ "/bin" ];
|
||||
};
|
||||
config = commonConfig.config;
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
snitch-alpine = pkgs.dockerTools.buildImage {
|
||||
name = "snitch";
|
||||
tag = "${snitch.version}-alpine";
|
||||
fromImage = alpine;
|
||||
copyToRoot = pkgs.buildEnv {
|
||||
name = "snitch-root";
|
||||
paths = [ snitch ];
|
||||
pathsToLink = [ "/bin" ];
|
||||
};
|
||||
config = commonConfig.config;
|
||||
};
|
||||
|
||||
snitch-debian = pkgs.dockerTools.buildImage {
|
||||
name = "snitch";
|
||||
tag = "${snitch.version}-debian";
|
||||
fromImage = debianTrixie;
|
||||
copyToRoot = pkgs.buildEnv {
|
||||
name = "snitch-root";
|
||||
paths = [ snitch ];
|
||||
pathsToLink = [ "/bin" ];
|
||||
};
|
||||
config = commonConfig.config;
|
||||
};
|
||||
|
||||
snitch-ubuntu = pkgs.dockerTools.buildImage {
|
||||
name = "snitch";
|
||||
tag = "${snitch.version}-ubuntu";
|
||||
fromImage = ubuntu;
|
||||
copyToRoot = pkgs.buildEnv {
|
||||
name = "snitch-root";
|
||||
paths = [ snitch ];
|
||||
pathsToLink = [ "/bin" ];
|
||||
};
|
||||
config = commonConfig.config;
|
||||
};
|
||||
|
||||
snitch-scratch = scratch;
|
||||
|
||||
oci-default = pkgs.dockerTools.buildImage {
|
||||
name = "snitch";
|
||||
tag = snitch.version;
|
||||
fromImage = alpine;
|
||||
copyToRoot = pkgs.buildEnv {
|
||||
name = "snitch-root";
|
||||
paths = [ snitch ];
|
||||
pathsToLink = [ "/bin" ];
|
||||
};
|
||||
config = commonConfig.config;
|
||||
};
|
||||
}
|
||||
|
||||
177
nix/hm-module.nix
Normal file
177
nix/hm-module.nix
Normal file
@@ -0,0 +1,177 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.programs.snitch;
|
||||
|
||||
themes = [
|
||||
"ansi"
|
||||
"catppuccin-mocha"
|
||||
"catppuccin-macchiato"
|
||||
"catppuccin-frappe"
|
||||
"catppuccin-latte"
|
||||
"gruvbox-dark"
|
||||
"gruvbox-light"
|
||||
"dracula"
|
||||
"nord"
|
||||
"tokyo-night"
|
||||
"tokyo-night-storm"
|
||||
"tokyo-night-light"
|
||||
"solarized-dark"
|
||||
"solarized-light"
|
||||
"one-dark"
|
||||
"mono"
|
||||
"auto"
|
||||
];
|
||||
|
||||
defaultFields = [
|
||||
"pid"
|
||||
"process"
|
||||
"user"
|
||||
"proto"
|
||||
"state"
|
||||
"laddr"
|
||||
"lport"
|
||||
"raddr"
|
||||
"rport"
|
||||
];
|
||||
|
||||
tomlFormat = pkgs.formats.toml { };
|
||||
|
||||
settingsType = lib.types.submodule {
|
||||
freeformType = tomlFormat.type;
|
||||
|
||||
options = {
|
||||
defaults = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
freeformType = tomlFormat.type;
|
||||
|
||||
options = {
|
||||
interval = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "1s";
|
||||
example = "2s";
|
||||
description = "Default refresh interval for watch/stats/trace commands.";
|
||||
};
|
||||
|
||||
numeric = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Disable name/service resolution by default.";
|
||||
};
|
||||
|
||||
fields = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = defaultFields;
|
||||
example = [ "pid" "process" "proto" "state" "laddr" "lport" ];
|
||||
description = "Default fields to display.";
|
||||
};
|
||||
|
||||
theme = lib.mkOption {
|
||||
type = lib.types.enum themes;
|
||||
default = "ansi";
|
||||
description = ''
|
||||
Color theme for the TUI. "ansi" inherits terminal colors.
|
||||
'';
|
||||
};
|
||||
|
||||
units = lib.mkOption {
|
||||
type = lib.types.enum [ "auto" "si" "iec" ];
|
||||
default = "auto";
|
||||
description = "Default units for byte display.";
|
||||
};
|
||||
|
||||
color = lib.mkOption {
|
||||
type = lib.types.enum [ "auto" "always" "never" ];
|
||||
default = "auto";
|
||||
description = "Default color mode.";
|
||||
};
|
||||
|
||||
resolve = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable name resolution by default.";
|
||||
};
|
||||
|
||||
dns_cache = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable DNS caching.";
|
||||
};
|
||||
|
||||
ipv4 = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Filter to IPv4 only by default.";
|
||||
};
|
||||
|
||||
ipv6 = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Filter to IPv6 only by default.";
|
||||
};
|
||||
|
||||
no_headers = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Omit headers in output by default.";
|
||||
};
|
||||
|
||||
output_format = lib.mkOption {
|
||||
type = lib.types.enum [ "table" "json" "csv" ];
|
||||
default = "table";
|
||||
description = "Default output format.";
|
||||
};
|
||||
|
||||
sort_by = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "pid";
|
||||
description = "Default sort field.";
|
||||
};
|
||||
};
|
||||
};
|
||||
default = { };
|
||||
description = "Default settings for snitch commands.";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options.programs.snitch = {
|
||||
enable = lib.mkEnableOption "snitch, a friendlier ss/netstat for humans";
|
||||
|
||||
package = lib.mkPackageOption pkgs "snitch" { };
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = settingsType;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
defaults = {
|
||||
theme = "catppuccin-mocha";
|
||||
interval = "2s";
|
||||
resolve = true;
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Configuration written to {file}`$XDG_CONFIG_HOME/snitch/snitch.toml`.
|
||||
|
||||
See <https://github.com/karol-broda/snitch> for available options.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = [ cfg.package ];
|
||||
|
||||
xdg.configFile."snitch/snitch.toml" = lib.mkIf (cfg.settings != { }) {
|
||||
source = tomlFormat.generate "snitch.toml" cfg.settings;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
429
nix/tests/hm-module-test.nix
Normal file
429
nix/tests/hm-module-test.nix
Normal file
@@ -0,0 +1,429 @@
|
||||
# home manager module tests
|
||||
#
|
||||
# run with: nix build .#checks.x86_64-linux.hm-module
|
||||
#
|
||||
# tests cover:
|
||||
# - module evaluation with various configurations
|
||||
# - type validation for all options
|
||||
# - generated TOML content verification
|
||||
# - edge cases (disabled, empty settings, full settings)
|
||||
{ pkgs, lib, hmModule }:
|
||||
|
||||
let
|
||||
# minimal home-manager stub for standalone module testing
|
||||
hmLib = {
|
||||
hm.types.dagOf = lib.types.attrsOf;
|
||||
dag.entryAnywhere = x: x;
|
||||
};
|
||||
|
||||
# evaluate the hm module with a given config
|
||||
evalModule = testConfig:
|
||||
lib.evalModules {
|
||||
modules = [
|
||||
hmModule
|
||||
# stub home-manager's expected structure
|
||||
{
|
||||
options = {
|
||||
home.packages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
default = [ ];
|
||||
};
|
||||
xdg.configFile = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
source = lib.mkOption { type = lib.types.path; };
|
||||
text = lib.mkOption { type = lib.types.str; default = ""; };
|
||||
};
|
||||
});
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
}
|
||||
testConfig
|
||||
];
|
||||
specialArgs = { inherit pkgs lib; };
|
||||
};
|
||||
|
||||
# read generated TOML file content
|
||||
readGeneratedToml = evalResult:
|
||||
let
|
||||
configFile = evalResult.config.xdg.configFile."snitch/snitch.toml" or null;
|
||||
in
|
||||
if configFile != null && configFile ? source
|
||||
then builtins.readFile configFile.source
|
||||
else null;
|
||||
|
||||
# test cases
|
||||
tests = {
|
||||
# test 1: module evaluates when disabled
|
||||
moduleDisabled = {
|
||||
name = "module-disabled";
|
||||
config = {
|
||||
programs.snitch.enable = false;
|
||||
};
|
||||
assertions = evalResult: [
|
||||
{
|
||||
assertion = evalResult.config.home.packages == [ ];
|
||||
message = "packages should be empty when disabled";
|
||||
}
|
||||
{
|
||||
assertion = !(evalResult.config.xdg.configFile ? "snitch/snitch.toml");
|
||||
message = "config file should not exist when disabled";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 2: module evaluates with enable only (defaults)
|
||||
moduleEnabledDefaults = {
|
||||
name = "module-enabled-defaults";
|
||||
config = {
|
||||
programs.snitch.enable = true;
|
||||
};
|
||||
assertions = evalResult: [
|
||||
{
|
||||
assertion = builtins.length evalResult.config.home.packages == 1;
|
||||
message = "package should be installed when enabled";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 3: all theme values are valid
|
||||
themeValidation = {
|
||||
name = "theme-validation";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.theme = "catppuccin-mocha";
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = toml != null;
|
||||
message = "TOML config should be generated";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "catppuccin-mocha" toml;
|
||||
message = "theme should be set in TOML";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 4: full configuration with all options
|
||||
fullConfiguration = {
|
||||
name = "full-configuration";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults = {
|
||||
interval = "2s";
|
||||
numeric = true;
|
||||
fields = [ "pid" "process" "proto" ];
|
||||
theme = "nord";
|
||||
units = "si";
|
||||
color = "always";
|
||||
resolve = false;
|
||||
dns_cache = false;
|
||||
ipv4 = true;
|
||||
ipv6 = false;
|
||||
no_headers = true;
|
||||
output_format = "json";
|
||||
sort_by = "pid";
|
||||
};
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = toml != null;
|
||||
message = "TOML config should be generated";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "interval = \"2s\"" toml;
|
||||
message = "interval should be 2s";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "numeric = true" toml;
|
||||
message = "numeric should be true";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "theme = \"nord\"" toml;
|
||||
message = "theme should be nord";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "units = \"si\"" toml;
|
||||
message = "units should be si";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "color = \"always\"" toml;
|
||||
message = "color should be always";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "resolve = false" toml;
|
||||
message = "resolve should be false";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "output_format = \"json\"" toml;
|
||||
message = "output_format should be json";
|
||||
}
|
||||
{
|
||||
assertion = lib.hasInfix "sort_by = \"pid\"" toml;
|
||||
message = "sort_by should be pid";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 5: output format enum validation
|
||||
outputFormatCsv = {
|
||||
name = "output-format-csv";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.output_format = "csv";
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.hasInfix "output_format = \"csv\"" toml;
|
||||
message = "output_format should accept csv";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 6: units enum validation
|
||||
unitsIec = {
|
||||
name = "units-iec";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.units = "iec";
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.hasInfix "units = \"iec\"" toml;
|
||||
message = "units should accept iec";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 7: color never value
|
||||
colorNever = {
|
||||
name = "color-never";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.color = "never";
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.hasInfix "color = \"never\"" toml;
|
||||
message = "color should accept never";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 8: freeform type allows custom keys
|
||||
freeformCustomKeys = {
|
||||
name = "freeform-custom-keys";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings = {
|
||||
defaults.theme = "dracula";
|
||||
custom_section = {
|
||||
custom_key = "custom_value";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.hasInfix "custom_key" toml;
|
||||
message = "freeform type should allow custom keys";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 9: all themes evaluate correctly
|
||||
allThemes =
|
||||
let
|
||||
themes = [
|
||||
"ansi"
|
||||
"catppuccin-mocha"
|
||||
"catppuccin-macchiato"
|
||||
"catppuccin-frappe"
|
||||
"catppuccin-latte"
|
||||
"gruvbox-dark"
|
||||
"gruvbox-light"
|
||||
"dracula"
|
||||
"nord"
|
||||
"tokyo-night"
|
||||
"tokyo-night-storm"
|
||||
"tokyo-night-light"
|
||||
"solarized-dark"
|
||||
"solarized-light"
|
||||
"one-dark"
|
||||
"mono"
|
||||
"auto"
|
||||
];
|
||||
in
|
||||
{
|
||||
name = "all-themes";
|
||||
# use the last theme as the test config
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.theme = "auto";
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
# verify all themes can be set by evaluating them
|
||||
themeResults = map
|
||||
(theme:
|
||||
let
|
||||
result = evalModule {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.theme = theme;
|
||||
};
|
||||
};
|
||||
toml = readGeneratedToml result;
|
||||
in
|
||||
{
|
||||
inherit theme;
|
||||
success = toml != null && lib.hasInfix theme toml;
|
||||
}
|
||||
)
|
||||
themes;
|
||||
allSucceeded = lib.all (r: r.success) themeResults;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = allSucceeded;
|
||||
message = "all themes should evaluate correctly: ${
|
||||
lib.concatMapStringsSep ", "
|
||||
(r: "${r.theme}=${if r.success then "ok" else "fail"}")
|
||||
themeResults
|
||||
}";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# test 10: fields list serialization
|
||||
fieldsListSerialization = {
|
||||
name = "fields-list-serialization";
|
||||
config = {
|
||||
programs.snitch = {
|
||||
enable = true;
|
||||
settings.defaults.fields = [ "pid" "process" "proto" "state" ];
|
||||
};
|
||||
};
|
||||
assertions = evalResult:
|
||||
let
|
||||
toml = readGeneratedToml evalResult;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.hasInfix "pid" toml && lib.hasInfix "process" toml;
|
||||
message = "fields list should be serialized correctly";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# run all tests and collect results
|
||||
runTests =
|
||||
let
|
||||
testResults = lib.mapAttrsToList
|
||||
(name: test:
|
||||
let
|
||||
evalResult = evalModule test.config;
|
||||
assertions = test.assertions evalResult;
|
||||
failures = lib.filter (a: !a.assertion) assertions;
|
||||
in
|
||||
{
|
||||
inherit name;
|
||||
testName = test.name;
|
||||
passed = failures == [ ];
|
||||
failures = map (f: f.message) failures;
|
||||
}
|
||||
)
|
||||
tests;
|
||||
|
||||
allPassed = lib.all (r: r.passed) testResults;
|
||||
failedTests = lib.filter (r: !r.passed) testResults;
|
||||
|
||||
summary = ''
|
||||
========================================
|
||||
home manager module test results
|
||||
========================================
|
||||
total tests: ${toString (builtins.length testResults)}
|
||||
passed: ${toString (builtins.length (lib.filter (r: r.passed) testResults))}
|
||||
failed: ${toString (builtins.length failedTests)}
|
||||
========================================
|
||||
${lib.concatMapStringsSep "\n" (r:
|
||||
if r.passed
|
||||
then "[yes] ${r.testName}"
|
||||
else "[no] ${r.testName}\n ${lib.concatStringsSep "\n " r.failures}"
|
||||
) testResults}
|
||||
========================================
|
||||
'';
|
||||
in
|
||||
{
|
||||
inherit testResults allPassed failedTests summary;
|
||||
};
|
||||
|
||||
results = runTests;
|
||||
|
||||
in
|
||||
pkgs.runCommand "hm-module-test"
|
||||
{
|
||||
passthru = {
|
||||
inherit results;
|
||||
# expose for debugging
|
||||
inherit evalModule tests;
|
||||
};
|
||||
}
|
||||
(
|
||||
if results.allPassed
|
||||
then ''
|
||||
echo "${results.summary}"
|
||||
echo "all tests passed"
|
||||
touch $out
|
||||
''
|
||||
else ''
|
||||
echo "${results.summary}"
|
||||
echo ""
|
||||
echo "failed tests:"
|
||||
${lib.concatMapStringsSep "\n" (t: ''
|
||||
echo " - ${t.testName}: ${lib.concatStringsSep ", " t.failures}"
|
||||
'') results.failedTests}
|
||||
exit 1
|
||||
''
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user