7 Commits

Author SHA1 Message Date
b10f442635 ci: fix secret name to GITEATOKEN
All checks were successful
Release / build (push) Successful in 39s
2026-02-24 16:57:04 +00:00
928ffa8934 ci: add release workflow for x86_64 builds 2026-02-24 16:56:21 +00:00
Karol Broda
57d592408d chore: add coc, contibution, and security documents for community guidelines (#31) 2026-01-01 20:58:14 +01:00
Karol Broda
df15770a94 feat: add oci container definitions build with nix (#18)
Some checks failed
release / release-linux (push) Failing after 1m23s
release / release-darwin (push) Has been skipped
release / release-containers (false, debian) (push) Failing after 1m45s
release / release-containers (false, ubuntu) (push) Failing after 21s
release / release-containers (true, alpine) (push) Failing after 22s
release / release-containers (false, scratch) (push) Failing after 1m51s
2025-12-29 20:30:02 +01:00
Karol Broda
bdc4de0229 feat: add port search, remote sort, export, and process info (#27) 2025-12-29 19:47:32 +01:00
Karol Broda
7c757f2769 cicd(aur): install README.md to /usr/share/doc/snitch (#19) 2025-12-26 00:33:07 +01:00
Karol Broda
d792e10d3c Feat/home manager (#16) 2025-12-25 19:39:47 +01:00
27 changed files with 2426 additions and 30 deletions

View 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
View 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
View 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

View 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
View 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 -->

View File

@@ -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

View File

@@ -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
View 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
View 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.

View File

@@ -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
View 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

View File

@@ -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)")

View File

@@ -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;
}
);
};
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}
})
}

View File

@@ -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"`

View File

@@ -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"
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
)

View File

@@ -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
View 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
View 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;
};
};
}

View 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
''
)