mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 11:13:33 +01:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d50c6a6eb | ||
|
|
6f919fd675 | ||
|
|
fd1465ee38 | ||
|
|
00ec2c130d | ||
|
|
8eafa5adfe | ||
|
|
b604027205 | ||
|
|
2a19755804 | ||
|
|
cc85a4bdb1 | ||
|
|
7908a678df | ||
|
|
7c0eacb53d | ||
|
|
4bf804ac60 | ||
|
|
33c5918087 | ||
|
|
d34177729d | ||
|
|
7fa50328d7 | ||
|
|
1790aec85d | ||
|
|
6bf51e758f | ||
|
|
242da8c89a | ||
|
|
2294f40ee0 | ||
|
|
fe25019b14 | ||
|
|
bfb071c0b2 | ||
|
|
95a4e31b6c | ||
|
|
0d9c92c8c0 | ||
|
|
16c50bb659 | ||
|
|
1a85c9e9c8 | ||
|
|
c9ccefa607 | ||
|
|
3a6b6e58f0 | ||
|
|
2edc0ee299 | ||
|
|
bd9fa1ba70 | ||
|
|
bb85e611f4 | ||
|
|
a2f538f114 | ||
|
|
80c91cbdee | ||
|
|
ede2d2dbaa |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Force LF line endings for mock/test data files to ensure consistent
|
||||
# behavior across platforms (Windows git autocrlf converts to CRLF otherwise)
|
||||
mocks/** text eol=lf
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -3,9 +3,5 @@ updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: github.com/alecthomas/chroma
|
||||
versions:
|
||||
- 0.9.1
|
||||
|
||||
58
.github/workflows/build.yml
vendored
58
.github/workflows/build.yml
vendored
@@ -1,46 +1,38 @@
|
||||
---
|
||||
name: Go
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
# TODO: is it possible to DRY out these jobs? Aside from `runs-on`, they are
|
||||
# identical.
|
||||
# See: https://github.com/actions/runner/issues/1182
|
||||
build-linux:
|
||||
runs-on: [ubuntu-latest]
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.19
|
||||
- name: Set up Revive (linter)
|
||||
run: go get -u github.com/boyter/scc github.com/mgechev/revive
|
||||
env:
|
||||
GO111MODULE: "off"
|
||||
- name: Build
|
||||
run: make build
|
||||
- name: Test
|
||||
run: make test
|
||||
go-version: stable
|
||||
- name: Install revive
|
||||
run: go install github.com/mgechev/revive@latest
|
||||
- name: Lint
|
||||
run: revive -exclude vendor/... ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Check formatting
|
||||
run: test -z "$(gofmt -l . | grep -v vendor/)"
|
||||
|
||||
build-osx:
|
||||
runs-on: [macos-latest]
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.19
|
||||
- name: Set up Revive (linter)
|
||||
run: go get -u github.com/boyter/scc github.com/mgechev/revive
|
||||
env:
|
||||
GO111MODULE: "off"
|
||||
go-version: stable
|
||||
- name: Build
|
||||
run: make build
|
||||
run: go build -mod vendor ./cmd/cheat
|
||||
- name: Test
|
||||
run: make test
|
||||
run: go test ./...
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
||||
language: [go]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
dist
|
||||
tags
|
||||
.tmp
|
||||
*.test
|
||||
.claude
|
||||
|
||||
117
CLAUDE.md
Normal file
117
CLAUDE.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Build for your architecture
|
||||
make build
|
||||
|
||||
# Build release binaries for all platforms
|
||||
make build-release
|
||||
|
||||
# Install cheat to your PATH
|
||||
make install
|
||||
```
|
||||
|
||||
### Testing and Quality Checks
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
go test ./...
|
||||
|
||||
# Run a single test
|
||||
go test -run TestFunctionName ./internal/package_name
|
||||
|
||||
# Generate test coverage report
|
||||
make coverage
|
||||
|
||||
# Run linter (revive)
|
||||
make lint
|
||||
|
||||
# Run go vet
|
||||
make vet
|
||||
|
||||
# Format code
|
||||
make fmt
|
||||
|
||||
# Run all checks (vendor, fmt, lint, vet, test)
|
||||
make check
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
```bash
|
||||
# Install development dependencies (revive linter, scc)
|
||||
make setup
|
||||
|
||||
# Update and verify vendored dependencies
|
||||
make vendor-update
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The `cheat` command-line tool is organized into several key packages:
|
||||
|
||||
### Command Layer (`cmd/cheat/`)
|
||||
- `main.go`: Entry point, argument parsing, command routing
|
||||
- `cmd_*.go`: Individual command implementations (view, edit, list, search, etc.)
|
||||
- Commands are selected based on docopt parsed arguments
|
||||
|
||||
### Core Internal Packages
|
||||
|
||||
1. **`internal/config`**: Configuration management
|
||||
- Loads YAML config from platform-specific paths
|
||||
- Manages editor, pager, colorization settings
|
||||
- Validates and expands cheatpath configurations
|
||||
|
||||
2. **`internal/cheatpath`**: Cheatsheet path management
|
||||
- Represents collections of cheatsheets on filesystem
|
||||
- Handles read-only vs writable paths
|
||||
- Supports filtering and validation
|
||||
|
||||
3. **`internal/sheet`**: Individual cheatsheet handling
|
||||
- Parses YAML frontmatter for tags and syntax
|
||||
- Implements syntax highlighting via Chroma
|
||||
- Provides search functionality within sheets
|
||||
|
||||
4. **`internal/sheets`**: Collection operations
|
||||
- Loads sheets from multiple cheatpaths
|
||||
- Consolidates duplicates (local overrides global)
|
||||
- Filters by tags and sorts results
|
||||
|
||||
5. **`internal/display`**: Output formatting
|
||||
- Writes to stdout or pager
|
||||
- Handles text formatting and indentation
|
||||
|
||||
6. **`internal/repo`**: Git repository management
|
||||
- Clones community cheatsheet repositories
|
||||
- Updates existing repositories
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- **Filesystem-based storage**: Cheatsheets are plain text files
|
||||
- **Override mechanism**: Local sheets override community sheets with same name
|
||||
- **Tag system**: Sheets can be categorized with tags in frontmatter
|
||||
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
|
||||
|
||||
### Sheet Format
|
||||
|
||||
Cheatsheets are plain text files optionally prefixed with YAML frontmatter:
|
||||
```
|
||||
---
|
||||
syntax: bash
|
||||
tags: [ networking, ssh ]
|
||||
---
|
||||
# SSH tunneling example
|
||||
ssh -L 8080:localhost:80 user@remote
|
||||
```
|
||||
|
||||
### Working with the Codebase
|
||||
|
||||
- Always check for `.git` directories and skip them during filesystem walks
|
||||
- Use `go-git` for repository operations, not exec'ing git commands
|
||||
- Platform-specific paths are handled in `internal/config/paths.go`
|
||||
- Color output uses ANSI codes via the Chroma library
|
||||
- Test files use the `internal/mock` package for test data
|
||||
@@ -1,48 +1,14 @@
|
||||
CONTRIBUTING
|
||||
Contributing
|
||||
============
|
||||
Do you want to contribute to `cheat`? There are a few ways to help:
|
||||
|
||||
#### Submit a cheatsheet ####
|
||||
Do you have a witty bash one-liner to share? [Open a pull-request][pr] against
|
||||
the [cheatsheets][] repository. (The `cheat` executable source code lives in
|
||||
[cheat/cheat][cheat]. Cheatsheet content lives in
|
||||
[cheat/cheatsheets][cheatsheets].)
|
||||
Thank you for your interest in `cheat`.
|
||||
|
||||
#### Report a bug ####
|
||||
Did you find a bug? Report it in the [issue tracker][issues]. (But before you
|
||||
do, please look through the open issues to make sure that it hasn't already
|
||||
been reported.)
|
||||
Pull requests are no longer being accepted, and have been disabled on this
|
||||
repository. The maintainer is not currently reviewing or merging external code
|
||||
contributions.
|
||||
|
||||
#### Add a feature ####
|
||||
Do you have a feature that you'd like to contribute? Propose it in the [issue
|
||||
tracker][issues] to discuss with the maintainer whether it would be considered
|
||||
for merging.
|
||||
Bug reports are still welcome. If you've found a bug, please open an issue in
|
||||
the [issue tracker][issues]. Before doing so, please search through the
|
||||
existing open issues to make sure it hasn't already been reported.
|
||||
|
||||
`cheat` is mostly mature and feature-complete, but may still have some room for
|
||||
new features. See [HACKING.md][hacking] for a quick-start guide to `cheat`
|
||||
development.
|
||||
|
||||
#### Add documentation ####
|
||||
Did you encounter features, bugs, edge-cases, use-cases, or environment
|
||||
considerations that were undocumented or under-documented? Add them to the
|
||||
[wiki][]. (You may also open a pull-request against the `README`, if
|
||||
appropriate.)
|
||||
|
||||
Do you enjoy technical writing or proofreading? Help keep the documentation
|
||||
error-free and well-organized.
|
||||
|
||||
#### Spread the word ####
|
||||
Are you unable to do the above, but still want to contribute? You can help
|
||||
`cheat` simply by telling others about it. Share it with friends and coworkers
|
||||
that might benefit from using it.
|
||||
|
||||
#### Pull Requests ####
|
||||
Please open all pull-requests against the `develop` branch.
|
||||
|
||||
|
||||
[cheat]: https://github.com/cheat/cheat
|
||||
[cheatsheets]: https://github.com/cheat/cheatsheets
|
||||
[hacking]: HACKING.md
|
||||
[issues]: https://github.com/cheat/cheat/issues
|
||||
[pr]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork
|
||||
[wiki]: https://github.com/cheat/cheat/wiki
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# NB: this image isn't used anywhere in the build pipeline. It exists to
|
||||
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
|
||||
# during development.
|
||||
FROM golang:1.15-alpine
|
||||
FROM golang:1.26-alpine
|
||||
|
||||
RUN apk add git less make
|
||||
|
||||
|
||||
250
HACKING.md
250
HACKING.md
@@ -1,57 +1,241 @@
|
||||
Hacking
|
||||
=======
|
||||
The following is a quickstart guide for developing `cheat`.
|
||||
# Hacking Guide
|
||||
|
||||
## 1. Install system dependencies
|
||||
Before you begin, you must install a handful of system dependencies. The
|
||||
following are required, and must be available on your `PATH`:
|
||||
This document provides a comprehensive guide for developing `cheat`, including setup, architecture overview, and code patterns.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install system dependencies
|
||||
|
||||
The following are required and must be available on your `PATH`:
|
||||
- `git`
|
||||
- `go` (>= 1.17 is recommended)
|
||||
- `go` (>= 1.19 is recommended)
|
||||
- `make`
|
||||
|
||||
The following dependencies are optional:
|
||||
Optional dependencies:
|
||||
- `docker`
|
||||
- `pandoc` (necessary to generate a `man` page)
|
||||
|
||||
## 2. Install utility applications
|
||||
Run `make setup` to install `scc` and `revive`, which are used by various
|
||||
`make` targets.
|
||||
### 2. Install utility applications
|
||||
Run `make setup` to install `scc` and `revive`, which are used by various `make` targets.
|
||||
|
||||
## 3. Development workflow
|
||||
After your environment has been configured, your development workflow will
|
||||
resemble the following:
|
||||
### 3. Development workflow
|
||||
|
||||
1. Make changes to the `cheat` source code.
|
||||
2. Run `make test` to run unit-tests.
|
||||
3. Fix compiler errors and failing tests as necessary.
|
||||
4. Run `make`. A `cheat` executable will be written to the `dist` directory.
|
||||
5. Use the new executable by running `dist/cheat <command>`.
|
||||
6. Run `make install` to install `cheat` to your `PATH`.
|
||||
7. Run `make build-release` to build cross-platform binaries in `dist`.
|
||||
8. Run `make clean` to clean the `dist` directory when desired.
|
||||
1. Make changes to the `cheat` source code
|
||||
2. Run `make test` to run unit-tests
|
||||
3. Fix compiler errors and failing tests as necessary
|
||||
4. Run `make build`. A `cheat` executable will be written to the `dist` directory
|
||||
5. Use the new executable by running `dist/cheat <command>`
|
||||
6. Run `make install` to install `cheat` to your `PATH`
|
||||
7. Run `make build-release` to build cross-platform binaries in `dist`
|
||||
8. Run `make clean` to clean the `dist` directory when desired
|
||||
|
||||
You may run `make help` to see a list of available `make` commands.
|
||||
|
||||
### Developing with docker
|
||||
It may be useful to test your changes within a pristine environment. An
|
||||
Alpine-based docker container has been provided for that purpose.
|
||||
### 4. Testing
|
||||
|
||||
If you would like to build the docker container, run:
|
||||
```sh
|
||||
#### Unit Tests
|
||||
Run unit tests with:
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
#### Integration Tests
|
||||
Integration tests that require network access are separated using build tags. Run them with:
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
To run all tests (unit and integration):
|
||||
```bash
|
||||
make test-all
|
||||
```
|
||||
|
||||
#### Test Coverage
|
||||
Generate a coverage report with:
|
||||
```bash
|
||||
make coverage # HTML report
|
||||
make coverage-text # Terminal output
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Package Structure
|
||||
|
||||
The `cheat` application follows a clean architecture with well-separated concerns:
|
||||
|
||||
- **`cmd/cheat/`**: Command layer with argument parsing and command routing
|
||||
- **`internal/config`**: Configuration management (YAML loading, validation, paths)
|
||||
- **`internal/cheatpath`**: Cheatsheet path management (collections, filtering)
|
||||
- **`internal/sheet`**: Individual cheatsheet handling (parsing, search, highlighting)
|
||||
- **`internal/sheets`**: Collection operations (loading, consolidation, filtering)
|
||||
- **`internal/display`**: Output formatting (pager integration, colorization)
|
||||
- **`internal/repo`**: Git repository management for community sheets
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- **Filesystem-based storage**: Cheatsheets are plain text files
|
||||
- **Override mechanism**: Local sheets override community sheets with same name
|
||||
- **Tag system**: Sheets can be categorized with tags in frontmatter
|
||||
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
|
||||
|
||||
## Core Types and Functions
|
||||
|
||||
### Config (`internal/config`)
|
||||
|
||||
The main configuration structure:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Colorize bool `yaml:"colorize"`
|
||||
Editor string `yaml:"editor"`
|
||||
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
|
||||
Style string `yaml:"style"`
|
||||
Formatter string `yaml:"formatter"`
|
||||
Pager string `yaml:"pager"`
|
||||
Path string
|
||||
}
|
||||
```
|
||||
|
||||
Key functions:
|
||||
- `New(opts, confPath, resolve)` - Load config from file
|
||||
- `Validate()` - Validate configuration values
|
||||
- `Editor()` - Get editor from environment or defaults (package-level function)
|
||||
- `Pager()` - Get pager from environment or defaults (package-level function)
|
||||
|
||||
### Cheatpath (`internal/cheatpath`)
|
||||
|
||||
Represents a directory containing cheatsheets:
|
||||
|
||||
```go
|
||||
type Cheatpath struct {
|
||||
Name string // Friendly name (e.g., "personal")
|
||||
Path string // Filesystem path
|
||||
Tags []string // Tags applied to all sheets in this path
|
||||
ReadOnly bool // Whether sheets can be modified
|
||||
}
|
||||
```
|
||||
|
||||
### Sheet (`internal/sheet`)
|
||||
|
||||
Represents an individual cheatsheet:
|
||||
|
||||
```go
|
||||
type Sheet struct {
|
||||
Title string // Sheet name (from filename)
|
||||
CheatPath string // Name of the cheatpath this sheet belongs to
|
||||
Path string // Full filesystem path
|
||||
Text string // Content (without frontmatter)
|
||||
Tags []string // Combined tags (from frontmatter + cheatpath)
|
||||
Syntax string // Syntax for highlighting
|
||||
ReadOnly bool // Whether sheet can be edited
|
||||
}
|
||||
```
|
||||
|
||||
Key methods:
|
||||
- `New(title, cheatpath, path, tags, readOnly)` - Load from file
|
||||
- `Search(reg)` - Search content with a compiled regexp
|
||||
- `Colorize(conf)` - Apply syntax highlighting (modifies sheet in place)
|
||||
- `Tagged(needle)` - Check if sheet has the given tag
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Loading and Displaying a Sheet
|
||||
|
||||
```go
|
||||
// Load sheet
|
||||
s, err := sheet.New("tar", "personal", "/path/to/tar", []string{"personal"}, false)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Apply syntax highlighting (modifies sheet in place)
|
||||
s.Colorize(conf)
|
||||
|
||||
// Display with pager
|
||||
display.Write(s.Text, conf)
|
||||
```
|
||||
|
||||
### Working with Sheet Collections
|
||||
|
||||
```go
|
||||
// Load all sheets from cheatpaths (returns a slice of maps, one per cheatpath)
|
||||
allSheets, err := sheets.Load(conf.Cheatpaths)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Consolidate to handle duplicates (later cheatpaths take precedence)
|
||||
consolidated := sheets.Consolidate(allSheets)
|
||||
|
||||
// Filter by tag (operates on the slice of maps)
|
||||
filtered := sheets.Filter(allSheets, []string{"networking"})
|
||||
|
||||
// Sort alphabetically (returns a sorted slice)
|
||||
sorted := sheets.Sort(consolidated)
|
||||
```
|
||||
|
||||
### Sheet Format
|
||||
|
||||
Cheatsheets are plain text files that may begin with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
syntax: bash
|
||||
tags: [networking, linux, ssh]
|
||||
---
|
||||
# Connect to remote server
|
||||
ssh user@hostname
|
||||
|
||||
# Copy files over SSH
|
||||
scp local_file user@hostname:/remote/path
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
make test # Run all tests
|
||||
make coverage # Generate coverage report
|
||||
go test ./... # Go test directly
|
||||
```
|
||||
|
||||
Test files follow Go conventions:
|
||||
- `*_test.go` files in same package
|
||||
- Table-driven tests for multiple scenarios
|
||||
- Mock data in `internal/mock` package
|
||||
|
||||
## Error Handling
|
||||
|
||||
The codebase follows consistent error handling patterns:
|
||||
- Functions return explicit errors
|
||||
- Errors are wrapped with context using `fmt.Errorf`
|
||||
- User-facing errors are written to stderr
|
||||
|
||||
Example:
|
||||
```go
|
||||
sheet, err := sheet.New(path, tags, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load sheet: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Developing with Docker
|
||||
|
||||
It may be useful to test your changes within a pristine environment. An Alpine-based docker container has been provided for that purpose.
|
||||
|
||||
Build the docker container:
|
||||
```bash
|
||||
make docker-setup
|
||||
```
|
||||
|
||||
To shell into the container, run:
|
||||
```sh
|
||||
Shell into the container:
|
||||
```bash
|
||||
make docker-sh
|
||||
```
|
||||
|
||||
The `cheat` source code will be mounted at `/app` within the container.
|
||||
|
||||
If you would like to destroy this container, you may run:
|
||||
```sh
|
||||
To destroy the container:
|
||||
```bash
|
||||
make distclean
|
||||
```
|
||||
|
||||
[go]: https://go.dev/
|
||||
|
||||
@@ -9,20 +9,20 @@ On Unix-like systems, you may simply paste the following snippet into your termi
|
||||
|
||||
```sh
|
||||
cd /tmp \
|
||||
&& wget https://github.com/cheat/cheat/releases/download/4.3.2/cheat-linux-amd64.gz \
|
||||
&& wget https://github.com/cheat/cheat/releases/download/4.5.1/cheat-linux-amd64.gz \
|
||||
&& gunzip cheat-linux-amd64.gz \
|
||||
&& chmod +x cheat-linux-amd64 \
|
||||
&& sudo mv cheat-linux-amd64 /usr/local/bin/cheat
|
||||
```
|
||||
|
||||
You may need to need to change the version number (`4.3.2`) and the archive
|
||||
You may need to need to change the version number (`4.5.1`) and the archive
|
||||
(`cheat-linux-amd64.gz`) depending on your platform.
|
||||
|
||||
See the [releases page][releases] for a list of supported platforms.
|
||||
|
||||
#### Windows
|
||||
TODO: community support is requested here. Please open a PR if you'd like to
|
||||
contribute installation instructions for Windows.
|
||||
On Windows, download the appropriate binary from the [releases page][releases],
|
||||
unzip the archive, and place the `cheat.exe` executable on your `PATH`.
|
||||
|
||||
### Install via `go install`
|
||||
If you have `go` version `>=1.17` available on your `PATH`, you can install
|
||||
|
||||
134
Makefile
134
Makefile
@@ -3,6 +3,9 @@ makefile := $(realpath $(lastword $(MAKEFILE_LIST)))
|
||||
cmd_dir := ./cmd/cheat
|
||||
dist_dir := ./dist
|
||||
|
||||
# parallel jobs for build-release (can be overridden)
|
||||
JOBS ?= 8
|
||||
|
||||
# executables
|
||||
CAT := cat
|
||||
COLUMN := column
|
||||
@@ -31,60 +34,92 @@ TMPDIR := /tmp
|
||||
# release binaries
|
||||
releases := \
|
||||
$(dist_dir)/cheat-darwin-amd64 \
|
||||
$(dist_dir)/cheat-darwin-arm64 \
|
||||
$(dist_dir)/cheat-linux-386 \
|
||||
$(dist_dir)/cheat-linux-amd64 \
|
||||
$(dist_dir)/cheat-linux-arm5 \
|
||||
$(dist_dir)/cheat-linux-arm6 \
|
||||
$(dist_dir)/cheat-linux-arm7 \
|
||||
$(dist_dir)/cheat-linux-arm64 \
|
||||
$(dist_dir)/cheat-linux-arm7 \
|
||||
$(dist_dir)/cheat-netbsd-amd64 \
|
||||
$(dist_dir)/cheat-openbsd-amd64 \
|
||||
$(dist_dir)/cheat-solaris-amd64 \
|
||||
$(dist_dir)/cheat-windows-amd64.exe
|
||||
|
||||
## build: build an executable for your architecture
|
||||
.PHONY: build
|
||||
build: $(dist_dir) | clean generate fmt lint vet vendor man
|
||||
build: | clean $(dist_dir) fmt lint vet vendor man
|
||||
$(GO) build $(BUILD_FLAGS) -o $(dist_dir)/cheat $(cmd_dir)
|
||||
|
||||
## build-release: build release executables
|
||||
# Runs prepare once, then builds all binaries in parallel
|
||||
# Override jobs with: make build-release JOBS=16
|
||||
.PHONY: build-release
|
||||
build-release: $(releases)
|
||||
build-release: prepare
|
||||
$(MAKE) -j$(JOBS) $(releases)
|
||||
|
||||
# cheat-darwin-amd64
|
||||
$(dist_dir)/cheat-darwin-amd64: prepare
|
||||
$(dist_dir)/cheat-darwin-amd64:
|
||||
GOARCH=amd64 GOOS=darwin \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-darwin-arm64
|
||||
$(dist_dir)/cheat-darwin-arm64:
|
||||
GOARCH=arm64 GOOS=darwin \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-linux-386
|
||||
$(dist_dir)/cheat-linux-386: prepare
|
||||
$(dist_dir)/cheat-linux-386:
|
||||
GOARCH=386 GOOS=linux \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-linux-amd64
|
||||
$(dist_dir)/cheat-linux-amd64: prepare
|
||||
$(dist_dir)/cheat-linux-amd64:
|
||||
GOARCH=amd64 GOOS=linux \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-linux-arm5
|
||||
$(dist_dir)/cheat-linux-arm5: prepare
|
||||
$(dist_dir)/cheat-linux-arm5:
|
||||
GOARCH=arm GOOS=linux GOARM=5 \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-linux-arm6
|
||||
$(dist_dir)/cheat-linux-arm6: prepare
|
||||
$(dist_dir)/cheat-linux-arm6:
|
||||
GOARCH=arm GOOS=linux GOARM=6 \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-linux-arm7
|
||||
$(dist_dir)/cheat-linux-arm7: prepare
|
||||
$(dist_dir)/cheat-linux-arm7:
|
||||
GOARCH=arm GOOS=linux GOARM=7 \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-linux-arm64
|
||||
$(dist_dir)/cheat-linux-arm64: prepare
|
||||
$(dist_dir)/cheat-linux-arm64:
|
||||
GOARCH=arm64 GOOS=linux \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-netbsd-amd64
|
||||
$(dist_dir)/cheat-netbsd-amd64:
|
||||
GOARCH=amd64 GOOS=netbsd \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-openbsd-amd64
|
||||
$(dist_dir)/cheat-openbsd-amd64:
|
||||
GOARCH=amd64 GOOS=openbsd \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-plan9-amd64
|
||||
$(dist_dir)/cheat-plan9-amd64:
|
||||
GOARCH=amd64 GOOS=plan9 \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-solaris-amd64
|
||||
$(dist_dir)/cheat-solaris-amd64:
|
||||
GOARCH=amd64 GOOS=solaris \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||
|
||||
# cheat-windows-amd64
|
||||
$(dist_dir)/cheat-windows-amd64.exe: prepare
|
||||
$(dist_dir)/cheat-windows-amd64.exe:
|
||||
GOARCH=amd64 GOOS=windows \
|
||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(ZIP) $@.zip $@ -j
|
||||
|
||||
@@ -92,9 +127,9 @@ $(dist_dir)/cheat-windows-amd64.exe: prepare
|
||||
$(dist_dir):
|
||||
$(MKDIR) $(dist_dir)
|
||||
|
||||
.PHONY: generate
|
||||
generate:
|
||||
$(GO) generate $(cmd_dir)
|
||||
# .tmp
|
||||
.tmp:
|
||||
$(MKDIR) .tmp
|
||||
|
||||
## install: build and install cheat on your PATH
|
||||
.PHONY: install
|
||||
@@ -103,8 +138,9 @@ install: build
|
||||
|
||||
## clean: remove compiled executables
|
||||
.PHONY: clean
|
||||
clean: $(dist_dir)
|
||||
$(RM) -f $(dist_dir)/* $(cmd_dir)/str_config.go $(cmd_dir)/str_usage.go
|
||||
clean:
|
||||
$(RM) -f $(dist_dir)/*
|
||||
$(RM) -rf .tmp
|
||||
|
||||
## distclean: remove the tags file
|
||||
.PHONY: distclean
|
||||
@@ -115,7 +151,8 @@ distclean:
|
||||
## setup: install revive (linter) and scc (sloc tool)
|
||||
.PHONY: setup
|
||||
setup:
|
||||
GO111MODULE=off $(GO) get -u github.com/boyter/scc github.com/mgechev/revive
|
||||
$(GO) install github.com/boyter/scc@latest
|
||||
$(GO) install github.com/mgechev/revive@latest
|
||||
|
||||
## sloc: count "semantic lines of code"
|
||||
.PHONY: sloc
|
||||
@@ -139,8 +176,9 @@ vendor:
|
||||
$(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
|
||||
|
||||
## vendor-update: update vendored dependencies
|
||||
.PHONY: vendor-update
|
||||
vendor-update:
|
||||
$(GO) get -t -u ./... && $(GO) mod vendor
|
||||
$(GO) get -t -u ./... && $(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
|
||||
|
||||
## fmt: run go fmt
|
||||
.PHONY: fmt
|
||||
@@ -162,18 +200,70 @@ vet:
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
## test-integration: run integration tests (requires network)
|
||||
.PHONY: test-integration
|
||||
test-integration:
|
||||
$(GO) test -tags=integration -count=1 ./...
|
||||
|
||||
## test-all: run all tests (unit and integration)
|
||||
.PHONY: test-all
|
||||
test-all: test test-integration
|
||||
|
||||
## test-fuzz: run quick fuzz tests for security-critical functions
|
||||
.PHONY: test-fuzz
|
||||
test-fuzz:
|
||||
@./build/fuzz.sh 15s
|
||||
|
||||
## test-fuzz-long: run extended fuzz tests (10 minutes each)
|
||||
.PHONY: test-fuzz-long
|
||||
test-fuzz-long:
|
||||
@./build/fuzz.sh 10m
|
||||
|
||||
## coverage: generate a test coverage report
|
||||
.PHONY: coverage
|
||||
coverage:
|
||||
$(GO) test ./... -coverprofile=$(TMPDIR)/cheat-coverage.out && \
|
||||
$(GO) tool cover -html=$(TMPDIR)/cheat-coverage.out
|
||||
coverage: .tmp
|
||||
$(GO) test ./... -coverprofile=.tmp/cheat-coverage.out && \
|
||||
$(GO) tool cover -html=.tmp/cheat-coverage.out -o .tmp/cheat-coverage.html && \
|
||||
echo "Coverage report generated: .tmp/cheat-coverage.html" && \
|
||||
(sensible-browser .tmp/cheat-coverage.html 2>/dev/null || \
|
||||
xdg-open .tmp/cheat-coverage.html 2>/dev/null || \
|
||||
open .tmp/cheat-coverage.html 2>/dev/null || \
|
||||
echo "Please open .tmp/cheat-coverage.html in your browser")
|
||||
|
||||
## coverage-text: show test coverage by function in terminal
|
||||
.PHONY: coverage-text
|
||||
coverage-text: .tmp
|
||||
$(GO) test ./... -coverprofile=.tmp/cheat-coverage.out && \
|
||||
$(GO) tool cover -func=.tmp/cheat-coverage.out | $(SORT) -k3 -n
|
||||
|
||||
## benchmark: run performance benchmarks
|
||||
.PHONY: benchmark
|
||||
benchmark: .tmp
|
||||
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./cmd/cheat | tee .tmp/benchmark-latest.txt && \
|
||||
$(RM) -f cheat.test
|
||||
|
||||
## benchmark-cpu: run benchmarks with CPU profiling
|
||||
.PHONY: benchmark-cpu
|
||||
benchmark-cpu: .tmp
|
||||
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./cmd/cheat && \
|
||||
$(RM) -f cheat.test && \
|
||||
echo "CPU profile saved to .tmp/cpu.prof" && \
|
||||
echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof"
|
||||
|
||||
## benchmark-mem: run benchmarks with memory profiling
|
||||
.PHONY: benchmark-mem
|
||||
benchmark-mem: .tmp
|
||||
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./cmd/cheat && \
|
||||
$(RM) -f cheat.test && \
|
||||
echo "Memory profile saved to .tmp/mem.prof" && \
|
||||
echo "View with: go tool pprof -http=:8080 .tmp/mem.prof"
|
||||
|
||||
## check: format, lint, vet, vendor, and run unit-tests
|
||||
.PHONY: check
|
||||
check: | vendor fmt lint vet test
|
||||
|
||||
.PHONY: prepare
|
||||
prepare: | $(dist_dir) clean generate vendor fmt lint vet test
|
||||
prepare: | clean $(dist_dir) vendor fmt lint vet test
|
||||
|
||||
## docker-setup: create a docker image for use during development
|
||||
.PHONY: docker-setup
|
||||
|
||||
@@ -117,7 +117,7 @@ cheat tar # file is named "tar"
|
||||
cheat foo/bar # file is named "bar", in a "foo" subdirectory
|
||||
```
|
||||
|
||||
Cheatsheet text may optionally be preceeded by a YAML frontmatter header that
|
||||
Cheatsheet text may optionally be preceded by a YAML frontmatter header that
|
||||
assigns tags and specifies syntax:
|
||||
|
||||
```
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
// This script embeds `docopt.txt and `conf.yml` into the binary during at
|
||||
// build time.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
// get the cwd
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// get the project root
|
||||
root, err := filepath.Abs(cwd + "../../../")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// specify template file information
|
||||
type file struct {
|
||||
In string
|
||||
Out string
|
||||
Method string
|
||||
}
|
||||
|
||||
// enumerate the template files to process
|
||||
files := []file{
|
||||
file{
|
||||
In: "cmd/cheat/docopt.txt",
|
||||
Out: "cmd/cheat/str_usage.go",
|
||||
Method: "usage"},
|
||||
file{
|
||||
In: "configs/conf.yml",
|
||||
Out: "cmd/cheat/str_config.go",
|
||||
Method: "configs"},
|
||||
}
|
||||
|
||||
// iterate over each static file
|
||||
for _, file := range files {
|
||||
|
||||
// delete the outfile
|
||||
os.Remove(filepath.Join(root, file.Out))
|
||||
|
||||
// read the static template
|
||||
bytes, err := ioutil.ReadFile(filepath.Join(root, file.In))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// render the template
|
||||
data := template(file.Method, string(bytes))
|
||||
|
||||
// write the file to the specified outpath
|
||||
spath := filepath.Join(root, file.Out)
|
||||
err = ioutil.WriteFile(spath, []byte(data), 0644)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// template packages the
|
||||
func template(method string, body string) string {
|
||||
|
||||
// specify the template string
|
||||
t := `package main
|
||||
|
||||
// Code generated .* DO NOT EDIT.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func %s() string {
|
||||
return strings.TrimSpace(%s)
|
||||
}
|
||||
`
|
||||
|
||||
return fmt.Sprintf(t, method, "`"+body+"`")
|
||||
}
|
||||
37
build/fuzz.sh
Executable file
37
build/fuzz.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run fuzz tests for cheat
|
||||
# Usage: ./scripts/fuzz.sh [duration]
|
||||
#
|
||||
# Note: Go's fuzzer will fail immediately if it finds a known failing input
|
||||
# in the corpus (testdata/fuzz/*). This is by design - it ensures you fix
|
||||
# known bugs before searching for new ones. To see failing inputs:
|
||||
# ls internal/*/testdata/fuzz/*/
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
DURATION="${1:-15s}"
|
||||
|
||||
# Define fuzz tests: "TestName:Package:Description"
|
||||
TESTS=(
|
||||
"FuzzParse:./internal/sheet:YAML frontmatter parsing"
|
||||
"FuzzValidateSheetName:./internal/cheatpath:sheet name validation (path traversal protection)"
|
||||
"FuzzSearchRegex:./internal/sheet:regex search operations"
|
||||
"FuzzSearchCatastrophicBacktracking:./internal/sheet:catastrophic backtracking"
|
||||
"FuzzTagged:./internal/sheet:tag matching with malicious input"
|
||||
"FuzzFilter:./internal/sheets:tag filtering operations"
|
||||
"FuzzTags:./internal/sheets:tag aggregation and sorting"
|
||||
)
|
||||
|
||||
echo "Running fuzz tests ($DURATION each)..."
|
||||
echo
|
||||
|
||||
for i in "${!TESTS[@]}"; do
|
||||
IFS=':' read -r test_name package description <<< "${TESTS[$i]}"
|
||||
echo "$((i+1)). Testing $description..."
|
||||
go test -fuzz="^${test_name}$" -fuzztime="$DURATION" "$package"
|
||||
echo
|
||||
done
|
||||
|
||||
echo "All fuzz tests passed!"
|
||||
@@ -6,6 +6,6 @@ import (
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
)
|
||||
|
||||
func cmdConf(opts map[string]interface{}, conf config.Config) {
|
||||
func cmdConf(_ map[string]interface{}, conf config.Config) {
|
||||
fmt.Println(conf.Path)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// cmdDirectories lists the configured cheatpaths.
|
||||
func cmdDirectories(opts map[string]interface{}, conf config.Config) {
|
||||
func cmdDirectories(_ map[string]interface{}, conf config.Config) {
|
||||
|
||||
// initialize a tabwriter to produce cleanly columnized output
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -17,6 +17,12 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
|
||||
|
||||
cheatsheet := opts["--edit"].(string)
|
||||
|
||||
// validate the cheatsheet name
|
||||
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// load the cheatsheets
|
||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||
if err != nil {
|
||||
|
||||
@@ -44,13 +44,37 @@ func cmdInit() {
|
||||
confpath := confpaths[0]
|
||||
confdir := filepath.Dir(confpath)
|
||||
|
||||
// create paths for community and personal cheatsheets
|
||||
// create paths for community, personal, and work cheatsheets
|
||||
community := filepath.Join(confdir, "cheatsheets", "community")
|
||||
personal := filepath.Join(confdir, "cheatsheets", "personal")
|
||||
work := filepath.Join(confdir, "cheatsheets", "work")
|
||||
|
||||
// template the above paths into the default configs
|
||||
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
|
||||
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
|
||||
configs = strings.Replace(configs, "WORK_PATH", work, -1)
|
||||
|
||||
// locate and set a default pager
|
||||
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
|
||||
|
||||
// locate and set a default editor
|
||||
if editor, err := config.Editor(); err == nil {
|
||||
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
|
||||
}
|
||||
|
||||
// comment out the community cheatpath by default, since the directory
|
||||
// won't exist until the user clones it
|
||||
configs = strings.Replace(configs,
|
||||
" - name: community\n"+
|
||||
" path: "+community+"\n"+
|
||||
" tags: [ community ]\n"+
|
||||
" readonly: true",
|
||||
" #- name: community\n"+
|
||||
" # path: "+community+"\n"+
|
||||
" # tags: [ community ]\n"+
|
||||
" # readonly: true",
|
||||
-1,
|
||||
)
|
||||
|
||||
// output the templated configs
|
||||
fmt.Println(configs)
|
||||
|
||||
@@ -5,15 +5,22 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cheat/cheat/internal/cheatpath"
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
"github.com/cheat/cheat/internal/sheets"
|
||||
)
|
||||
|
||||
// cmdRemove opens a cheatsheet for editing (or creates it if it doesn't exist).
|
||||
// cmdRemove removes (deletes) a cheatsheet.
|
||||
func cmdRemove(opts map[string]interface{}, conf config.Config) {
|
||||
|
||||
cheatsheet := opts["--rm"].(string)
|
||||
|
||||
// validate the cheatsheet name
|
||||
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// load the cheatsheets
|
||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||
if err != nil {
|
||||
|
||||
@@ -31,6 +31,21 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
|
||||
)
|
||||
}
|
||||
|
||||
// prepare the search pattern
|
||||
pattern := "(?i)" + phrase
|
||||
|
||||
// unless --regex is provided, in which case we pass the regex unaltered
|
||||
if opts["--regex"] == true {
|
||||
pattern = phrase
|
||||
}
|
||||
|
||||
// compile the regex once, outside the loop
|
||||
reg, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to compile regexp: %s, %v\n", pattern, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// iterate over each cheatpath
|
||||
out := ""
|
||||
for _, pathcheats := range cheatsheets {
|
||||
@@ -44,21 +59,6 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
|
||||
continue
|
||||
}
|
||||
|
||||
// assume that we want to perform a case-insensitive search for <phrase>
|
||||
pattern := "(?i)" + phrase
|
||||
|
||||
// unless --regex is provided, in which case we pass the regex unaltered
|
||||
if opts["--regex"] == true {
|
||||
pattern = phrase
|
||||
}
|
||||
|
||||
// compile the regex
|
||||
reg, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to compile regexp: %s, %v\n", pattern, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// `Search` will return text entries that match the search terms.
|
||||
// We're using it here to overwrite the prior cheatsheet Text,
|
||||
// filtering it to only what is relevant.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// cmdTags lists all tags in use.
|
||||
func cmdTags(opts map[string]interface{}, conf config.Config) {
|
||||
func cmdTags(_ map[string]interface{}, conf config.Config) {
|
||||
|
||||
// load the cheatsheets
|
||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||
|
||||
74
cmd/cheat/config.go
Normal file
74
cmd/cheat/config.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
// configs returns the default configuration template
|
||||
func configs() string {
|
||||
return `---
|
||||
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
|
||||
editor: EDITOR_PATH
|
||||
|
||||
# Should 'cheat' always colorize output?
|
||||
colorize: false
|
||||
|
||||
# Which 'chroma' colorscheme should be applied to the output?
|
||||
# Options are available here:
|
||||
# https://github.com/alecthomas/chroma/tree/master/styles
|
||||
style: monokai
|
||||
|
||||
# Which 'chroma' "formatter" should be applied?
|
||||
# One of: "terminal", "terminal256", "terminal16m"
|
||||
formatter: terminal256
|
||||
|
||||
# Through which pager should output be piped?
|
||||
# 'less -FRX' is recommended on Unix systems
|
||||
# 'more' is recommended on Windows
|
||||
pager: PAGER_PATH
|
||||
|
||||
# The paths at which cheatsheets are available. Tags associated with a cheatpath
|
||||
# are automatically attached to all cheatsheets residing on that path.
|
||||
#
|
||||
# Whenever cheatsheets share the same title (like 'tar'), the most local
|
||||
# cheatsheets (those which come later in this file) take precedence over the
|
||||
# less local sheets. This allows you to create your own "overides" for
|
||||
# "upstream" cheatsheets.
|
||||
#
|
||||
# But what if you want to view the "upstream" cheatsheets instead of your own?
|
||||
# Cheatsheets may be filtered by 'tags' in combination with the '--tag' flag.
|
||||
#
|
||||
# Example: 'cheat tar --tag=community' will display the 'tar' cheatsheet that
|
||||
# is tagged as 'community' rather than your own.
|
||||
#
|
||||
# Paths that come earlier are considered to be the most "global", and paths
|
||||
# that come later are considered to be the most "local". The most "local" paths
|
||||
# take precedence.
|
||||
#
|
||||
# See: https://github.com/cheat/cheat/blob/master/doc/cheat.1.md#cheatpaths
|
||||
cheatpaths:
|
||||
|
||||
# Cheatsheets that are tagged "personal" are stored here by default:
|
||||
- name: personal
|
||||
path: PERSONAL_PATH
|
||||
tags: [ personal ]
|
||||
readonly: false
|
||||
|
||||
# Cheatsheets that are tagged "work" are stored here by default:
|
||||
- name: work
|
||||
path: WORK_PATH
|
||||
tags: [ work ]
|
||||
readonly: false
|
||||
|
||||
# Community cheatsheets (https://github.com/cheat/cheatsheets):
|
||||
# To install: git clone https://github.com/cheat/cheatsheets COMMUNITY_PATH
|
||||
- name: community
|
||||
path: COMMUNITY_PATH
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
|
||||
# You can also use glob patterns to automatically load cheatsheets from all
|
||||
# directories that match.
|
||||
#
|
||||
# Example: overload cheatsheets for projects under ~/src/github.com/example/*/
|
||||
#- name: example-projects
|
||||
# path: ~/src/github.com/example/**/.cheat
|
||||
# tags: [ example ]
|
||||
# readonly: true`
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
Usage:
|
||||
cheat [options] [<cheatsheet>]
|
||||
|
||||
Options:
|
||||
--init Write a default config file to stdout
|
||||
-a --all Search among all cheatpaths
|
||||
-c --colorize Colorize output
|
||||
-d --directories List cheatsheet directories
|
||||
-e --edit=<cheatsheet> Edit <cheatsheet>
|
||||
-l --list List cheatsheets
|
||||
-p --path=<name> Return only sheets found on cheatpath <name>
|
||||
-r --regex Treat search <phrase> as a regex
|
||||
-s --search=<phrase> Search cheatsheets for <phrase>
|
||||
-t --tag=<tag> Return only sheets matching <tag>
|
||||
-T --tags List all tags in use
|
||||
-v --version Print the version number
|
||||
--rm=<cheatsheet> Remove (delete) <cheatsheet>
|
||||
--conf Display the config file path
|
||||
|
||||
Examples:
|
||||
|
||||
To initialize a config file:
|
||||
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
|
||||
|
||||
To view the tar cheatsheet:
|
||||
cheat tar
|
||||
|
||||
To edit (or create) the foo cheatsheet:
|
||||
cheat -e foo
|
||||
|
||||
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
|
||||
cheat -p work -e foo/bar
|
||||
|
||||
To view all cheatsheet directories:
|
||||
cheat -d
|
||||
|
||||
To list all available cheatsheets:
|
||||
cheat -l
|
||||
|
||||
To list all cheatsheets whose titles match "apt":
|
||||
cheat -l apt
|
||||
|
||||
To list all tags in use:
|
||||
cheat -T
|
||||
|
||||
To list available cheatsheets that are tagged as "personal":
|
||||
cheat -l -t personal
|
||||
|
||||
To search for "ssh" among all cheatsheets, and colorize matches:
|
||||
cheat -c -s ssh
|
||||
|
||||
To search (by regex) for cheatsheets that contain an IP address:
|
||||
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
|
||||
|
||||
To remove (delete) the foo/bar cheatsheet:
|
||||
cheat --rm foo/bar
|
||||
|
||||
To view the configuration file path:
|
||||
cheat --conf
|
||||
304
cmd/cheat/first_run_integration_test.go
Normal file
304
cmd/cheat/first_run_integration_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFirstRunIntegration exercises the end-to-end first-run experience:
|
||||
// no config exists, the binary creates one, and subsequent runs succeed.
|
||||
// This is the regression test for issues #721, #771, and #730.
|
||||
func TestFirstRunIntegration(t *testing.T) {
|
||||
// Build the cheat binary
|
||||
binName := "cheat_test"
|
||||
if runtime.GOOS == "windows" {
|
||||
binName += ".exe"
|
||||
}
|
||||
binPath := filepath.Join(t.TempDir(), binName)
|
||||
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
t.Run("init comments out community", func(t *testing.T) {
|
||||
testHome := t.TempDir()
|
||||
env := firstRunEnv(testHome)
|
||||
|
||||
cmd := exec.Command(binPath, "--init")
|
||||
cmd.Env = env
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("--init failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
outStr := string(output)
|
||||
|
||||
// No placeholder strings should survive (regression for #721)
|
||||
assertNoPlaceholders(t, outStr)
|
||||
|
||||
// Community cheatpath should be commented out
|
||||
assertCommunityCommentedOut(t, outStr)
|
||||
|
||||
// Personal and work cheatpaths should be active (uncommented)
|
||||
assertCheatpathActive(t, outStr, "personal")
|
||||
assertCheatpathActive(t, outStr, "work")
|
||||
|
||||
// Should include clone instructions
|
||||
if !strings.Contains(outStr, "git clone") {
|
||||
t.Error("expected git clone instructions in --init output")
|
||||
}
|
||||
|
||||
// Save the config and verify it loads without errors.
|
||||
// --init only outputs config, it doesn't create directories,
|
||||
// so we need to create the cheatpath dirs the config references.
|
||||
confpath := filepath.Join(testHome, "conf.yml")
|
||||
if err := os.WriteFile(confpath, output, 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Determine the confdir that --init used (same logic as cmd_init.go)
|
||||
initConfpaths := firstRunConfpaths(testHome)
|
||||
initConfdir := filepath.Dir(initConfpaths[0])
|
||||
for _, name := range []string{"personal", "work"} {
|
||||
dir := filepath.Join(initConfdir, "cheatsheets", name)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("failed to create %s dir: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd2 := exec.Command(binPath, "--directories")
|
||||
cmd2.Env = append(append([]string{}, env...), "CHEAT_CONFIG_PATH="+confpath)
|
||||
output2, err := cmd2.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("config from --init failed to load: %v\nOutput: %s", err, output2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decline config creation", func(t *testing.T) {
|
||||
testHome := t.TempDir()
|
||||
env := firstRunEnv(testHome)
|
||||
|
||||
cmd := exec.Command(binPath)
|
||||
cmd.Env = env
|
||||
cmd.Stdin = strings.NewReader("n\n")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("cheat exited with error: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Verify no config was created
|
||||
if firstRunConfigExists(testHome) {
|
||||
t.Error("config file was created despite user declining")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accept config decline community", func(t *testing.T) {
|
||||
testHome := t.TempDir()
|
||||
env := firstRunEnv(testHome)
|
||||
|
||||
// First run: yes to create config, no to community cheatsheets
|
||||
cmd := exec.Command(binPath)
|
||||
cmd.Env = env
|
||||
cmd.Stdin = strings.NewReader("y\nn\n")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("first run failed: %v\nOutput: %s", err, output)
|
||||
}
|
||||
outStr := string(output)
|
||||
|
||||
// Parse the config path from output
|
||||
confpath := parseCreatedConfPath(t, outStr)
|
||||
if confpath == "" {
|
||||
t.Fatalf("could not find config path in output:\n%s", outStr)
|
||||
}
|
||||
|
||||
// Verify config file exists
|
||||
if _, err := os.Stat(confpath); os.IsNotExist(err) {
|
||||
t.Fatalf("config file not found at %s", confpath)
|
||||
}
|
||||
|
||||
// Verify config file contents
|
||||
content, err := os.ReadFile(confpath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config: %v", err)
|
||||
}
|
||||
contentStr := string(content)
|
||||
|
||||
// No placeholder strings should survive (regression for #721)
|
||||
assertNoPlaceholders(t, contentStr)
|
||||
|
||||
// Community cheatpath should be commented out
|
||||
assertCommunityCommentedOut(t, contentStr)
|
||||
|
||||
// Personal and work cheatpaths should be active (uncommented)
|
||||
assertCheatpathActive(t, contentStr, "personal")
|
||||
assertCheatpathActive(t, contentStr, "work")
|
||||
|
||||
// Verify personal and work directories were created
|
||||
confdir := filepath.Dir(confpath)
|
||||
for _, name := range []string{"personal", "work"} {
|
||||
dir := filepath.Join(confdir, "cheatsheets", name)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
t.Errorf("expected %s directory at %s", name, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// Community directory should NOT exist
|
||||
communityDir := filepath.Join(confdir, "cheatsheets", "community")
|
||||
if _, err := os.Stat(communityDir); err == nil {
|
||||
t.Error("community directory should not exist when declined")
|
||||
}
|
||||
|
||||
// --- Second run: verify the config loads successfully ---
|
||||
// This is the core regression test for #721/#771/#730:
|
||||
// previously, the second run would fail because config.New()
|
||||
// hard-errored on the missing community cheatpath directory.
|
||||
// Use --directories (not --list, which exits 2 when no sheets exist).
|
||||
cmd2 := exec.Command(binPath, "--directories")
|
||||
cmd2.Env = append(append([]string{}, env...), "CHEAT_CONFIG_PATH="+confpath)
|
||||
output2, err := cmd2.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"second run failed (regression for #721/#771/#730): %v\nOutput: %s",
|
||||
err, output2,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the output lists the expected cheatpaths
|
||||
outStr2 := string(output2)
|
||||
if !strings.Contains(outStr2, "personal") {
|
||||
t.Errorf("expected 'personal' cheatpath in --directories output:\n%s", outStr2)
|
||||
}
|
||||
if !strings.Contains(outStr2, "work") {
|
||||
t.Errorf("expected 'work' cheatpath in --directories output:\n%s", outStr2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// firstRunEnv returns a minimal environment for a clean first-run test.
|
||||
func firstRunEnv(home string) []string {
|
||||
env := []string{
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
env = append(env,
|
||||
"APPDATA="+filepath.Join(home, "AppData", "Roaming"),
|
||||
"USERPROFILE="+home,
|
||||
"SystemRoot="+os.Getenv("SystemRoot"),
|
||||
)
|
||||
default:
|
||||
env = append(env,
|
||||
"HOME="+home,
|
||||
"EDITOR=vi",
|
||||
)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// parseCreatedConfPath extracts the config file path from the installer's
|
||||
// "Created config file: <path>" output. The message may appear mid-line
|
||||
// (after prompt text), so we search for the substring anywhere in the output.
|
||||
func parseCreatedConfPath(t *testing.T, output string) string {
|
||||
t.Helper()
|
||||
const marker = "Created config file: "
|
||||
idx := strings.Index(output, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := output[idx+len(marker):]
|
||||
// the path ends at the next newline
|
||||
if nl := strings.IndexByte(rest, '\n'); nl >= 0 {
|
||||
rest = rest[:nl]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// firstRunConfpaths returns the config file paths that cheat would check
|
||||
// for the given home directory, matching the logic in config.Paths().
|
||||
func firstRunConfpaths(home string) []string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return []string{
|
||||
filepath.Join(home, "AppData", "Roaming", "cheat", "conf.yml"),
|
||||
}
|
||||
default:
|
||||
return []string{
|
||||
filepath.Join(home, ".config", "cheat", "conf.yml"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoPlaceholders verifies that no template placeholder strings survived
|
||||
// in the config output. This is the regression check for #721 (literal
|
||||
// PAGER_PATH appearing in the config).
|
||||
func assertNoPlaceholders(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
placeholders := []string{
|
||||
"PAGER_PATH",
|
||||
"COMMUNITY_PATH",
|
||||
"PERSONAL_PATH",
|
||||
"WORK_PATH",
|
||||
}
|
||||
for _, p := range placeholders {
|
||||
if strings.Contains(content, p) {
|
||||
t.Errorf("placeholder %q was not replaced in config", p)
|
||||
}
|
||||
}
|
||||
// EDITOR_PATH is special: it survives if no editor is found.
|
||||
// In our test env EDITOR=vi is set, so it should be replaced.
|
||||
if strings.Contains(content, "editor: EDITOR_PATH") {
|
||||
t.Error("placeholder EDITOR_PATH was not replaced in config")
|
||||
}
|
||||
}
|
||||
|
||||
// assertCommunityCommentedOut verifies that the community cheatpath entry
|
||||
// is commented out (not active) in the config.
|
||||
func assertCommunityCommentedOut(t *testing.T, content string) {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "- name: community" {
|
||||
t.Error("community cheatpath should be commented out")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !strings.Contains(content, "#- name: community") {
|
||||
t.Error("expected commented-out community cheatpath")
|
||||
}
|
||||
}
|
||||
|
||||
// assertCheatpathActive verifies that a named cheatpath is present and
|
||||
// uncommented in the config.
|
||||
func assertCheatpathActive(t *testing.T, content string, name string) {
|
||||
t.Helper()
|
||||
marker := "- name: " + name
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == marker {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected active (uncommented) cheatpath %q", name)
|
||||
}
|
||||
|
||||
// firstRunConfigExists checks whether a cheat config file exists under the
|
||||
// given home directory at any of the standard locations.
|
||||
func firstRunConfigExists(home string) bool {
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".config", "cheat", "conf.yml"),
|
||||
filepath.Join(home, ".cheat", "conf.yml"),
|
||||
filepath.Join(home, "AppData", "Roaming", "cheat", "conf.yml"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Package main serves as the executable entrypoint.
|
||||
package main
|
||||
|
||||
//go:generate go run ../../build/embed.go
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -16,7 +15,7 @@ import (
|
||||
"github.com/cheat/cheat/internal/installer"
|
||||
)
|
||||
|
||||
const version = "4.3.2"
|
||||
const version = "4.5.1"
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -44,6 +43,7 @@ func main() {
|
||||
// read the envvars into a map of strings
|
||||
envvars := map[string]string{}
|
||||
for _, e := range os.Environ() {
|
||||
// os.Environ() guarantees "key=value" format (see ADR-002)
|
||||
pair := strings.SplitN(e, "=", 2)
|
||||
if runtime.GOOS == "windows" {
|
||||
pair[0] = strings.ToUpper(pair[0])
|
||||
|
||||
225
cmd/cheat/path_traversal_integration_test.go
Normal file
225
cmd/cheat/path_traversal_integration_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPathTraversalIntegration tests that the cheat binary properly blocks
|
||||
// path traversal attempts when invoked as a subprocess.
|
||||
func TestPathTraversalIntegration(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("integration test uses Unix-specific env and tools")
|
||||
}
|
||||
|
||||
// Build the cheat binary
|
||||
binPath := filepath.Join(t.TempDir(), "cheat_test")
|
||||
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
|
||||
t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Set up test environment
|
||||
testDir := t.TempDir()
|
||||
sheetsDir := filepath.Join(testDir, "sheets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
|
||||
// Create config
|
||||
config := fmt.Sprintf(`---
|
||||
editor: echo
|
||||
colorize: false
|
||||
pager: cat
|
||||
cheatpaths:
|
||||
- name: test
|
||||
path: %s
|
||||
readonly: false
|
||||
`, sheetsDir)
|
||||
|
||||
configPath := filepath.Join(testDir, "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
|
||||
t.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Test table
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
wantFail bool
|
||||
wantMsg string
|
||||
}{
|
||||
// Blocked patterns
|
||||
{
|
||||
name: "block parent traversal edit",
|
||||
command: []string{"--edit", "../evil"},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot contain '..'",
|
||||
},
|
||||
{
|
||||
name: "block absolute path edit",
|
||||
command: []string{"--edit", "/etc/passwd"},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot be an absolute path",
|
||||
},
|
||||
{
|
||||
name: "block home dir edit",
|
||||
command: []string{"--edit", "~/.ssh/config"},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot start with '~'",
|
||||
},
|
||||
{
|
||||
name: "block parent traversal remove",
|
||||
command: []string{"--rm", "../evil"},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot contain '..'",
|
||||
},
|
||||
{
|
||||
name: "block complex traversal",
|
||||
command: []string{"--edit", "foo/../../bar"},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot contain '..'",
|
||||
},
|
||||
{
|
||||
name: "block just dots",
|
||||
command: []string{"--edit", ".."},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot contain '..'",
|
||||
},
|
||||
{
|
||||
name: "block empty name",
|
||||
command: []string{"--edit", ""},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot be empty",
|
||||
},
|
||||
// Allowed patterns
|
||||
{
|
||||
name: "allow simple name",
|
||||
command: []string{"--edit", "docker"},
|
||||
wantFail: false,
|
||||
},
|
||||
{
|
||||
name: "allow nested name",
|
||||
command: []string{"--edit", "lang/go"},
|
||||
wantFail: false,
|
||||
},
|
||||
{
|
||||
name: "block hidden file",
|
||||
command: []string{"--edit", ".gitignore"},
|
||||
wantFail: true,
|
||||
wantMsg: "cannot start with '.'",
|
||||
},
|
||||
{
|
||||
name: "allow current dir",
|
||||
command: []string{"--edit", "./local"},
|
||||
wantFail: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Run tests
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command(binPath, tc.command...)
|
||||
cmd.Env = []string{
|
||||
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configPath),
|
||||
fmt.Sprintf("HOME=%s", testDir),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if tc.wantFail {
|
||||
if err == nil {
|
||||
t.Errorf("Expected failure but command succeeded. Output: %s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "invalid cheatsheet name") {
|
||||
t.Errorf("Expected 'invalid cheatsheet name' error, got: %s", output)
|
||||
}
|
||||
if tc.wantMsg != "" && !strings.Contains(string(output), tc.wantMsg) {
|
||||
t.Errorf("Expected message %q in output, got: %s", tc.wantMsg, output)
|
||||
}
|
||||
} else {
|
||||
// Command might fail for other reasons (e.g., editor not found)
|
||||
// but should NOT fail with "invalid cheatsheet name"
|
||||
if strings.Contains(string(output), "invalid cheatsheet name") {
|
||||
t.Errorf("Command incorrectly blocked. Output: %s", output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathTraversalRealWorld tests with more realistic scenarios
|
||||
func TestPathTraversalRealWorld(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("integration test uses Unix-specific env and tools")
|
||||
}
|
||||
|
||||
// This test ensures our protection works with actual file operations
|
||||
|
||||
// Build cheat
|
||||
binPath := filepath.Join(t.TempDir(), "cheat_test")
|
||||
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
|
||||
t.Fatalf("Failed to build: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
// Create test structure
|
||||
testRoot := t.TempDir()
|
||||
sheetsDir := filepath.Join(testRoot, "cheatsheets")
|
||||
secretDir := filepath.Join(testRoot, "secrets")
|
||||
os.MkdirAll(sheetsDir, 0755)
|
||||
os.MkdirAll(secretDir, 0755)
|
||||
|
||||
// Create a "secret" file that should not be accessible
|
||||
secretFile := filepath.Join(secretDir, "secret.txt")
|
||||
os.WriteFile(secretFile, []byte("SECRET DATA"), 0644)
|
||||
|
||||
// Create config using vim in non-interactive mode
|
||||
config := fmt.Sprintf(`---
|
||||
editor: vim -u NONE -n --cmd "set noswapfile" --cmd "wq"
|
||||
colorize: false
|
||||
pager: cat
|
||||
cheatpaths:
|
||||
- name: personal
|
||||
path: %s
|
||||
readonly: false
|
||||
`, sheetsDir)
|
||||
|
||||
configPath := filepath.Join(testRoot, "config.yml")
|
||||
os.WriteFile(configPath, []byte(config), 0644)
|
||||
|
||||
// Test 1: Try to edit a file outside cheatsheets using traversal
|
||||
cmd := exec.Command(binPath, "--edit", "../secrets/secret")
|
||||
cmd.Env = []string{
|
||||
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configPath),
|
||||
fmt.Sprintf("HOME=%s", testRoot),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err == nil || !strings.Contains(string(output), "invalid cheatsheet name") {
|
||||
t.Errorf("Path traversal was not blocked! Output: %s", output)
|
||||
}
|
||||
|
||||
// Test 2: Verify the secret file is still intact
|
||||
content, _ := os.ReadFile(secretFile)
|
||||
if string(content) != "SECRET DATA" {
|
||||
t.Errorf("Secret file was modified!")
|
||||
}
|
||||
|
||||
// Test 3: Verify no files were created outside sheets directory
|
||||
err = filepath.Walk(testRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() &&
|
||||
path != configPath &&
|
||||
path != secretFile &&
|
||||
!strings.HasPrefix(path, sheetsDir) {
|
||||
t.Errorf("File created outside allowed directory: %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Walk error: %v", err)
|
||||
}
|
||||
}
|
||||
209
cmd/cheat/search_bench_test.go
Normal file
209
cmd/cheat/search_bench_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// BenchmarkSearchCommand benchmarks the actual cheat search command
|
||||
func BenchmarkSearchCommand(b *testing.B) {
|
||||
// Build the cheat binary in .tmp (using absolute path)
|
||||
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get root dir: %v", err)
|
||||
}
|
||||
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
b.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
cheatBin := filepath.Join(tmpDir, "cheat-bench")
|
||||
|
||||
// Clean up the binary when done
|
||||
b.Cleanup(func() {
|
||||
os.Remove(cheatBin)
|
||||
})
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
|
||||
cmd.Dir = rootDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Set up test environment in .tmp
|
||||
configDir := filepath.Join(tmpDir, "config")
|
||||
cheatsheetDir := filepath.Join(configDir, "cheatsheets", "community")
|
||||
|
||||
// Clone community cheatsheets (or reuse if already exists)
|
||||
if _, err := os.Stat(cheatsheetDir); os.IsNotExist(err) {
|
||||
b.Logf("Cloning community cheatsheets to %s...", cheatsheetDir)
|
||||
_, err := git.PlainClone(cheatsheetDir, false, &git.CloneOptions{
|
||||
URL: "https://github.com/cheat/cheatsheets.git",
|
||||
Depth: 1,
|
||||
SingleBranch: true,
|
||||
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
|
||||
Progress: nil,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to clone cheatsheets: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a minimal config file
|
||||
configFile := filepath.Join(configDir, "conf.yml")
|
||||
configContent := fmt.Sprintf(`---
|
||||
cheatpaths:
|
||||
- name: community
|
||||
path: %s
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
`, cheatsheetDir)
|
||||
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
b.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
|
||||
b.Fatalf("Failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Set environment to use our config
|
||||
env := append(os.Environ(),
|
||||
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configFile),
|
||||
)
|
||||
|
||||
// Define test cases
|
||||
testCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"SimpleSearch", []string{"-s", "echo"}},
|
||||
{"RegexSearch", []string{"-r", "-s", "^#.*example"}},
|
||||
{"ColorizedSearch", []string{"-c", "-s", "grep"}},
|
||||
{"ComplexRegex", []string{"-r", "-s", "(git|hg|svn)\\s+(add|commit|push)"}},
|
||||
{"AllCheatpaths", []string{"-a", "-s", "list"}},
|
||||
}
|
||||
|
||||
// Warm up - run once to ensure everything is loaded
|
||||
warmupCmd := exec.Command(cheatBin, "-l")
|
||||
warmupCmd.Env = env
|
||||
warmupCmd.Run()
|
||||
|
||||
// Run benchmarks
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
// Reset timer to exclude setup
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
cmd := exec.Command(cheatBin, tc.args...)
|
||||
cmd.Env = env
|
||||
|
||||
// Capture output to prevent spamming
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Run()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
b.Fatalf("Command failed: %v\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// Report custom metric
|
||||
b.ReportMetric(float64(elapsed.Nanoseconds())/1e6, "ms/op")
|
||||
|
||||
// Ensure we got some results
|
||||
if stdout.Len() == 0 {
|
||||
b.Fatal("No output from search")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkListCommand benchmarks the list command for comparison
|
||||
func BenchmarkListCommand(b *testing.B) {
|
||||
// Build the cheat binary in .tmp (using absolute path)
|
||||
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get root dir: %v", err)
|
||||
}
|
||||
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
b.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
cheatBin := filepath.Join(tmpDir, "cheat-bench")
|
||||
|
||||
// Clean up the binary when done
|
||||
b.Cleanup(func() {
|
||||
os.Remove(cheatBin)
|
||||
})
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
|
||||
cmd.Dir = rootDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
|
||||
}
|
||||
|
||||
// Set up test environment (simplified - reuse if possible)
|
||||
configDir := filepath.Join(tmpDir, "config")
|
||||
cheatsheetDir := filepath.Join(configDir, "cheatsheets", "community")
|
||||
|
||||
// Check if we need to clone
|
||||
if _, err := os.Stat(cheatsheetDir); os.IsNotExist(err) {
|
||||
_, err := git.PlainClone(cheatsheetDir, false, &git.CloneOptions{
|
||||
URL: "https://github.com/cheat/cheatsheets.git",
|
||||
Depth: 1,
|
||||
SingleBranch: true,
|
||||
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
|
||||
Progress: nil,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to clone cheatsheets: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create config
|
||||
configFile := filepath.Join(configDir, "conf.yml")
|
||||
configContent := fmt.Sprintf(`---
|
||||
cheatpaths:
|
||||
- name: community
|
||||
path: %s
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
`, cheatsheetDir)
|
||||
|
||||
os.MkdirAll(configDir, 0755)
|
||||
os.WriteFile(configFile, []byte(configContent), 0644)
|
||||
|
||||
env := append(os.Environ(),
|
||||
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configFile),
|
||||
)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
cmd := exec.Command(cheatBin, "-l")
|
||||
cmd.Env = env
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
b.Fatalf("Command failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package main
|
||||
|
||||
// Code generated .* DO NOT EDIT.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func configs() string {
|
||||
return strings.TrimSpace(`---
|
||||
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
|
||||
editor: EDITOR_PATH
|
||||
|
||||
# Should 'cheat' always colorize output?
|
||||
colorize: false
|
||||
|
||||
# Which 'chroma' colorscheme should be applied to the output?
|
||||
# Options are available here:
|
||||
# https://github.com/alecthomas/chroma/tree/master/styles
|
||||
style: monokai
|
||||
|
||||
# Which 'chroma' "formatter" should be applied?
|
||||
# One of: "terminal", "terminal256", "terminal16m"
|
||||
formatter: terminal256
|
||||
|
||||
# Through which pager should output be piped?
|
||||
# 'less -FRX' is recommended on Unix systems
|
||||
# 'more' is recommended on Windows
|
||||
pager: PAGER_PATH
|
||||
|
||||
# Cheatpaths are paths at which cheatsheets are available on your local
|
||||
# filesystem.
|
||||
#
|
||||
# It is useful to sort cheatsheets into different cheatpaths for organizational
|
||||
# purposes. For example, you might want one cheatpath for community
|
||||
# cheatsheets, one for personal cheatsheets, one for cheatsheets pertaining to
|
||||
# your day job, one for code snippets, etc.
|
||||
#
|
||||
# Cheatpaths are scoped, such that more "local" cheatpaths take priority over
|
||||
# more "global" cheatpaths. (The most global cheatpath is listed first in this
|
||||
# file; the most local is listed last.) For example, if there is a 'tar'
|
||||
# cheatsheet on both global and local paths, you'll be presented with the local
|
||||
# one by default. ('cheat -p' can be used to view cheatsheets from alternative
|
||||
# cheatpaths.)
|
||||
#
|
||||
# Cheatpaths can also be tagged as "read only". This instructs cheat not to
|
||||
# automatically create cheatsheets on a read-only cheatpath. Instead, when you
|
||||
# would like to edit a read-only cheatsheet using 'cheat -e', cheat will
|
||||
# perform a copy-on-write of that cheatsheet from a read-only cheatpath to a
|
||||
# writeable cheatpath.
|
||||
#
|
||||
# This is very useful when you would like to maintain, for example, a
|
||||
# "pristine" repository of community cheatsheets on one cheatpath, and an
|
||||
# editable personal reponsity of cheatsheets on another cheatpath.
|
||||
#
|
||||
# Cheatpaths can be also configured to automatically apply tags to cheatsheets
|
||||
# on certain paths, which can be useful for querying purposes.
|
||||
# Example: 'cheat -t work jenkins'.
|
||||
#
|
||||
# Community cheatsheets must be installed separately, though you may have
|
||||
# downloaded them automatically when installing 'cheat'. If not, you may
|
||||
# download them here:
|
||||
#
|
||||
# https://github.com/cheat/cheatsheets
|
||||
cheatpaths:
|
||||
# Cheatpath properties mean the following:
|
||||
# 'name': the name of the cheatpath (view with 'cheat -d', filter with 'cheat -p')
|
||||
# 'path': the filesystem path of the cheatsheet directory (view with 'cheat -d')
|
||||
# 'tags': tags that should be automatically applied to sheets on this path
|
||||
# 'readonly': shall user-created ('cheat -e') cheatsheets be saved here?
|
||||
- name: community
|
||||
path: COMMUNITY_PATH
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
|
||||
# If you have personalized cheatsheets, list them last. They will take
|
||||
# precedence over the more global cheatsheets.
|
||||
- name: personal
|
||||
path: PERSONAL_PATH
|
||||
tags: [ personal ]
|
||||
readonly: false
|
||||
|
||||
# While it requires no configuration here, it's also worth noting that
|
||||
# cheat will automatically append directories named '.cheat' within the
|
||||
# current working directory to the 'cheatpath'. This can be very useful if
|
||||
# you'd like to closely associate cheatsheets with, for example, a directory
|
||||
# containing source code.
|
||||
#
|
||||
# Such "directory-scoped" cheatsheets will be treated as the most "local"
|
||||
# cheatsheets, and will override less "local" cheatsheets. Similarly,
|
||||
# directory-scoped cheatsheets will always be editable ('readonly: false').
|
||||
`)
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
package main
|
||||
|
||||
// Code generated .* DO NOT EDIT.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// usage returns the usage text for the cheat command
|
||||
func usage() string {
|
||||
return strings.TrimSpace(`Usage:
|
||||
return `Usage:
|
||||
cheat [options] [<cheatsheet>]
|
||||
|
||||
Options:
|
||||
@@ -65,6 +60,5 @@ Examples:
|
||||
cheat --rm foo/bar
|
||||
|
||||
To view the configuration file path:
|
||||
cheat --conf
|
||||
`)
|
||||
cheat --conf`
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
|
||||
editor: EDITOR_PATH
|
||||
|
||||
# Should 'cheat' always colorize output?
|
||||
colorize: false
|
||||
|
||||
# Which 'chroma' colorscheme should be applied to the output?
|
||||
# Options are available here:
|
||||
# https://github.com/alecthomas/chroma/tree/master/styles
|
||||
style: monokai
|
||||
|
||||
# Which 'chroma' "formatter" should be applied?
|
||||
# One of: "terminal", "terminal256", "terminal16m"
|
||||
formatter: terminal256
|
||||
|
||||
# Through which pager should output be piped?
|
||||
# 'less -FRX' is recommended on Unix systems
|
||||
# 'more' is recommended on Windows
|
||||
pager: PAGER_PATH
|
||||
|
||||
# Cheatpaths are paths at which cheatsheets are available on your local
|
||||
# filesystem.
|
||||
#
|
||||
# It is useful to sort cheatsheets into different cheatpaths for organizational
|
||||
# purposes. For example, you might want one cheatpath for community
|
||||
# cheatsheets, one for personal cheatsheets, one for cheatsheets pertaining to
|
||||
# your day job, one for code snippets, etc.
|
||||
#
|
||||
# Cheatpaths are scoped, such that more "local" cheatpaths take priority over
|
||||
# more "global" cheatpaths. (The most global cheatpath is listed first in this
|
||||
# file; the most local is listed last.) For example, if there is a 'tar'
|
||||
# cheatsheet on both global and local paths, you'll be presented with the local
|
||||
# one by default. ('cheat -p' can be used to view cheatsheets from alternative
|
||||
# cheatpaths.)
|
||||
#
|
||||
# Cheatpaths can also be tagged as "read only". This instructs cheat not to
|
||||
# automatically create cheatsheets on a read-only cheatpath. Instead, when you
|
||||
# would like to edit a read-only cheatsheet using 'cheat -e', cheat will
|
||||
# perform a copy-on-write of that cheatsheet from a read-only cheatpath to a
|
||||
# writeable cheatpath.
|
||||
#
|
||||
# This is very useful when you would like to maintain, for example, a
|
||||
# "pristine" repository of community cheatsheets on one cheatpath, and an
|
||||
# editable personal reponsity of cheatsheets on another cheatpath.
|
||||
#
|
||||
# Cheatpaths can be also configured to automatically apply tags to cheatsheets
|
||||
# on certain paths, which can be useful for querying purposes.
|
||||
# Example: 'cheat -t work jenkins'.
|
||||
#
|
||||
# Community cheatsheets must be installed separately, though you may have
|
||||
# downloaded them automatically when installing 'cheat'. If not, you may
|
||||
# download them here:
|
||||
#
|
||||
# https://github.com/cheat/cheatsheets
|
||||
cheatpaths:
|
||||
# Cheatpath properties mean the following:
|
||||
# 'name': the name of the cheatpath (view with 'cheat -d', filter with 'cheat -p')
|
||||
# 'path': the filesystem path of the cheatsheet directory (view with 'cheat -d')
|
||||
# 'tags': tags that should be automatically applied to sheets on this path
|
||||
# 'readonly': shall user-created ('cheat -e') cheatsheets be saved here?
|
||||
- name: community
|
||||
path: COMMUNITY_PATH
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
|
||||
# If you have personalized cheatsheets, list them last. They will take
|
||||
# precedence over the more global cheatsheets.
|
||||
- name: personal
|
||||
path: PERSONAL_PATH
|
||||
tags: [ personal ]
|
||||
readonly: false
|
||||
|
||||
# While it requires no configuration here, it's also worth noting that
|
||||
# cheat will automatically append directories named '.cheat' within the
|
||||
# current working directory to the 'cheatpath'. This can be very useful if
|
||||
# you'd like to closely associate cheatsheets with, for example, a directory
|
||||
# containing source code.
|
||||
#
|
||||
# Such "directory-scoped" cheatsheets will be treated as the most "local"
|
||||
# cheatsheets, and will override less "local" cheatsheets. Similarly,
|
||||
# directory-scoped cheatsheets will always be editable ('readonly: false').
|
||||
169
doc/adr/001-path-traversal-protection.md
Normal file
169
doc/adr/001-path-traversal-protection.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# ADR-001: Path Traversal Protection for Cheatsheet Names
|
||||
|
||||
Date: 2025-01-21
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The `cheat` tool allows users to create, edit, and remove cheatsheets using commands like:
|
||||
- `cheat --edit <name>`
|
||||
- `cheat --rm <name>`
|
||||
|
||||
Without validation, a user could potentially provide malicious names like:
|
||||
- `../../../etc/passwd` (directory traversal)
|
||||
- `/etc/passwd` (absolute path)
|
||||
- `~/.ssh/authorized_keys` (home directory expansion)
|
||||
|
||||
While `cheat` is a local tool run by the user themselves (not a network service), path traversal could still lead to:
|
||||
1. Accidental file overwrites outside cheatsheet directories
|
||||
2. Confusion about where files are being created
|
||||
3. Potential security issues in shared environments
|
||||
|
||||
## Decision
|
||||
|
||||
We implemented input validation for cheatsheet names to prevent directory traversal attacks. The validation rejects names that:
|
||||
|
||||
1. Contain `..` (parent directory references)
|
||||
2. Are absolute paths (start with `/` on Unix)
|
||||
3. Start with `~` (home directory expansion)
|
||||
4. Are empty
|
||||
5. Start with `.` (hidden files - these are not displayed by cheat)
|
||||
|
||||
The validation is performed at the application layer before any file operations occur.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Validation Function
|
||||
|
||||
The validation is implemented in `internal/cheatpath/validate.go`:
|
||||
|
||||
```go
|
||||
func ValidateSheetName(name string) error {
|
||||
// Reject empty names
|
||||
if name == "" {
|
||||
return fmt.Errorf("cheatsheet name cannot be empty")
|
||||
}
|
||||
|
||||
// Reject names containing directory traversal
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("cheatsheet name cannot contain '..'")
|
||||
}
|
||||
|
||||
// Reject absolute paths
|
||||
if filepath.IsAbs(name) {
|
||||
return fmt.Errorf("cheatsheet name cannot be an absolute path")
|
||||
}
|
||||
|
||||
// Reject names that start with ~ (home directory expansion)
|
||||
if strings.HasPrefix(name, "~") {
|
||||
return fmt.Errorf("cheatsheet name cannot start with '~'")
|
||||
}
|
||||
|
||||
// Reject hidden files (files that start with a dot)
|
||||
filename := filepath.Base(name)
|
||||
if strings.HasPrefix(filename, ".") {
|
||||
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
The validation is called in:
|
||||
- `cmd/cheat/cmd_edit.go` - before creating or editing a cheatsheet
|
||||
- `cmd/cheat/cmd_remove.go` - before removing a cheatsheet
|
||||
|
||||
### Allowed Patterns
|
||||
|
||||
The following patterns are explicitly allowed:
|
||||
- Simple names: `docker`, `git`
|
||||
- Nested paths: `docker/compose`, `lang/go/slice`
|
||||
- Current directory references: `./mysheet`
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Safety**: Prevents accidental or intentional file operations outside cheatsheet directories
|
||||
2. **Simplicity**: Validation happens early, before any file operations
|
||||
3. **User-friendly**: Clear error messages explain why a name was rejected
|
||||
4. **Performance**: Minimal overhead - simple string checks
|
||||
5. **Compatibility**: Doesn't break existing valid cheatsheet names
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Limitation**: Users cannot use `..` in cheatsheet names even if legitimate
|
||||
2. **No symlink support**: Cannot create cheatsheets through symlinks outside the cheatpath
|
||||
|
||||
### Neutral
|
||||
|
||||
1. Uses Go's `filepath.IsAbs()` which handles platform differences (Windows vs Unix)
|
||||
2. No attempt to resolve or canonicalize paths - validation is purely syntactic
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
`cheat` is a local command-line tool, not a network service. The primary threats are:
|
||||
- User error (accidentally overwriting important files)
|
||||
- Malicious scripts that invoke `cheat` with crafted arguments
|
||||
- Shared system scenarios where cheatsheets might be shared
|
||||
|
||||
### What This Protects Against
|
||||
|
||||
- Directory traversal using `../`
|
||||
- Absolute path access to system files
|
||||
- Shell expansion of `~` to home directory
|
||||
- Empty names that might cause unexpected behavior
|
||||
- Hidden files that wouldn't be displayed anyway
|
||||
|
||||
### What This Does NOT Protect Against
|
||||
|
||||
- Users with filesystem permissions can still directly edit any file
|
||||
- Symbolic links within the cheatpath pointing outside
|
||||
- Race conditions (TOCTOU) - though minimal risk for a local tool
|
||||
- Malicious content within cheatsheets themselves
|
||||
|
||||
## Testing
|
||||
|
||||
Comprehensive tests ensure the validation works correctly:
|
||||
|
||||
1. **Unit tests** (`internal/cheatpath/validate_test.go`) verify the validation logic
|
||||
2. **Integration tests** verify the actual binary blocks malicious inputs
|
||||
3. **No system files are accessed** during testing - all tests use isolated directories
|
||||
|
||||
Example test cases:
|
||||
```bash
|
||||
# These are blocked:
|
||||
cheat --edit "../../../etc/passwd"
|
||||
cheat --edit "/etc/passwd"
|
||||
cheat --edit "~/.ssh/config"
|
||||
cheat --rm ".."
|
||||
|
||||
# These are allowed:
|
||||
cheat --edit "docker"
|
||||
cheat --edit "docker/compose"
|
||||
cheat --edit "./local"
|
||||
```
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
1. **Path resolution and verification**: Resolve the final path and check if it's within the cheatpath
|
||||
- Rejected: More complex, potential race conditions, platform-specific edge cases
|
||||
|
||||
2. **Chroot/sandbox**: Run file operations in a restricted environment
|
||||
- Rejected: Overkill for a local tool, platform compatibility issues
|
||||
|
||||
3. **Filename allowlist**: Only allow alphanumeric characters and specific symbols
|
||||
- Rejected: Too restrictive, would break existing cheatsheets with valid special characters
|
||||
|
||||
## References
|
||||
|
||||
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
|
||||
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
|
||||
- Go filepath package documentation: https://pkg.go.dev/path/filepath
|
||||
100
doc/adr/002-environment-variable-parsing.md
Normal file
100
doc/adr/002-environment-variable-parsing.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# ADR-002: No Defensive Checks for Environment Variable Parsing
|
||||
|
||||
Date: 2025-01-21
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
In `cmd/cheat/main.go` lines 47-52, the code parses environment variables assuming they all contain an equals sign:
|
||||
|
||||
```go
|
||||
for _, e := range os.Environ() {
|
||||
pair := strings.SplitN(e, "=", 2)
|
||||
if runtime.GOOS == "windows" {
|
||||
pair[0] = strings.ToUpper(pair[0])
|
||||
}
|
||||
envvars[pair[0]] = pair[1] // Could panic if pair has < 2 elements
|
||||
}
|
||||
```
|
||||
|
||||
If `os.Environ()` returned a string without an equals sign, `strings.SplitN` would return a slice with only one element, causing a panic when accessing `pair[1]`.
|
||||
|
||||
## Decision
|
||||
|
||||
We will **not** add defensive checks for this condition. The current code that assumes all environment strings contain "=" will remain unchanged.
|
||||
|
||||
## Rationale
|
||||
|
||||
### Go Runtime Guarantees
|
||||
|
||||
Go's official documentation guarantees that `os.Environ()` returns environment variables in the form "key=value". This is a documented contract of the Go runtime that has been stable since Go 1.0.
|
||||
|
||||
### Empirical Evidence
|
||||
|
||||
Testing across platforms confirms:
|
||||
- All environment variables returned by `os.Environ()` contain at least one "="
|
||||
- Empty environment variables appear as "KEY=" (with an empty value)
|
||||
- Even Windows special variables like "=C:=C:\path" maintain the format
|
||||
|
||||
### Cost-Benefit Analysis
|
||||
|
||||
Adding defensive code would:
|
||||
- **Cost**: Add complexity and cognitive overhead
|
||||
- **Cost**: Suggest uncertainty about Go's documented behavior
|
||||
- **Cost**: Create dead code that can never execute under normal conditions
|
||||
- **Benefit**: Protect against a theoretical scenario that violates Go's guarantees
|
||||
|
||||
The only scenarios where this could panic are:
|
||||
1. A bug in Go's runtime (extremely unlikely, would affect all Go programs)
|
||||
2. Corrupted OS-level environment (would cause broader system issues)
|
||||
3. Breaking change in future Go version (would break many programs, unlikely)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Simpler, more readable code
|
||||
- Trust in platform guarantees reduces unnecessary defensive programming
|
||||
- No performance overhead from unnecessary checks
|
||||
|
||||
### Negative
|
||||
- Theoretical panic if Go's guarantees are violated
|
||||
|
||||
### Neutral
|
||||
- Follows Go community standards of trusting standard library contracts
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Add Defensive Check
|
||||
```go
|
||||
if len(pair) < 2 {
|
||||
continue // or pair[1] = ""
|
||||
}
|
||||
```
|
||||
**Rejected**: Adds complexity for a condition that should never occur.
|
||||
|
||||
### 2. Add Panic with Clear Message
|
||||
```go
|
||||
if len(pair) < 2 {
|
||||
panic("os.Environ() contract violation: " + e)
|
||||
}
|
||||
```
|
||||
**Rejected**: Would crash the program for the same theoretical issue.
|
||||
|
||||
### 3. Add Comment Documenting Assumption
|
||||
```go
|
||||
// os.Environ() guarantees "key=value" format, so pair[1] is safe
|
||||
envvars[pair[0]] = pair[1]
|
||||
```
|
||||
**Rejected**: While documentation is good, this particular guarantee is fundamental to Go.
|
||||
|
||||
## Notes
|
||||
|
||||
If Go ever changes this behavior (extremely unlikely as it would break compatibility), it would be caught immediately in testing as the program would panic on startup. This would be a clear signal to revisit this decision.
|
||||
|
||||
## References
|
||||
|
||||
- Go os.Environ() documentation: https://pkg.go.dev/os#Environ
|
||||
- Go os.Environ() source code and tests
|
||||
104
doc/adr/003-search-parallelization.md
Normal file
104
doc/adr/003-search-parallelization.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# ADR-003: No Parallelization for Search Operations
|
||||
|
||||
Date: 2025-01-22
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
We investigated optimizing cheat's search performance through parallelization. Initial assumptions suggested that I/O operations (reading multiple cheatsheet files) would be the primary bottleneck, making parallel processing beneficial.
|
||||
|
||||
Performance benchmarks were implemented to measure search operations, and a parallel search implementation using goroutines was created and tested.
|
||||
|
||||
## Decision
|
||||
|
||||
We will **not** implement parallel search. The sequential implementation will remain unchanged.
|
||||
|
||||
## Rationale
|
||||
|
||||
### Performance Profile Analysis
|
||||
|
||||
CPU profiling revealed that search performance is dominated by:
|
||||
- **Process creation overhead** (~30% in `os/exec.(*Cmd).Run`)
|
||||
- **System calls** (~30% in `syscall.Syscall6`)
|
||||
- **Process management** (fork, exec, pipe setup)
|
||||
|
||||
The actual search logic (regex matching, file reading) was negligible in the profile, indicating our optimization efforts were targeting the wrong bottleneck.
|
||||
|
||||
### Benchmark Results
|
||||
|
||||
Parallel implementation showed minimal improvements:
|
||||
- Simple search: 17ms → 15.3ms (10% improvement)
|
||||
- Regex search: 15ms → 14.9ms (minimal improvement)
|
||||
- Colorized search: 19.5ms → 16.8ms (14% improvement)
|
||||
- Complex regex: 20ms → 15.3ms (24% improvement)
|
||||
|
||||
The best case saved only ~5ms in absolute terms.
|
||||
|
||||
### Cost-Benefit Analysis
|
||||
|
||||
**Costs of parallelization:**
|
||||
- Added complexity with goroutines, channels, and synchronization
|
||||
- Increased maintenance burden
|
||||
- More difficult debugging and testing
|
||||
- Potential race conditions
|
||||
|
||||
**Benefits:**
|
||||
- 5-15% performance improvement (5ms in real terms)
|
||||
- Imperceptible to users in interactive use
|
||||
|
||||
### User Experience Perspective
|
||||
|
||||
For a command-line tool:
|
||||
- Current 15-20ms response time is excellent
|
||||
- Users cannot perceive 5ms differences
|
||||
- Sub-50ms is considered "instant" in HCI research
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Simpler, more maintainable codebase
|
||||
- Easier to debug and reason about
|
||||
- No synchronization bugs or race conditions
|
||||
- Focus remains on code clarity
|
||||
|
||||
### Negative
|
||||
- Missed opportunity for ~5ms performance gain
|
||||
- Search remains single-threaded
|
||||
|
||||
### Neutral
|
||||
- Performance remains excellent for intended use case
|
||||
- Follows Go philosophy of preferring simplicity
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Keep Parallel Implementation
|
||||
**Rejected**: Complexity outweighs negligible performance gains.
|
||||
|
||||
### 2. Optimize Process Startup
|
||||
**Rejected**: Process creation overhead is inherent to CLI tools and cannot be avoided without fundamental architecture changes.
|
||||
|
||||
### 3. Future Optimizations
|
||||
If performance becomes critical, consider:
|
||||
- **Long-running daemon**: Eliminate process startup overhead entirely
|
||||
- **Shell function**: Reduce fork/exec overhead
|
||||
- **Compiled-in cheatsheets**: Eliminate file I/O
|
||||
|
||||
However, these would fundamentally change the tool's architecture and usage model.
|
||||
|
||||
## Notes
|
||||
|
||||
This decision reinforces important principles:
|
||||
1. Always profile before optimizing
|
||||
2. Consider the full execution context
|
||||
3. Measure what matters to users
|
||||
4. Complexity has a real cost
|
||||
|
||||
The parallelization attempt was valuable as a learning exercise and definitively answered whether this optimization path was worthwhile.
|
||||
|
||||
## References
|
||||
|
||||
- Benchmark implementation: cmd/cheat/search_bench_test.go
|
||||
- Reverted parallel implementation: see git history (commit 82eb918)
|
||||
38
doc/adr/README.md
Normal file
38
doc/adr/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This directory contains Architecture Decision Records (ADRs) for the cheat project.
|
||||
|
||||
## What is an ADR?
|
||||
|
||||
An Architecture Decision Record captures an important architectural decision made along with its context and consequences. ADRs help us:
|
||||
|
||||
- Document why decisions were made
|
||||
- Understand the context and trade-offs
|
||||
- Review decisions when requirements change
|
||||
- Onboard new contributors
|
||||
|
||||
## ADR Format
|
||||
|
||||
Each ADR follows this template:
|
||||
|
||||
1. **Title**: ADR-NNN: Brief description
|
||||
2. **Date**: When the decision was made
|
||||
3. **Status**: Proposed, Accepted, Deprecated, Superseded
|
||||
4. **Context**: What prompted this decision?
|
||||
5. **Decision**: What did we decide to do?
|
||||
6. **Consequences**: What are the positive, negative, and neutral outcomes?
|
||||
|
||||
## Index of ADRs
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [001](001-path-traversal-protection.md) | Path Traversal Protection for Cheatsheet Names | Accepted | 2025-01-21 |
|
||||
| [002](002-environment-variable-parsing.md) | No Defensive Checks for Environment Variable Parsing | Accepted | 2025-01-21 |
|
||||
| [003](003-search-parallelization.md) | No Parallelization for Search Operations | Accepted | 2025-01-22 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
1. Copy the template from an existing ADR
|
||||
2. Use the next sequential number
|
||||
3. Fill in all sections
|
||||
4. Include the ADR alongside the commit implementing the decision
|
||||
233
doc/cheat.1
233
doc/cheat.1
@@ -1,208 +1,168 @@
|
||||
.\" Automatically generated by Pandoc 2.2.1
|
||||
.\" Automatically generated by Pandoc 3.1.11.1
|
||||
.\"
|
||||
.TH "CHEAT" "1" "" "" "General Commands Manual"
|
||||
.hy
|
||||
.SH NAME
|
||||
.PP
|
||||
\f[B]cheat\f[] \[em] create and view command\-line cheatsheets
|
||||
\f[B]cheat\f[R] \[em] create and view command\-line cheatsheets
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\f[B]cheat\f[] [options] [\f[I]CHEATSHEET\f[]]
|
||||
\f[B]cheat\f[R] [options] [\f[I]CHEATSHEET\f[R]]
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
\f[B]cheat\f[] allows you to create and view interactive cheatsheets on
|
||||
\f[B]cheat\f[R] allows you to create and view interactive cheatsheets on
|
||||
the command\-line.
|
||||
It was designed to help remind *nix system administrators of options for
|
||||
commands that they use frequently, but not frequently enough to
|
||||
remember.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \[en]init
|
||||
\[en]init
|
||||
Print a config file to stdout.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \-c, \[en]colorize
|
||||
\[en]conf
|
||||
Display the config file path.
|
||||
.TP
|
||||
\-a, \[en]all
|
||||
Search among all cheatpaths.
|
||||
.TP
|
||||
\-c, \[en]colorize
|
||||
Colorize output.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \-d, \[en]directories
|
||||
\-d, \[en]directories
|
||||
List cheatsheet directories.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \-e, \[en]edit=\f[I]CHEATSHEET\f[]
|
||||
Open \f[I]CHEATSHEET\f[] for editing.
|
||||
.RS
|
||||
.RE
|
||||
\-e, \[en]edit=\f[I]CHEATSHEET\f[R]
|
||||
Open \f[I]CHEATSHEET\f[R] for editing.
|
||||
.TP
|
||||
.B \-l, \[en]list
|
||||
\-l, \[en]list
|
||||
List available cheatsheets.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \-p, \[en]path=\f[I]PATH\f[]
|
||||
Filter only to sheets found on path \f[I]PATH\f[].
|
||||
.RS
|
||||
.RE
|
||||
\-p, \[en]path=\f[I]PATH\f[R]
|
||||
Filter only to sheets found on path \f[I]PATH\f[R].
|
||||
.TP
|
||||
.B \-r, \[en]regex
|
||||
Treat search \f[I]PHRASE\f[] as a regular expression.
|
||||
.RS
|
||||
.RE
|
||||
\-r, \[en]regex
|
||||
Treat search \f[I]PHRASE\f[R] as a regular expression.
|
||||
.TP
|
||||
.B \-s, \[en]search=\f[I]PHRASE\f[]
|
||||
Search cheatsheets for \f[I]PHRASE\f[].
|
||||
.RS
|
||||
.RE
|
||||
\-s, \[en]search=\f[I]PHRASE\f[R]
|
||||
Search cheatsheets for \f[I]PHRASE\f[R].
|
||||
.TP
|
||||
.B \-t, \[en]tag=\f[I]TAG\f[]
|
||||
Filter only to sheets tagged with \f[I]TAG\f[].
|
||||
.RS
|
||||
.RE
|
||||
\-t, \[en]tag=\f[I]TAG\f[R]
|
||||
Filter only to sheets tagged with \f[I]TAG\f[R].
|
||||
.TP
|
||||
.B \-T, \[en]tags
|
||||
\-T, \[en]tags
|
||||
List all tags in use.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \-v, \[en]version
|
||||
\-v, \[en]version
|
||||
Print the version number.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \[en]rm=\f[I]CHEATSHEET\f[]
|
||||
Remove (deletes) \f[I]CHEATSHEET\f[].
|
||||
.RS
|
||||
.RE
|
||||
\[en]rm=\f[I]CHEATSHEET\f[R]
|
||||
Remove (deletes) \f[I]CHEATSHEET\f[R].
|
||||
.SH EXAMPLES
|
||||
.TP
|
||||
.B To view the foo cheatsheet:
|
||||
cheat \f[I]foo\f[]
|
||||
.RS
|
||||
.RE
|
||||
To view the foo cheatsheet:
|
||||
cheat \f[I]foo\f[R]
|
||||
.TP
|
||||
.B To edit (or create) the foo cheatsheet:
|
||||
cheat \-e \f[I]foo\f[]
|
||||
.RS
|
||||
.RE
|
||||
To edit (or create) the foo cheatsheet:
|
||||
cheat \-e \f[I]foo\f[R]
|
||||
.TP
|
||||
.B To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
|
||||
cheat \-p \f[I]work\f[] \-e \f[I]foo/bar\f[]
|
||||
.RS
|
||||
.RE
|
||||
To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
|
||||
cheat \-p \f[I]work\f[R] \-e \f[I]foo/bar\f[R]
|
||||
.TP
|
||||
.B To view all cheatsheet directories:
|
||||
To view all cheatsheet directories:
|
||||
cheat \-d
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B To list all available cheatsheets:
|
||||
To list all available cheatsheets:
|
||||
cheat \-l
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B To list all cheatsheets whose titles match `apt':
|
||||
cheat \-l \f[I]apt\f[]
|
||||
.RS
|
||||
.RE
|
||||
To list all cheatsheets whose titles match `apt':
|
||||
cheat \-l \f[I]apt\f[R]
|
||||
.TP
|
||||
.B To list all tags in use:
|
||||
To list all tags in use:
|
||||
cheat \-T
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B To list available cheatsheets that are tagged as `personal':
|
||||
cheat \-l \-t \f[I]personal\f[]
|
||||
.RS
|
||||
.RE
|
||||
To list available cheatsheets that are tagged as `personal':
|
||||
cheat \-l \-t \f[I]personal\f[R]
|
||||
.TP
|
||||
.B To search for `ssh' among all cheatsheets, and colorize matches:
|
||||
cheat \-c \-s \f[I]ssh\f[]
|
||||
.RS
|
||||
.RE
|
||||
To search for `ssh' among all cheatsheets, and colorize matches:
|
||||
cheat \-c \-s \f[I]ssh\f[R]
|
||||
.TP
|
||||
.B To search (by regex) for cheatsheets that contain an IP address:
|
||||
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[]
|
||||
.RS
|
||||
.RE
|
||||
To search (by regex) for cheatsheets that contain an IP address:
|
||||
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[R]
|
||||
.TP
|
||||
.B To remove (delete) the foo/bar cheatsheet:
|
||||
cheat \[en]rm \f[I]foo/bar\f[]
|
||||
.RS
|
||||
.RE
|
||||
To remove (delete) the foo/bar cheatsheet:
|
||||
cheat \[en]rm \f[I]foo/bar\f[R]
|
||||
.TP
|
||||
To view the configuration file path:
|
||||
cheat \[en]conf
|
||||
.SH FILES
|
||||
.SS Configuration
|
||||
.PP
|
||||
\f[B]cheat\f[] is configured via a YAML file that is conventionally
|
||||
named \f[I]conf.yaml\f[].
|
||||
\f[B]cheat\f[] will search for \f[I]conf.yaml\f[] in varying locations,
|
||||
depending upon your platform:
|
||||
\f[B]cheat\f[R] is configured via a YAML file that is conventionally
|
||||
named \f[I]conf.yaml\f[R].
|
||||
\f[B]cheat\f[R] will search for \f[I]conf.yaml\f[R] in varying
|
||||
locations, depending upon your platform:
|
||||
.SS Linux, OSX, and other Unixes
|
||||
.IP "1." 3
|
||||
\f[B]CHEAT_CONFIG_PATH\f[]
|
||||
\f[B]CHEAT_CONFIG_PATH\f[R]
|
||||
.IP "2." 3
|
||||
\f[B]XDG_CONFIG_HOME\f[]/cheat/conf.yaml
|
||||
\f[B]XDG_CONFIG_HOME\f[R]/cheat/conf.yaml
|
||||
.IP "3." 3
|
||||
\f[B]$HOME\f[]/.config/cheat/conf.yml
|
||||
\f[B]$HOME\f[R]/.config/cheat/conf.yml
|
||||
.IP "4." 3
|
||||
\f[B]$HOME\f[]/.cheat/conf.yml
|
||||
\f[B]$HOME\f[R]/.cheat/conf.yml
|
||||
.SS Windows
|
||||
.IP "1." 3
|
||||
\f[B]CHEAT_CONFIG_PATH\f[]
|
||||
\f[B]CHEAT_CONFIG_PATH\f[R]
|
||||
.IP "2." 3
|
||||
\f[B]APPDATA\f[]/cheat/conf.yml
|
||||
\f[B]APPDATA\f[R]/cheat/conf.yml
|
||||
.IP "3." 3
|
||||
\f[B]PROGRAMDATA\f[]/cheat/conf.yml
|
||||
\f[B]PROGRAMDATA\f[R]/cheat/conf.yml
|
||||
.PP
|
||||
\f[B]cheat\f[] will search in the order specified above.
|
||||
The first \f[I]conf.yaml\f[] encountered will be respected.
|
||||
\f[B]cheat\f[R] will search in the order specified above.
|
||||
The first \f[I]conf.yaml\f[R] encountered will be respected.
|
||||
.PP
|
||||
If \f[B]cheat\f[] cannot locate a config file, it will ask if you'd like
|
||||
to generate one automatically.
|
||||
If \f[B]cheat\f[R] cannot locate a config file, it will ask if you\[cq]d
|
||||
like to generate one automatically.
|
||||
Alternatively, you may also generate a config file manually by running
|
||||
\f[B]cheat \[en]init\f[] and saving its output to the appropriate
|
||||
\f[B]cheat \[en]init\f[R] and saving its output to the appropriate
|
||||
location for your platform.
|
||||
.SS Cheatpaths
|
||||
.PP
|
||||
\f[B]cheat\f[] reads its cheatsheets from \[lq]cheatpaths\[rq], which
|
||||
\f[B]cheat\f[R] reads its cheatsheets from \[lq]cheatpaths\[rq], which
|
||||
are the directories in which cheatsheets are stored.
|
||||
Cheatpaths may be configured in \f[I]conf.yaml\f[], and viewed via
|
||||
\f[B]cheat \-d\f[].
|
||||
Cheatpaths may be configured in \f[I]conf.yaml\f[R], and viewed via
|
||||
\f[B]cheat \-d\f[R].
|
||||
.PP
|
||||
For detailed instructions on how to configure cheatpaths, please refer
|
||||
to the comments in conf.yml.
|
||||
.SS Autocompletion
|
||||
Autocompletion scripts for \f[B]bash\f[R], \f[B]zsh\f[R], and
|
||||
\f[B]fish\f[R] are available for download:
|
||||
.IP \[bu] 2
|
||||
\c
|
||||
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.bash
|
||||
.UE \c
|
||||
.IP \[bu] 2
|
||||
\c
|
||||
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.fish
|
||||
.UE \c
|
||||
.IP \[bu] 2
|
||||
\c
|
||||
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh
|
||||
.UE \c
|
||||
.PP
|
||||
Autocompletion scripts for \f[B]bash\f[], \f[B]zsh\f[], and
|
||||
\f[B]fish\f[] are available for download:
|
||||
.IP \[bu] 2
|
||||
<https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
|
||||
.IP \[bu] 2
|
||||
<https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
|
||||
.IP \[bu] 2
|
||||
<https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
|
||||
.PP
|
||||
The \f[B]bash\f[] and \f[B]zsh\f[] scripts provide optional integration
|
||||
with \f[B]fzf\f[], if the latter is available on your \f[B]PATH\f[].
|
||||
The \f[B]bash\f[R] and \f[B]zsh\f[R] scripts provide optional
|
||||
integration with \f[B]fzf\f[R], if the latter is available on your
|
||||
\f[B]PATH\f[R].
|
||||
.PP
|
||||
The installation process will vary per system and shell configuration,
|
||||
and thus will not be discussed here.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B \f[B]CHEAT_CONFIG_PATH\f[]
|
||||
\f[B]CHEAT_CONFIG_PATH\f[R]
|
||||
The path at which the config file is available.
|
||||
If \f[B]CHEAT_CONFIG_PATH\f[] is set, all other config paths will be
|
||||
If \f[B]CHEAT_CONFIG_PATH\f[R] is set, all other config paths will be
|
||||
ignored.
|
||||
.RS
|
||||
.RE
|
||||
.TP
|
||||
.B \f[B]CHEAT_USE_FZF\f[]
|
||||
\f[B]CHEAT_USE_FZF\f[R]
|
||||
If set, autocompletion scripts will attempt to integrate with
|
||||
\f[B]fzf\f[].
|
||||
.RS
|
||||
.RE
|
||||
\f[B]fzf\f[R].
|
||||
.SH RETURN VALUES
|
||||
.IP "0." 3
|
||||
Successful termination
|
||||
@@ -211,11 +171,12 @@ Application error
|
||||
.IP "2." 3
|
||||
Cheatsheet(s) not found
|
||||
.SH BUGS
|
||||
.PP
|
||||
See GitHub issues: <https://github.com/cheat/cheat/issues>
|
||||
See GitHub issues: \c
|
||||
.UR https://github.com/cheat/cheat/issues
|
||||
.UE \c
|
||||
.SH AUTHOR
|
||||
.PP
|
||||
Christopher Allen Lane <chris@chris-allen-lane.com>
|
||||
Christopher Allen Lane \c
|
||||
.MT chris@chris-allen-lane.com
|
||||
.ME \c
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\f[B]fzf(1)\f[]
|
||||
\f[B]fzf(1)\f[R]
|
||||
|
||||
@@ -23,6 +23,12 @@ OPTIONS
|
||||
--init
|
||||
: Print a config file to stdout.
|
||||
|
||||
--conf
|
||||
: Display the config file path.
|
||||
|
||||
-a, --all
|
||||
: Search among all cheatpaths.
|
||||
|
||||
-c, --colorize
|
||||
: Colorize output.
|
||||
|
||||
@@ -93,6 +99,9 @@ To search (by regex) for cheatsheets that contain an IP address:
|
||||
To remove (delete) the foo/bar cheatsheet:
|
||||
: cheat --rm _foo/bar_
|
||||
|
||||
To view the configuration file path:
|
||||
: cheat --conf
|
||||
|
||||
|
||||
FILES
|
||||
=====
|
||||
|
||||
36
go.mod
36
go.mod
@@ -1,21 +1,37 @@
|
||||
module github.com/cheat/cheat
|
||||
|
||||
go 1.19
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
|
||||
github.com/mattn/go-isatty v0.0.16
|
||||
github.com/go-git/go-git/v5 v5.16.5
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.5.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
127
go.sum
127
go.sum
@@ -1,38 +1,121 @@
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
|
||||
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA=
|
||||
github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
|
||||
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Package cheatpath implements functions pertaining to cheatsheet file path
|
||||
// management.
|
||||
package cheatpath
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Cheatpath encapsulates cheatsheet path information
|
||||
type Cheatpath struct {
|
||||
Name string `yaml:"name"`
|
||||
@@ -7,3 +11,18 @@ type Cheatpath struct {
|
||||
ReadOnly bool `yaml:"readonly"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// Validate ensures that the Cheatpath is valid
|
||||
func (c Cheatpath) Validate() error {
|
||||
// Check that name is not empty
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("cheatpath name cannot be empty")
|
||||
}
|
||||
|
||||
// Check that path is not empty
|
||||
if c.Path == "" {
|
||||
return fmt.Errorf("cheatpath path cannot be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
113
internal/cheatpath/cheatpath_test.go
Normal file
113
internal/cheatpath/cheatpath_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheatpathValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cheatpath Cheatpath
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid cheatpath",
|
||||
cheatpath: Cheatpath{
|
||||
Name: "personal",
|
||||
Path: "/home/user/.config/cheat/personal",
|
||||
ReadOnly: false,
|
||||
Tags: []string{"personal"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
cheatpath: Cheatpath{
|
||||
Name: "",
|
||||
Path: "/home/user/.config/cheat/personal",
|
||||
ReadOnly: false,
|
||||
Tags: []string{"personal"},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "cheatpath name cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
cheatpath: Cheatpath{
|
||||
Name: "personal",
|
||||
Path: "",
|
||||
ReadOnly: false,
|
||||
Tags: []string{"personal"},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "cheatpath path cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
cheatpath: Cheatpath{
|
||||
Name: "",
|
||||
Path: "",
|
||||
ReadOnly: true,
|
||||
Tags: nil,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "cheatpath name cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "minimal valid",
|
||||
cheatpath: Cheatpath{
|
||||
Name: "x",
|
||||
Path: "/",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "with readonly and tags",
|
||||
cheatpath: Cheatpath{
|
||||
Name: "community",
|
||||
Path: "/usr/share/cheat",
|
||||
ReadOnly: true,
|
||||
Tags: []string{"community", "shared", "readonly"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cheatpath.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("Validate() error = %v, want error containing %q", err, tt.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheatpathStruct(t *testing.T) {
|
||||
// Test that the struct fields work as expected
|
||||
cp := Cheatpath{
|
||||
Name: "test",
|
||||
Path: "/test/path",
|
||||
ReadOnly: true,
|
||||
Tags: []string{"tag1", "tag2"},
|
||||
}
|
||||
|
||||
if cp.Name != "test" {
|
||||
t.Errorf("expected Name to be 'test', got %q", cp.Name)
|
||||
}
|
||||
if cp.Path != "/test/path" {
|
||||
t.Errorf("expected Path to be '/test/path', got %q", cp.Path)
|
||||
}
|
||||
if !cp.ReadOnly {
|
||||
t.Error("expected ReadOnly to be true")
|
||||
}
|
||||
if len(cp.Tags) != 2 || cp.Tags[0] != "tag1" || cp.Tags[1] != "tag2" {
|
||||
t.Errorf("expected Tags to be [tag1 tag2], got %v", cp.Tags)
|
||||
}
|
||||
}
|
||||
63
internal/cheatpath/doc.go
Normal file
63
internal/cheatpath/doc.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package cheatpath manages collections of cheat sheets organized in filesystem directories.
|
||||
//
|
||||
// A Cheatpath represents a directory containing cheat sheets, with associated
|
||||
// metadata such as tags and read-only status. Multiple cheatpaths can be
|
||||
// configured to organize sheets from different sources (personal, community, work, etc.).
|
||||
//
|
||||
// # Cheatpath Structure
|
||||
//
|
||||
// Each cheatpath has:
|
||||
// - Name: A friendly identifier (e.g., "personal", "community")
|
||||
// - Path: The filesystem path to the directory
|
||||
// - Tags: Tags automatically applied to all sheets in this path
|
||||
// - ReadOnly: Whether sheets in this path can be modified
|
||||
//
|
||||
// Example configuration:
|
||||
//
|
||||
// cheatpaths:
|
||||
// - name: personal
|
||||
// path: ~/cheat
|
||||
// tags: []
|
||||
// readonly: false
|
||||
// - name: community
|
||||
// path: ~/cheat/community
|
||||
// tags: [community]
|
||||
// readonly: true
|
||||
//
|
||||
// # Directory-Scoped Cheatpaths
|
||||
//
|
||||
// The package supports directory-scoped cheatpaths via `.cheat` directories.
|
||||
// When running cheat from a directory containing a `.cheat` subdirectory,
|
||||
// that directory is temporarily added to the available cheatpaths.
|
||||
//
|
||||
// # Precedence and Overrides
|
||||
//
|
||||
// When multiple cheatpaths contain a sheet with the same name, the sheet
|
||||
// from the most "local" cheatpath takes precedence. This allows users to
|
||||
// override community sheets with personal versions.
|
||||
//
|
||||
// Key Functions
|
||||
//
|
||||
// - Filter: Filters cheatpaths by name
|
||||
// - Validate: Ensures cheatpath configuration is valid
|
||||
// - Writeable: Returns the first writeable cheatpath
|
||||
//
|
||||
// Example Usage
|
||||
//
|
||||
// // Filter cheatpaths to only "personal"
|
||||
// filtered, err := cheatpath.Filter(paths, "personal")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Find a writeable cheatpath
|
||||
// writeable, err := cheatpath.Writeable(paths)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Validate cheatpath configuration
|
||||
// if err := cheatpath.Validate(paths); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
package cheatpath
|
||||
@@ -2,16 +2,38 @@ package cheatpath
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate returns an error if the cheatpath is invalid
|
||||
func (c *Cheatpath) Validate() error {
|
||||
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("invalid cheatpath: name must be specified")
|
||||
// ValidateSheetName ensures that a cheatsheet name does not contain
|
||||
// directory traversal sequences or other potentially dangerous patterns.
|
||||
func ValidateSheetName(name string) error {
|
||||
// Reject empty names
|
||||
if name == "" {
|
||||
return fmt.Errorf("cheatsheet name cannot be empty")
|
||||
}
|
||||
if c.Path == "" {
|
||||
return fmt.Errorf("invalid cheatpath: path must be specified")
|
||||
|
||||
// Reject names containing directory traversal
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("cheatsheet name cannot contain '..'")
|
||||
}
|
||||
|
||||
// Reject absolute paths
|
||||
if filepath.IsAbs(name) {
|
||||
return fmt.Errorf("cheatsheet name cannot be an absolute path")
|
||||
}
|
||||
|
||||
// Reject names that start with ~ (home directory expansion)
|
||||
if strings.HasPrefix(name, "~") {
|
||||
return fmt.Errorf("cheatsheet name cannot start with '~'")
|
||||
}
|
||||
|
||||
// Reject hidden files (files that start with a dot)
|
||||
// We don't display hidden files, so we shouldn't create them
|
||||
filename := filepath.Base(name)
|
||||
if strings.HasPrefix(filename, ".") {
|
||||
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
169
internal/cheatpath/validate_fuzz_test.go
Normal file
169
internal/cheatpath/validate_fuzz_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// FuzzValidateSheetName tests the ValidateSheetName function with fuzzing
|
||||
// to ensure it properly prevents path traversal and other security issues
|
||||
func FuzzValidateSheetName(f *testing.F) {
|
||||
// Add seed corpus with various valid and malicious inputs
|
||||
// Valid names
|
||||
f.Add("docker")
|
||||
f.Add("docker/compose")
|
||||
f.Add("lang/go/slice")
|
||||
f.Add("my-cheat_sheet")
|
||||
f.Add("file.txt")
|
||||
f.Add("a")
|
||||
f.Add("123")
|
||||
|
||||
// Path traversal attempts
|
||||
f.Add("..")
|
||||
f.Add("../etc/passwd")
|
||||
f.Add("foo/../bar")
|
||||
f.Add("foo/../../etc/passwd")
|
||||
f.Add("..\\windows\\system32")
|
||||
f.Add("foo\\..\\..\\windows")
|
||||
|
||||
// Encoded traversal attempts
|
||||
f.Add("%2e%2e")
|
||||
f.Add("%2e%2e%2f")
|
||||
f.Add("..%2f")
|
||||
f.Add("%2e.")
|
||||
f.Add(".%2e")
|
||||
f.Add("\x2e\x2e")
|
||||
f.Add("\\x2e\\x2e")
|
||||
|
||||
// Unicode and special characters
|
||||
f.Add("€test")
|
||||
f.Add("test€")
|
||||
f.Add("中文")
|
||||
f.Add("🎉emoji")
|
||||
f.Add("\x00null")
|
||||
f.Add("test\x00null")
|
||||
f.Add("\nnewline")
|
||||
f.Add("test\ttab")
|
||||
|
||||
// Absolute paths
|
||||
f.Add("/etc/passwd")
|
||||
f.Add("C:\\Windows\\System32")
|
||||
f.Add("\\\\server\\share")
|
||||
f.Add("//server/share")
|
||||
|
||||
// Home directory
|
||||
f.Add("~")
|
||||
f.Add("~/config")
|
||||
f.Add("~user/file")
|
||||
|
||||
// Hidden files
|
||||
f.Add(".hidden")
|
||||
f.Add("dir/.hidden")
|
||||
f.Add(".git/config")
|
||||
|
||||
// Edge cases
|
||||
f.Add("")
|
||||
f.Add(" ")
|
||||
f.Add(" ")
|
||||
f.Add("\t")
|
||||
f.Add(".")
|
||||
f.Add("./")
|
||||
f.Add("./file")
|
||||
f.Add(".../")
|
||||
f.Add("...")
|
||||
f.Add("....")
|
||||
|
||||
// Very long names
|
||||
f.Add(strings.Repeat("a", 255))
|
||||
f.Add(strings.Repeat("a/", 100) + "file")
|
||||
f.Add(strings.Repeat("../", 50) + "etc/passwd")
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
// The function should never panic
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ValidateSheetName panicked with input %q: %v", input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := ValidateSheetName(input)
|
||||
|
||||
// Security invariants that must always hold
|
||||
if err == nil {
|
||||
// If validation passed, verify security properties
|
||||
|
||||
// Should not contain ".." for path traversal
|
||||
if strings.Contains(input, "..") {
|
||||
t.Errorf("validation passed but input contains '..': %q", input)
|
||||
}
|
||||
|
||||
// Should not be empty
|
||||
if input == "" {
|
||||
t.Error("validation passed for empty input")
|
||||
}
|
||||
|
||||
// Should not start with ~ (home directory)
|
||||
if strings.HasPrefix(input, "~") {
|
||||
t.Errorf("validation passed but input starts with '~': %q", input)
|
||||
}
|
||||
|
||||
// Base filename should not start with .
|
||||
parts := strings.Split(input, "/")
|
||||
if len(parts) > 0 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if strings.HasPrefix(lastPart, ".") && lastPart != "." {
|
||||
t.Errorf("validation passed but filename starts with '.': %q", input)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: result should be valid UTF-8
|
||||
if !utf8.ValidString(input) {
|
||||
// While the function doesn't explicitly check this,
|
||||
// we want to ensure it handles invalid UTF-8 gracefully
|
||||
t.Logf("validation passed for invalid UTF-8: %q", input)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzValidateSheetNamePathTraversal specifically targets path traversal bypasses
|
||||
func FuzzValidateSheetNamePathTraversal(f *testing.F) {
|
||||
// Seed corpus focusing on path traversal variations
|
||||
f.Add("..", "/", "")
|
||||
f.Add("", "..", "/")
|
||||
f.Add("a", "b", "c")
|
||||
|
||||
f.Fuzz(func(t *testing.T, prefix string, middle string, suffix string) {
|
||||
// Construct various path traversal attempts
|
||||
inputs := []string{
|
||||
prefix + ".." + suffix,
|
||||
prefix + "/.." + suffix,
|
||||
prefix + "\\.." + suffix,
|
||||
prefix + middle + ".." + suffix,
|
||||
prefix + "../" + middle + suffix,
|
||||
prefix + "..%2f" + suffix,
|
||||
prefix + "%2e%2e" + suffix,
|
||||
prefix + "%2e%2e%2f" + suffix,
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ValidateSheetName panicked with constructed input %q: %v", input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := ValidateSheetName(input)
|
||||
|
||||
// If the input contains literal "..", it must be rejected
|
||||
if strings.Contains(input, "..") && err == nil {
|
||||
t.Errorf("validation incorrectly passed for input containing '..': %q", input)
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,56 +1,113 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateValid asserts that valid cheatpaths validate successfully
|
||||
func TestValidateValid(t *testing.T) {
|
||||
|
||||
// initialize a valid cheatpath
|
||||
cheatpath := Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
func TestValidateSheetName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
// Valid names
|
||||
{
|
||||
name: "simple name",
|
||||
input: "docker",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "name with slash",
|
||||
input: "docker/compose",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "name with multiple slashes",
|
||||
input: "lang/go/slice",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "name with dash and underscore",
|
||||
input: "my-cheat_sheet",
|
||||
wantErr: false,
|
||||
},
|
||||
// Invalid names
|
||||
{
|
||||
name: "empty name",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errMsg: "empty",
|
||||
},
|
||||
{
|
||||
name: "parent directory traversal",
|
||||
input: "../etc/passwd",
|
||||
wantErr: true,
|
||||
errMsg: "'..'",
|
||||
},
|
||||
{
|
||||
name: "complex traversal",
|
||||
input: "foo/../../etc/passwd",
|
||||
wantErr: true,
|
||||
errMsg: "'..'",
|
||||
},
|
||||
{
|
||||
name: "absolute path unix",
|
||||
input: "/etc/passwd",
|
||||
wantErr: runtime.GOOS != "windows", // /etc/passwd is not absolute on Windows
|
||||
errMsg: "absolute",
|
||||
},
|
||||
{
|
||||
name: "absolute path windows",
|
||||
input: `C:\evil`,
|
||||
wantErr: runtime.GOOS == "windows", // C:\evil is not absolute on Unix
|
||||
errMsg: "absolute",
|
||||
},
|
||||
{
|
||||
name: "home directory",
|
||||
input: "~/secrets",
|
||||
wantErr: true,
|
||||
errMsg: "'~'",
|
||||
},
|
||||
{
|
||||
name: "just dots",
|
||||
input: "..",
|
||||
wantErr: true,
|
||||
errMsg: "'..'",
|
||||
},
|
||||
{
|
||||
name: "hidden file not allowed",
|
||||
input: ".hidden",
|
||||
wantErr: true,
|
||||
errMsg: "cannot start with '.'",
|
||||
},
|
||||
{
|
||||
name: "current dir is ok",
|
||||
input: "./current",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested hidden file not allowed",
|
||||
input: "config/.gitignore",
|
||||
wantErr: true,
|
||||
errMsg: "cannot start with '.'",
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := cheatpath.Validate(); err != nil {
|
||||
t.Errorf("failed to validate valid cheatpath: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMissingName asserts that paths that are missing a name fail to
|
||||
// validate
|
||||
func TestValidateMissingName(t *testing.T) {
|
||||
|
||||
// initialize a valid cheatpath
|
||||
cheatpath := Cheatpath{
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := cheatpath.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate cheatpath without name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMissingPath asserts that paths that are missing a path fail to
|
||||
// validate
|
||||
func TestValidateMissingPath(t *testing.T) {
|
||||
|
||||
// initialize a valid cheatpath
|
||||
cheatpath := Cheatpath{
|
||||
Name: "foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := cheatpath.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate cheatpath without path")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateSheetName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil && tt.errMsg != "" {
|
||||
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("ValidateName(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package config implements functions pertaining to configuration management.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"gopkg.in/yaml.v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config encapsulates configuration parameters
|
||||
@@ -24,7 +25,7 @@ type Config struct {
|
||||
}
|
||||
|
||||
// New returns a new Config struct
|
||||
func New(opts map[string]interface{}, confPath string, resolve bool) (Config, error) {
|
||||
func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error) {
|
||||
|
||||
// read the config file
|
||||
buf, err := os.ReadFile(confPath)
|
||||
@@ -39,7 +40,7 @@ func New(opts map[string]interface{}, confPath string, resolve bool) (Config, er
|
||||
conf.Path = confPath
|
||||
|
||||
// unmarshal the yaml
|
||||
err = yaml.UnmarshalStrict(buf, &conf)
|
||||
err = yaml.Unmarshal(buf, &conf)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||
}
|
||||
@@ -63,7 +64,8 @@ func New(opts map[string]interface{}, confPath string, resolve bool) (Config, er
|
||||
}
|
||||
|
||||
// process cheatpaths
|
||||
for i, cheatpath := range conf.Cheatpaths {
|
||||
var validPaths []cp.Cheatpath
|
||||
for _, cheatpath := range conf.Cheatpaths {
|
||||
|
||||
// expand ~ in config paths
|
||||
expanded, err := homedir.Expand(cheatpath.Path)
|
||||
@@ -82,6 +84,14 @@ func New(opts map[string]interface{}, confPath string, resolve bool) (Config, er
|
||||
if resolve {
|
||||
evaled, err := filepath.EvalSymlinks(expanded)
|
||||
if err != nil {
|
||||
// if the path simply doesn't exist, warn and skip it
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"WARNING: cheatpath '%s' does not exist, skipping\n",
|
||||
expanded,
|
||||
)
|
||||
continue
|
||||
}
|
||||
return Config{}, fmt.Errorf(
|
||||
"failed to resolve symlink: %s: %v",
|
||||
expanded,
|
||||
@@ -92,8 +102,13 @@ func New(opts map[string]interface{}, confPath string, resolve bool) (Config, er
|
||||
expanded = evaled
|
||||
}
|
||||
|
||||
conf.Cheatpaths[i].Path = expanded
|
||||
cheatpath.Path = expanded
|
||||
validPaths = append(validPaths, cheatpath)
|
||||
}
|
||||
conf.Cheatpaths = validPaths
|
||||
|
||||
// trim editor whitespace
|
||||
conf.Editor = strings.TrimSpace(conf.Editor)
|
||||
|
||||
// if an editor was not provided in the configs, attempt to choose one
|
||||
// that's appropriate for the environment
|
||||
|
||||
268
internal/config/config_extended_test.go
Normal file
268
internal/config/config_extended_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/mock"
|
||||
)
|
||||
|
||||
// TestConfigYAMLErrors tests YAML parsing errors
|
||||
func TestConfigYAMLErrors(t *testing.T) {
|
||||
// Create a temporary file with invalid YAML
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
invalidYAML := filepath.Join(tempDir, "invalid.yml")
|
||||
err = os.WriteFile(invalidYAML, []byte("invalid: yaml: content:\n - no closing"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write invalid yaml: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to load invalid YAML
|
||||
_, err = New(map[string]interface{}{}, invalidYAML, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigLocalCheatpath tests local .cheat directory detection
|
||||
func TestConfigLocalCheatpath(t *testing.T) {
|
||||
// Create a temporary directory to act as working directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
|
||||
tempDir, err = filepath.EvalSymlinks(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
|
||||
}
|
||||
|
||||
// Save current working directory
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
// Change to temp directory
|
||||
err = os.Chdir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change dir: %v", err)
|
||||
}
|
||||
|
||||
// Create .cheat directory
|
||||
localCheat := filepath.Join(tempDir, ".cheat")
|
||||
err = os.Mkdir(localCheat, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||
}
|
||||
|
||||
// Load config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Check that local cheatpath was added
|
||||
found := false
|
||||
for _, cp := range conf.Cheatpaths {
|
||||
if cp.Name == "cwd" && cp.Path == localCheat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("local .cheat directory was not added to cheatpaths")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigDefaults tests default values
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
// Load empty config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Check defaults
|
||||
if conf.Style != "bw" {
|
||||
t.Errorf("expected default style 'bw', got %s", conf.Style)
|
||||
}
|
||||
|
||||
if conf.Formatter != "terminal" {
|
||||
t.Errorf("expected default formatter 'terminal', got %s", conf.Formatter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSymlinkResolution tests symlink resolution
|
||||
func TestConfigSymlinkResolution(t *testing.T) {
|
||||
// Create temp directory structure
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
|
||||
tempDir, err = filepath.EvalSymlinks(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
|
||||
}
|
||||
|
||||
// Create target directory
|
||||
targetDir := filepath.Join(tempDir, "target")
|
||||
err = os.Mkdir(targetDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create target dir: %v", err)
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
linkPath := filepath.Join(tempDir, "link")
|
||||
err = os.Symlink(targetDir, linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Create config with symlink path
|
||||
configContent := `---
|
||||
editor: vim
|
||||
cheatpaths:
|
||||
- name: test
|
||||
path: ` + linkPath + `
|
||||
readonly: true
|
||||
`
|
||||
configFile := filepath.Join(tempDir, "config.yml")
|
||||
err = os.WriteFile(configFile, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Load config with symlink resolution
|
||||
conf, err := New(map[string]interface{}{}, configFile, true)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Verify symlink was resolved
|
||||
if len(conf.Cheatpaths) > 0 && conf.Cheatpaths[0].Path != targetDir {
|
||||
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBrokenSymlink tests broken symlink handling
|
||||
func TestConfigBrokenSymlink(t *testing.T) {
|
||||
// Create temp directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create broken symlink
|
||||
linkPath := filepath.Join(tempDir, "broken-link")
|
||||
err = os.Symlink("/nonexistent/path", linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Create config with broken symlink
|
||||
configContent := `---
|
||||
editor: vim
|
||||
cheatpaths:
|
||||
- name: test
|
||||
path: ` + linkPath + `
|
||||
readonly: true
|
||||
`
|
||||
configFile := filepath.Join(tempDir, "config.yml")
|
||||
err = os.WriteFile(configFile, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Load config with symlink resolution should skip the broken cheatpath
|
||||
// (warn to stderr) rather than hard-error
|
||||
conf, err := New(map[string]interface{}{}, configFile, true)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
|
||||
}
|
||||
if len(conf.Cheatpaths) != 0 {
|
||||
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigTildeExpansionError tests tilde expansion error handling
|
||||
func TestConfigTildeExpansionError(t *testing.T) {
|
||||
// This is tricky to test without mocking homedir.Expand
|
||||
// We'll create a config with an invalid home reference
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create config with user that likely doesn't exist
|
||||
configContent := `---
|
||||
editor: vim
|
||||
cheatpaths:
|
||||
- name: test
|
||||
path: ~nonexistentuser12345/cheat
|
||||
readonly: true
|
||||
`
|
||||
configFile := filepath.Join(tempDir, "config.yml")
|
||||
err = os.WriteFile(configFile, []byte(configContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Load config - this may or may not fail depending on the system
|
||||
// but we're testing that it doesn't panic
|
||||
_, _ = New(map[string]interface{}{}, configFile, false)
|
||||
}
|
||||
|
||||
// TestConfigGetCwdError tests error handling when os.Getwd fails
|
||||
func TestConfigGetCwdError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not allow removing the current directory")
|
||||
}
|
||||
|
||||
// This is difficult to test without being able to break os.Getwd
|
||||
// We'll create a scenario where the current directory is removed
|
||||
|
||||
// Create and enter a temp directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
oldCwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
defer os.Chdir(oldCwd)
|
||||
|
||||
err = os.Chdir(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change dir: %v", err)
|
||||
}
|
||||
|
||||
// Remove the directory we're in
|
||||
err = os.RemoveAll(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Now os.Getwd should fail
|
||||
_, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
||||
// This might not fail on all systems, so we just ensure no panic
|
||||
_ = err
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
@@ -78,6 +79,9 @@ func TestConfigFailure(t *testing.T) {
|
||||
// TestEmptyEditor asserts that envvars are respected if an editor is not
|
||||
// specified in the configs
|
||||
func TestEmptyEditor(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Editor() returns notepad on Windows before checking env vars")
|
||||
}
|
||||
|
||||
// clear the environment variables
|
||||
os.Setenv("VISUAL", "")
|
||||
|
||||
52
internal/config/doc.go
Normal file
52
internal/config/doc.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package config manages application configuration and settings.
|
||||
//
|
||||
// The config package provides functionality to:
|
||||
// - Load configuration from YAML files
|
||||
// - Validate configuration values
|
||||
// - Manage platform-specific configuration paths
|
||||
// - Handle editor and pager settings
|
||||
// - Configure colorization and formatting options
|
||||
//
|
||||
// # Configuration Structure
|
||||
//
|
||||
// The main configuration file (conf.yml) contains:
|
||||
// - Editor preferences
|
||||
// - Pager settings
|
||||
// - Colorization options
|
||||
// - Cheatpath definitions
|
||||
// - Formatting preferences
|
||||
//
|
||||
// Example configuration:
|
||||
//
|
||||
// ---
|
||||
// editor: vim
|
||||
// colorize: true
|
||||
// style: monokai
|
||||
// formatter: terminal256
|
||||
// pager: less -FRX
|
||||
// cheatpaths:
|
||||
// - name: personal
|
||||
// path: ~/cheat
|
||||
// tags: []
|
||||
// readonly: false
|
||||
// - name: community
|
||||
// path: ~/cheat/.cheat
|
||||
// tags: [community]
|
||||
// readonly: true
|
||||
//
|
||||
// # Platform-Specific Paths
|
||||
//
|
||||
// The package automatically detects configuration paths based on the operating system:
|
||||
// - Linux/Unix: $XDG_CONFIG_HOME/cheat/conf.yml or ~/.config/cheat/conf.yml
|
||||
// - macOS: ~/Library/Application Support/cheat/conf.yml
|
||||
// - Windows: %APPDATA%\cheat\conf.yml
|
||||
//
|
||||
// # Environment Variables
|
||||
//
|
||||
// The following environment variables are respected:
|
||||
// - CHEAT_CONFIG_PATH: Override the configuration file location
|
||||
// - CHEAT_USE_FZF: Enable fzf integration when set to "true"
|
||||
// - EDITOR: Default editor if not specified in config
|
||||
// - VISUAL: Fallback editor if EDITOR is not set
|
||||
// - PAGER: Default pager if not specified in config
|
||||
package config
|
||||
95
internal/config/editor_test.go
Normal file
95
internal/config/editor_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEditor tests the Editor function
|
||||
func TestEditor(t *testing.T) {
|
||||
// Save original env vars
|
||||
oldVisual := os.Getenv("VISUAL")
|
||||
oldEditor := os.Getenv("EDITOR")
|
||||
defer func() {
|
||||
os.Setenv("VISUAL", oldVisual)
|
||||
os.Setenv("EDITOR", oldEditor)
|
||||
}()
|
||||
|
||||
t.Run("windows default", func(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("skipping windows test on non-windows platform")
|
||||
}
|
||||
|
||||
// Clear env vars
|
||||
os.Setenv("VISUAL", "")
|
||||
os.Setenv("EDITOR", "")
|
||||
|
||||
editor, err := Editor()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if editor != "notepad" {
|
||||
t.Errorf("expected 'notepad' on windows, got %s", editor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("VISUAL takes precedence", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping non-windows test on windows platform")
|
||||
}
|
||||
|
||||
os.Setenv("VISUAL", "emacs")
|
||||
os.Setenv("EDITOR", "nano")
|
||||
|
||||
editor, err := Editor()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if editor != "emacs" {
|
||||
t.Errorf("expected VISUAL to take precedence, got %s", editor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EDITOR when no VISUAL", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping non-windows test on windows platform")
|
||||
}
|
||||
|
||||
os.Setenv("VISUAL", "")
|
||||
os.Setenv("EDITOR", "vim")
|
||||
|
||||
editor, err := Editor()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if editor != "vim" {
|
||||
t.Errorf("expected EDITOR value, got %s", editor)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no editor found error", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping non-windows test on windows platform")
|
||||
}
|
||||
|
||||
// Clear all environment variables
|
||||
os.Setenv("VISUAL", "")
|
||||
os.Setenv("EDITOR", "")
|
||||
|
||||
// Create a custom PATH that doesn't include common editors
|
||||
oldPath := os.Getenv("PATH")
|
||||
defer os.Setenv("PATH", oldPath)
|
||||
|
||||
// Set a very limited PATH that won't have editors
|
||||
os.Setenv("PATH", "/nonexistent")
|
||||
|
||||
editor, err := Editor()
|
||||
|
||||
// If we found an editor, it's likely in the system
|
||||
// This test might not always produce an error on systems with editors
|
||||
if editor == "" && err == nil {
|
||||
t.Error("expected error when no editor found")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -35,3 +38,90 @@ func TestInit(t *testing.T) {
|
||||
t.Errorf("failed to write configs: want: %s, got: %s", conf, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitCreateDirectory tests that Init creates the directory if it doesn't exist
|
||||
func TestInitCreateDirectory(t *testing.T) {
|
||||
// Create a temp directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-init-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Path to a config file in a non-existent subdirectory
|
||||
confPath := filepath.Join(tempDir, "subdir", "conf.yml")
|
||||
|
||||
// Initialize the config file
|
||||
conf := "test config"
|
||||
if err = Init(confPath, conf); err != nil {
|
||||
t.Errorf("failed to init config file: %v", err)
|
||||
}
|
||||
|
||||
// Verify the directory was created
|
||||
if _, err := os.Stat(filepath.Dir(confPath)); os.IsNotExist(err) {
|
||||
t.Error("Init did not create the directory")
|
||||
}
|
||||
|
||||
// Verify the file was created with correct content
|
||||
bytes, err := os.ReadFile(confPath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read config file: %v", err)
|
||||
}
|
||||
if string(bytes) != conf {
|
||||
t.Errorf("config content mismatch: got %q, want %q", string(bytes), conf)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitWriteError tests error handling when file write fails
|
||||
func TestInitWriteError(t *testing.T) {
|
||||
// Skip this test if running as root (can write anywhere)
|
||||
if runtime.GOOS != "windows" && os.Getuid() == 0 {
|
||||
t.Skip("Cannot test write errors as root")
|
||||
}
|
||||
|
||||
// Use a platform-appropriate invalid path
|
||||
invalidPath := "/dev/null/impossible/path/conf.yml"
|
||||
if runtime.GOOS == "windows" {
|
||||
invalidPath = `NUL\impossible\path\conf.yml`
|
||||
}
|
||||
|
||||
// Try to write to a read-only directory
|
||||
err := Init(invalidPath, "test")
|
||||
if err == nil {
|
||||
t.Error("expected error when writing to invalid path, got nil")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "failed to create") {
|
||||
t.Errorf("expected 'failed to create' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitExistingFile tests that Init overwrites existing files
|
||||
func TestInitExistingFile(t *testing.T) {
|
||||
// Create a temp file
|
||||
tempFile, err := os.CreateTemp("", "cheat-init-existing-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(tempFile.Name())
|
||||
|
||||
// Write initial content
|
||||
initialContent := "initial content"
|
||||
if err := os.WriteFile(tempFile.Name(), []byte(initialContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write initial content: %v", err)
|
||||
}
|
||||
|
||||
// Initialize with new content
|
||||
newContent := "new config content"
|
||||
if err = Init(tempFile.Name(), newContent); err != nil {
|
||||
t.Errorf("failed to init over existing file: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file was overwritten
|
||||
bytes, err := os.ReadFile(tempFile.Name())
|
||||
if err != nil {
|
||||
t.Errorf("failed to read config file: %v", err)
|
||||
}
|
||||
if string(bytes) != newContent {
|
||||
t.Errorf("config not overwritten: got %q, want %q", string(bytes), newContent)
|
||||
}
|
||||
}
|
||||
|
||||
125
internal/config/new_test.go
Normal file
125
internal/config/new_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTrimsWhitespace(t *testing.T) {
|
||||
// Create a temporary config file with whitespace in editor and pager
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
configContent := `---
|
||||
editor: " vim -c 'set number' "
|
||||
pager: " less -R "
|
||||
style: monokai
|
||||
formatter: terminal
|
||||
cheatpaths:
|
||||
- name: personal
|
||||
path: ~/cheat
|
||||
tags: []
|
||||
readonly: false
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Load the config
|
||||
conf, err := New(map[string]interface{}{}, configPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Verify editor is trimmed
|
||||
expectedEditor := "vim -c 'set number'"
|
||||
if conf.Editor != expectedEditor {
|
||||
t.Errorf("editor not properly trimmed: got %q, want %q", conf.Editor, expectedEditor)
|
||||
}
|
||||
|
||||
// Verify pager is trimmed
|
||||
expectedPager := "less -R"
|
||||
if conf.Pager != expectedPager {
|
||||
t.Errorf("pager not properly trimmed: got %q, want %q", conf.Pager, expectedPager)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmptyEditorFallback(t *testing.T) {
|
||||
// Skip if required environment variables would interfere
|
||||
oldVisual := os.Getenv("VISUAL")
|
||||
oldEditor := os.Getenv("EDITOR")
|
||||
os.Unsetenv("VISUAL")
|
||||
os.Unsetenv("EDITOR")
|
||||
defer func() {
|
||||
os.Setenv("VISUAL", oldVisual)
|
||||
os.Setenv("EDITOR", oldEditor)
|
||||
}()
|
||||
|
||||
// Create a config with whitespace-only editor
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
configContent := `---
|
||||
editor: " "
|
||||
pager: less
|
||||
style: monokai
|
||||
formatter: terminal
|
||||
cheatpaths:
|
||||
- name: personal
|
||||
path: ~/cheat
|
||||
tags: []
|
||||
readonly: false
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Load the config
|
||||
conf, err := New(map[string]interface{}{}, configPath, false)
|
||||
if err != nil {
|
||||
// It's OK if this fails due to no editor being found
|
||||
// The important thing is it doesn't panic
|
||||
return
|
||||
}
|
||||
|
||||
// If it succeeded, editor should not be empty (fallback was used)
|
||||
if conf.Editor == "" {
|
||||
t.Error("editor should not be empty after fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWhitespaceOnlyPager(t *testing.T) {
|
||||
// Create a config with whitespace-only pager
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
configContent := `---
|
||||
editor: vim
|
||||
pager: " "
|
||||
style: monokai
|
||||
formatter: terminal
|
||||
cheatpaths:
|
||||
- name: personal
|
||||
path: ~/cheat
|
||||
tags: []
|
||||
readonly: false
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Load the config
|
||||
conf, err := New(map[string]interface{}{}, configPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Pager should be empty after trimming
|
||||
if conf.Pager != "" {
|
||||
t.Errorf("pager should be empty after trimming whitespace: got %q", conf.Pager)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func Pager() string {
|
||||
// Otherwise, search for `pager`, `less`, and `more` on the `$PATH`. If
|
||||
// none are found, return an empty pager.
|
||||
for _, pager := range []string{"pager", "less", "more"} {
|
||||
if path, err := exec.LookPath(pager); err != nil {
|
||||
if path, err := exec.LookPath(pager); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
90
internal/config/pager_test.go
Normal file
90
internal/config/pager_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPager tests the Pager function
|
||||
func TestPager(t *testing.T) {
|
||||
// Save original env var
|
||||
oldPager := os.Getenv("PAGER")
|
||||
defer os.Setenv("PAGER", oldPager)
|
||||
|
||||
t.Run("windows default", func(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("skipping windows test on non-windows platform")
|
||||
}
|
||||
|
||||
os.Setenv("PAGER", "")
|
||||
pager := Pager()
|
||||
if pager != "more" {
|
||||
t.Errorf("expected 'more' on windows, got %s", pager)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PAGER env var", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping non-windows test on windows platform")
|
||||
}
|
||||
|
||||
os.Setenv("PAGER", "bat")
|
||||
pager := Pager()
|
||||
if pager != "bat" {
|
||||
t.Errorf("expected PAGER env var value, got %s", pager)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback to system pager", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping non-windows test on windows platform")
|
||||
}
|
||||
|
||||
os.Setenv("PAGER", "")
|
||||
pager := Pager()
|
||||
|
||||
// Should find one of the fallback pagers or return empty string
|
||||
validPagers := map[string]bool{
|
||||
"": true, // no pager found
|
||||
"pager": true,
|
||||
"less": true,
|
||||
"more": true,
|
||||
}
|
||||
|
||||
// Check if it's a path to one of these
|
||||
found := false
|
||||
for p := range validPagers {
|
||||
if p == "" && pager == "" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if p != "" && (pager == p || len(pager) >= len(p) && pager[len(pager)-len(p):] == p) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("unexpected pager value: %s", pager)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no pager available", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping non-windows test on windows platform")
|
||||
}
|
||||
|
||||
os.Setenv("PAGER", "")
|
||||
|
||||
// Save and modify PATH to ensure no pagers are found
|
||||
oldPath := os.Getenv("PATH")
|
||||
defer os.Setenv("PATH", oldPath)
|
||||
os.Setenv("PATH", "/nonexistent")
|
||||
|
||||
pager := Pager()
|
||||
if pager != "" {
|
||||
t.Errorf("expected empty string when no pager found, got %s", pager)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -28,7 +28,10 @@ func Paths(
|
||||
}
|
||||
|
||||
switch sys {
|
||||
case "android", "darwin", "linux", "freebsd":
|
||||
|
||||
// darwin/linux/unix
|
||||
case "aix", "android", "darwin", "dragonfly", "freebsd", "illumos", "ios",
|
||||
"linux", "netbsd", "openbsd", "plan9", "solaris":
|
||||
paths := []string{}
|
||||
|
||||
// don't include the `XDG_CONFIG_HOME` path if that envvar is not set
|
||||
@@ -43,6 +46,8 @@ func Paths(
|
||||
}...)
|
||||
|
||||
return paths, nil
|
||||
|
||||
// windows
|
||||
case "windows":
|
||||
return []string{
|
||||
filepath.Join(envvars["APPDATA"], "cheat", "conf.yml"),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
@@ -10,6 +12,9 @@ import (
|
||||
// TestValidatePathsNix asserts that the proper config paths are returned on
|
||||
// *nix platforms
|
||||
func TestValidatePathsNix(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("filepath.Join uses backslashes on Windows")
|
||||
}
|
||||
|
||||
// mock the user's home directory
|
||||
home := "/home/foo"
|
||||
@@ -57,6 +62,9 @@ func TestValidatePathsNix(t *testing.T) {
|
||||
// TestValidatePathsNixNoXDG asserts that the proper config paths are returned
|
||||
// on *nix platforms when `XDG_CONFIG_HOME is not set
|
||||
func TestValidatePathsNixNoXDG(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("filepath.Join uses backslashes on Windows")
|
||||
}
|
||||
|
||||
// mock the user's home directory
|
||||
home := "/home/foo"
|
||||
@@ -106,8 +114,8 @@ func TestValidatePathsWindows(t *testing.T) {
|
||||
|
||||
// mock some envvars
|
||||
envvars := map[string]string{
|
||||
"APPDATA": "/apps",
|
||||
"PROGRAMDATA": "/programs",
|
||||
"APPDATA": filepath.Join("C:", "apps"),
|
||||
"PROGRAMDATA": filepath.Join("C:", "programs"),
|
||||
}
|
||||
|
||||
// get the paths for the platform
|
||||
@@ -118,8 +126,8 @@ func TestValidatePathsWindows(t *testing.T) {
|
||||
|
||||
// specify the expected output
|
||||
want := []string{
|
||||
"/apps/cheat/conf.yml",
|
||||
"/programs/cheat/conf.yml",
|
||||
filepath.Join("C:", "apps", "cheat", "conf.yml"),
|
||||
filepath.Join("C:", "programs", "cheat", "conf.yml"),
|
||||
}
|
||||
|
||||
// assert that output matches expectations
|
||||
|
||||
45
internal/display/doc.go
Normal file
45
internal/display/doc.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package display handles output formatting and presentation for the cheat application.
|
||||
//
|
||||
// The display package provides utilities for:
|
||||
// - Writing output to stdout or a pager
|
||||
// - Formatting text with indentation
|
||||
// - Creating faint (dimmed) text for de-emphasis
|
||||
// - Managing colored output
|
||||
//
|
||||
// # Pager Integration
|
||||
//
|
||||
// The package integrates with system pagers (less, more, etc.) to handle
|
||||
// long output. If a pager is configured and the output is to a terminal,
|
||||
// content is automatically piped through the pager.
|
||||
//
|
||||
// # Text Formatting
|
||||
//
|
||||
// Various formatting utilities are provided:
|
||||
// - Faint: Creates dimmed text using ANSI escape codes
|
||||
// - Indent: Adds consistent indentation to text blocks
|
||||
// - Write: Intelligent output that uses stdout or pager as appropriate
|
||||
//
|
||||
// Example Usage
|
||||
//
|
||||
// // Write output, using pager if configured
|
||||
// if err := display.Write(output, config); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Create faint text for de-emphasis
|
||||
// fainted := display.Faint("(read-only)", config)
|
||||
//
|
||||
// // Indent a block of text
|
||||
// indented := display.Indent(text, " ")
|
||||
//
|
||||
// # Color Support
|
||||
//
|
||||
// The package respects the colorization settings from the config.
|
||||
// When colorization is disabled, formatting functions like Faint
|
||||
// return unmodified text.
|
||||
//
|
||||
// # Terminal Detection
|
||||
//
|
||||
// The package uses isatty to detect if output is to a terminal,
|
||||
// which affects decisions about using a pager and applying colors.
|
||||
package display
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package display implement functions pertaining to writing formatted
|
||||
// cheatsheet content to stdout, or alternatively the system pager.
|
||||
package display
|
||||
|
||||
import (
|
||||
@@ -6,7 +8,8 @@ import (
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
)
|
||||
|
||||
// Faint returns an faint string
|
||||
// Faint returns a faintly-colored string that's used to de-prioritize text
|
||||
// written to stdout
|
||||
func Faint(str string, conf config.Config) string {
|
||||
// make `str` faint only if colorization has been requested
|
||||
if conf.Colorize {
|
||||
|
||||
@@ -19,6 +19,11 @@ func Write(out string, conf config.Config) {
|
||||
}
|
||||
|
||||
// otherwise, pipe output through the pager
|
||||
writeToPager(out, conf)
|
||||
}
|
||||
|
||||
// writeToPager writes output through a pager command
|
||||
func writeToPager(out string, conf config.Config) {
|
||||
parts := strings.Split(conf.Pager, " ")
|
||||
pager := parts[0]
|
||||
args := parts[1:]
|
||||
|
||||
136
internal/display/write_test.go
Normal file
136
internal/display/write_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
)
|
||||
|
||||
// TestWriteToPager tests the writeToPager function
|
||||
func TestWriteToPager(t *testing.T) {
|
||||
// Skip these tests in CI/CD environments where interactive commands might not work
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Skip("Skipping pager tests in CI environment")
|
||||
}
|
||||
|
||||
// Note: We can't easily test os.Exit calls, so we focus on testing writeToPager
|
||||
// which contains the core logic
|
||||
|
||||
t.Run("successful pager execution", func(t *testing.T) {
|
||||
// Save original stdout
|
||||
oldStdout := os.Stdout
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
// Create pipe for capturing output
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Use 'cat' as a simple pager that just outputs input
|
||||
conf := config.Config{
|
||||
Pager: "cat",
|
||||
}
|
||||
|
||||
// This will call os.Exit on error, so we need to be careful
|
||||
// We're using 'cat' which should always succeed
|
||||
input := "Test output\n"
|
||||
|
||||
// Run in a goroutine to avoid blocking
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
writeToPager(input, conf)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for completion or timeout
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
}
|
||||
|
||||
// Close write end and read output
|
||||
w.Close()
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
// Verify output
|
||||
if buf.String() != input {
|
||||
t.Errorf("expected output %q, got %q", input, buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pager with arguments", func(t *testing.T) {
|
||||
// Save original stdout
|
||||
oldStdout := os.Stdout
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
// Create pipe for capturing output
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Use 'cat' with '-A' flag (shows non-printing characters)
|
||||
conf := config.Config{
|
||||
Pager: "cat -A",
|
||||
}
|
||||
|
||||
input := "Test\toutput\n"
|
||||
|
||||
// Run in a goroutine
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
writeToPager(input, conf)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for completion
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
}
|
||||
|
||||
// Close write end and read output
|
||||
w.Close()
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
|
||||
// cat -A shows tabs as ^I and line endings as $
|
||||
expected := "Test^Ioutput$\n"
|
||||
if buf.String() != expected {
|
||||
t.Errorf("expected output %q, got %q", expected, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWriteToPagerError tests error handling in writeToPager
|
||||
func TestWriteToPagerError(t *testing.T) {
|
||||
if os.Getenv("TEST_PAGER_ERROR_SUBPROCESS") == "1" {
|
||||
// This is the subprocess - run the actual test
|
||||
conf := config.Config{Pager: "/nonexistent/command"}
|
||||
writeToPager("test", conf)
|
||||
return
|
||||
}
|
||||
|
||||
// Run test in subprocess to handle os.Exit
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestWriteToPagerError$")
|
||||
cmd.Env = append(os.Environ(), "TEST_PAGER_ERROR_SUBPROCESS=1")
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Should exit with error
|
||||
if err == nil {
|
||||
t.Error("expected process to exit with error")
|
||||
}
|
||||
|
||||
// Should contain error message
|
||||
if !strings.Contains(string(output), "failed to write to pager") {
|
||||
t.Errorf("expected error message about pager failure, got %q", string(output))
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
const cloneURL = "https://github.com/cheat/cheatsheets.git"
|
||||
|
||||
// clone clones the community cheatsheets
|
||||
func clone(path string) error {
|
||||
|
||||
// perform the clone in a shell
|
||||
cmd := exec.Command("git", "clone", cloneURL, path)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package installer implements functions that provide a first-time
|
||||
// installation wizard.
|
||||
package installer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -10,20 +11,34 @@ import (
|
||||
// Prompt prompts the user for a answer
|
||||
func Prompt(prompt string, def bool) (bool, error) {
|
||||
|
||||
// initialize a line reader
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
// display the prompt
|
||||
fmt.Printf("%s: ", prompt)
|
||||
|
||||
// read the answer
|
||||
ans, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse input: %v", err)
|
||||
// read one byte at a time until newline to avoid buffering past the
|
||||
// end of the current line, which would consume input intended for
|
||||
// subsequent Prompt calls on the same stdin
|
||||
var line []byte
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if n > 0 {
|
||||
if buf[0] == '\n' {
|
||||
break
|
||||
}
|
||||
if buf[0] != '\r' {
|
||||
line = append(line, buf[0])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if len(line) > 0 {
|
||||
break
|
||||
}
|
||||
return false, fmt.Errorf("failed to prompt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// normalize the answer
|
||||
ans = strings.ToLower(strings.TrimSpace(ans))
|
||||
ans := strings.ToLower(strings.TrimSpace(string(line)))
|
||||
|
||||
// return the appropriate response
|
||||
switch ans {
|
||||
|
||||
180
internal/installer/prompt_test.go
Normal file
180
internal/installer/prompt_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrompt(t *testing.T) {
|
||||
// Save original stdin/stdout
|
||||
oldStdin := os.Stdin
|
||||
oldStdout := os.Stdout
|
||||
defer func() {
|
||||
os.Stdin = oldStdin
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
input string
|
||||
defaultVal bool
|
||||
want bool
|
||||
wantErr bool
|
||||
wantPrompt string
|
||||
}{
|
||||
{
|
||||
name: "answer yes",
|
||||
prompt: "Continue?",
|
||||
input: "y\n",
|
||||
defaultVal: false,
|
||||
want: true,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "answer yes with uppercase",
|
||||
prompt: "Continue?",
|
||||
input: "Y\n",
|
||||
defaultVal: false,
|
||||
want: true,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "answer yes with spaces",
|
||||
prompt: "Continue?",
|
||||
input: " y \n",
|
||||
defaultVal: false,
|
||||
want: true,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "answer no",
|
||||
prompt: "Continue?",
|
||||
input: "n\n",
|
||||
defaultVal: true,
|
||||
want: false,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "answer no with any text",
|
||||
prompt: "Continue?",
|
||||
input: "anything\n",
|
||||
defaultVal: true,
|
||||
want: false,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "empty answer uses default true",
|
||||
prompt: "Continue?",
|
||||
input: "\n",
|
||||
defaultVal: true,
|
||||
want: true,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "empty answer uses default false",
|
||||
prompt: "Continue?",
|
||||
input: "\n",
|
||||
defaultVal: false,
|
||||
want: false,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
{
|
||||
name: "whitespace answer uses default",
|
||||
prompt: "Continue?",
|
||||
input: " \n",
|
||||
defaultVal: true,
|
||||
want: true,
|
||||
wantPrompt: "Continue?: ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a pipe for stdin
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
|
||||
// Create a pipe for stdout to capture the prompt
|
||||
rOut, wOut, _ := os.Pipe()
|
||||
os.Stdout = wOut
|
||||
|
||||
// Write input to stdin
|
||||
go func() {
|
||||
defer w.Close()
|
||||
io.WriteString(w, tt.input)
|
||||
}()
|
||||
|
||||
// Call the function
|
||||
got, err := Prompt(tt.prompt, tt.defaultVal)
|
||||
|
||||
// Close stdout write end and read the prompt
|
||||
wOut.Close()
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, rOut)
|
||||
|
||||
// Check error
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Prompt() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check result
|
||||
if got != tt.want {
|
||||
t.Errorf("Prompt() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
// Check that prompt was displayed correctly
|
||||
if buf.String() != tt.wantPrompt {
|
||||
t.Errorf("Prompt display = %q, want %q", buf.String(), tt.wantPrompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptError(t *testing.T) {
|
||||
// Save original stdin
|
||||
oldStdin := os.Stdin
|
||||
defer func() {
|
||||
os.Stdin = oldStdin
|
||||
}()
|
||||
|
||||
// Create a pipe and close it immediately to simulate read error
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
r.Close()
|
||||
w.Close()
|
||||
|
||||
// This should cause a read error
|
||||
_, err := Prompt("Test?", false)
|
||||
if err == nil {
|
||||
t.Error("expected error when reading from closed stdin, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to prompt") {
|
||||
t.Errorf("expected 'failed to prompt' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromptIntegration provides a simple integration test
|
||||
func TestPromptIntegration(t *testing.T) {
|
||||
// This demonstrates how the prompt would be used in practice
|
||||
// It's skipped by default since it requires actual user input
|
||||
if os.Getenv("TEST_INTERACTIVE") != "1" {
|
||||
t.Skip("Skipping interactive test - set TEST_INTERACTIVE=1 to run")
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Interactive Prompt Test ===")
|
||||
fmt.Println("You will be prompted to answer a question.")
|
||||
fmt.Println("Try different inputs: y, n, Y, N, empty (just press Enter)")
|
||||
|
||||
result, err := Prompt("Would you like to continue? [Y/n]", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Prompt failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("You answered: %v\n", result)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
"github.com/cheat/cheat/internal/repo"
|
||||
)
|
||||
|
||||
// Run runs the installer
|
||||
@@ -16,13 +17,15 @@ func Run(configs string, confpath string) error {
|
||||
// cheatsheets based on the user's platform
|
||||
confdir := filepath.Dir(confpath)
|
||||
|
||||
// create paths for community and personal cheatsheets
|
||||
// create paths for community, personal, and work cheatsheets
|
||||
community := filepath.Join(confdir, "cheatsheets", "community")
|
||||
personal := filepath.Join(confdir, "cheatsheets", "personal")
|
||||
work := filepath.Join(confdir, "cheatsheets", "work")
|
||||
|
||||
// set default cheatpaths
|
||||
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
|
||||
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
|
||||
configs = strings.Replace(configs, "WORK_PATH", work, -1)
|
||||
|
||||
// locate and set a default pager
|
||||
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
|
||||
@@ -43,15 +46,29 @@ func Run(configs string, confpath string) error {
|
||||
|
||||
// clone the community cheatsheets if so instructed
|
||||
if yes {
|
||||
// clone the community cheatsheets
|
||||
fmt.Printf("Cloning community cheatsheets to %s.\n", community)
|
||||
if err := clone(community); err != nil {
|
||||
if err := repo.Clone(community); err != nil {
|
||||
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
||||
}
|
||||
} else {
|
||||
// comment out the community cheatpath in the config since
|
||||
// the directory won't exist
|
||||
configs = strings.Replace(configs,
|
||||
" - name: community\n"+
|
||||
" path: "+community+"\n"+
|
||||
" tags: [ community ]\n"+
|
||||
" readonly: true",
|
||||
" #- name: community\n"+
|
||||
" # path: "+community+"\n"+
|
||||
" # tags: [ community ]\n"+
|
||||
" # readonly: true",
|
||||
-1,
|
||||
)
|
||||
}
|
||||
|
||||
// also create a directory for personal cheatsheets
|
||||
fmt.Printf("Cloning personal cheatsheets to %s.\n", personal)
|
||||
if err := os.MkdirAll(personal, os.ModePerm); err != nil {
|
||||
// always create personal and work directories
|
||||
for _, dir := range []string{personal, work} {
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
268
internal/installer/run_test.go
Normal file
268
internal/installer/run_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "cheat-installer-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Save original stdin/stdout
|
||||
oldStdin := os.Stdin
|
||||
oldStdout := os.Stdout
|
||||
defer func() {
|
||||
os.Stdin = oldStdin
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configs string
|
||||
confpath string
|
||||
userInput string
|
||||
wantErr bool
|
||||
wantInErr string
|
||||
checkFiles []string
|
||||
dontWantFiles []string
|
||||
}{
|
||||
{
|
||||
name: "user declines community cheatsheets",
|
||||
configs: `---
|
||||
editor: EDITOR_PATH
|
||||
pager: PAGER_PATH
|
||||
cheatpaths:
|
||||
- name: community
|
||||
path: COMMUNITY_PATH
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
- name: personal
|
||||
path: PERSONAL_PATH
|
||||
tags: [ personal ]
|
||||
readonly: false
|
||||
`,
|
||||
confpath: filepath.Join(tempDir, "conf1", "conf.yml"),
|
||||
userInput: "n\n",
|
||||
wantErr: false,
|
||||
checkFiles: []string{"conf1/conf.yml", "conf1/cheatsheets/personal", "conf1/cheatsheets/work"},
|
||||
dontWantFiles: []string{"conf1/cheatsheets/community"},
|
||||
},
|
||||
{
|
||||
name: "user accepts but clone fails",
|
||||
configs: `---
|
||||
cheatpaths:
|
||||
- name: community
|
||||
path: COMMUNITY_PATH
|
||||
`,
|
||||
confpath: filepath.Join(tempDir, "conf2", "conf.yml"),
|
||||
userInput: "y\n",
|
||||
wantErr: true,
|
||||
wantInErr: "failed to clone cheatsheets",
|
||||
},
|
||||
{
|
||||
name: "invalid config path",
|
||||
configs: "test",
|
||||
// /dev/null/... is truly uncreatable on Unix;
|
||||
// NUL\... is uncreatable on Windows
|
||||
confpath: func() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `NUL\impossible\conf.yml`
|
||||
}
|
||||
return "/dev/null/impossible/conf.yml"
|
||||
}(),
|
||||
userInput: "n\n",
|
||||
wantErr: true,
|
||||
wantInErr: "failed to create",
|
||||
},
|
||||
}
|
||||
|
||||
// Pre-create a .git dir inside the community path so go-git's PlainClone
|
||||
// returns ErrRepositoryAlreadyExists (otherwise, on CI runners with
|
||||
// network access, the real clone succeeds and the test fails)
|
||||
fakeGitDir := filepath.Join(tempDir, "conf2", "cheatsheets", "community", ".git")
|
||||
if err := os.MkdirAll(fakeGitDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create fake .git dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(fakeGitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write fake HEAD: %v", err)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create stdin pipe
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
|
||||
// Create stdout pipe to suppress output
|
||||
_, wOut, _ := os.Pipe()
|
||||
os.Stdout = wOut
|
||||
|
||||
// Write user input
|
||||
go func() {
|
||||
defer w.Close()
|
||||
io.WriteString(w, tt.userInput)
|
||||
}()
|
||||
|
||||
// Run the installer
|
||||
err := Run(tt.configs, tt.confpath)
|
||||
|
||||
// Close pipes
|
||||
wOut.Close()
|
||||
|
||||
// Check error
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil && tt.wantInErr != "" && !strings.Contains(err.Error(), tt.wantInErr) {
|
||||
t.Errorf("Run() error = %v, want error containing %q", err, tt.wantInErr)
|
||||
}
|
||||
|
||||
// Check created files
|
||||
for _, file := range tt.checkFiles {
|
||||
path := filepath.Join(tempDir, file)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Errorf("expected file %s to exist, but it doesn't", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Check files that shouldn't exist
|
||||
for _, file := range tt.dontWantFiles {
|
||||
path := filepath.Join(tempDir, file)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Errorf("expected file %s to not exist, but it does", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPromptError(t *testing.T) {
|
||||
// Save original stdin
|
||||
oldStdin := os.Stdin
|
||||
defer func() {
|
||||
os.Stdin = oldStdin
|
||||
}()
|
||||
|
||||
// Close stdin to cause prompt error
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
r.Close()
|
||||
w.Close()
|
||||
|
||||
tempDir, _ := os.MkdirTemp("", "cheat-installer-prompt-test-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
err := Run("test", filepath.Join(tempDir, "conf.yml"))
|
||||
if err == nil {
|
||||
t.Error("expected error when prompt fails, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to prompt") {
|
||||
t.Errorf("expected 'failed to prompt' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStringReplacements(t *testing.T) {
|
||||
// Test that path replacements work correctly
|
||||
configs := `---
|
||||
editor: EDITOR_PATH
|
||||
pager: PAGER_PATH
|
||||
cheatpaths:
|
||||
- name: personal
|
||||
path: PERSONAL_PATH
|
||||
tags: [ personal ]
|
||||
readonly: false
|
||||
- name: work
|
||||
path: WORK_PATH
|
||||
tags: [ work ]
|
||||
readonly: false
|
||||
- name: community
|
||||
path: COMMUNITY_PATH
|
||||
tags: [ community ]
|
||||
readonly: true
|
||||
`
|
||||
|
||||
// Create temp directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-installer-replace-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
confpath := filepath.Join(tempDir, "conf.yml")
|
||||
confdir := filepath.Dir(confpath)
|
||||
|
||||
// Expected paths
|
||||
expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal")
|
||||
|
||||
// Save original stdin/stdout
|
||||
oldStdin := os.Stdin
|
||||
oldStdout := os.Stdout
|
||||
defer func() {
|
||||
os.Stdin = oldStdin
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
// Create stdin pipe with "n" answer
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
go func() {
|
||||
defer w.Close()
|
||||
io.WriteString(w, "n\n")
|
||||
}()
|
||||
|
||||
// Suppress stdout
|
||||
_, wOut, _ := os.Pipe()
|
||||
os.Stdout = wOut
|
||||
defer wOut.Close()
|
||||
|
||||
// Run installer
|
||||
err = Run(configs, confpath)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the created config file
|
||||
content, err := os.ReadFile(confpath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config file: %v", err)
|
||||
}
|
||||
|
||||
// Check replacements
|
||||
contentStr := string(content)
|
||||
if strings.Contains(contentStr, "COMMUNITY_PATH") {
|
||||
t.Error("COMMUNITY_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "PERSONAL_PATH") {
|
||||
t.Error("PERSONAL_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "EDITOR_PATH") && !strings.Contains(contentStr, fmt.Sprintf("editor: %s", "")) {
|
||||
t.Error("EDITOR_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) {
|
||||
t.Error("PAGER_PATH was not replaced")
|
||||
}
|
||||
if strings.Contains(contentStr, "WORK_PATH") {
|
||||
t.Error("WORK_PATH was not replaced")
|
||||
}
|
||||
|
||||
// Verify community path is commented out (user declined)
|
||||
if strings.Contains(contentStr, " - name: community") {
|
||||
t.Error("expected community cheatpath to be commented out when declined")
|
||||
}
|
||||
if !strings.Contains(contentStr, " #- name: community") {
|
||||
t.Error("expected commented-out community cheatpath")
|
||||
}
|
||||
if !strings.Contains(contentStr, expectedPersonal) {
|
||||
t.Errorf("expected personal path %q in config", expectedPersonal)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package mock implements mock functions used in unit-tests.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
@@ -15,7 +15,7 @@ func Path(filename string) string {
|
||||
|
||||
// compute the mock path
|
||||
file, err := filepath.Abs(
|
||||
path.Join(
|
||||
filepath.Join(
|
||||
filepath.Dir(thisfile),
|
||||
"../../mocks",
|
||||
filename,
|
||||
|
||||
26
internal/repo/clone.go
Normal file
26
internal/repo/clone.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Package repo implements functions pertaining to the management of git repos.
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
// Clone clones the community cheatsheets repository to the specified directory
|
||||
func Clone(dir string) error {
|
||||
|
||||
// clone the community cheatsheets
|
||||
_, err := git.PlainClone(dir, false, &git.CloneOptions{
|
||||
URL: "https://github.com/cheat/cheatsheets.git",
|
||||
Depth: 1,
|
||||
Progress: os.Stdout,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
80
internal/repo/clone_integration_test.go
Normal file
80
internal/repo/clone_integration_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCloneIntegration performs a real clone operation to verify functionality
|
||||
// Run with: go test -tags=integration ./internal/repo -v -run TestCloneIntegration
|
||||
func TestCloneIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create a temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "cheat-clone-integration-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
destDir := filepath.Join(tmpDir, "cheatsheets")
|
||||
|
||||
t.Logf("Cloning to: %s", destDir)
|
||||
|
||||
// Perform the actual clone
|
||||
err = Clone(destDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Clone() failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the clone succeeded
|
||||
info, err := os.Stat(destDir)
|
||||
if err != nil {
|
||||
t.Fatalf("destination directory not created: %v", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
t.Fatal("destination is not a directory")
|
||||
}
|
||||
|
||||
// Check for .git directory
|
||||
gitDir := filepath.Join(destDir, ".git")
|
||||
if _, err := os.Stat(gitDir); err != nil {
|
||||
t.Error(".git directory not found")
|
||||
}
|
||||
|
||||
// Check for some expected cheatsheets
|
||||
expectedFiles := []string{
|
||||
"bash", // bash cheatsheet should exist
|
||||
"git", // git cheatsheet should exist
|
||||
"ls", // ls cheatsheet should exist
|
||||
}
|
||||
|
||||
foundCount := 0
|
||||
for _, file := range expectedFiles {
|
||||
path := filepath.Join(destDir, file)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
foundCount++
|
||||
}
|
||||
}
|
||||
|
||||
if foundCount < 2 {
|
||||
t.Errorf("expected at least 2 common cheatsheets, found %d", foundCount)
|
||||
}
|
||||
|
||||
t.Log("Clone integration test passed!")
|
||||
|
||||
// Test cloning to existing directory (should fail)
|
||||
err = Clone(destDir)
|
||||
if err == nil {
|
||||
t.Error("expected error when cloning to existing repository, got nil")
|
||||
} else {
|
||||
t.Logf("Expected error when cloning to existing dir: %v", err)
|
||||
}
|
||||
}
|
||||
53
internal/repo/clone_test.go
Normal file
53
internal/repo/clone_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClone tests the Clone function
|
||||
func TestClone(t *testing.T) {
|
||||
// This test requires network access, so we'll only test error cases
|
||||
// that don't require actual cloning
|
||||
|
||||
t.Run("clone to read-only directory", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod does not restrict writes on Windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("Cannot test read-only directory as root")
|
||||
}
|
||||
|
||||
// Create a temporary directory
|
||||
tempDir, err := os.MkdirTemp("", "cheat-clone-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a read-only subdirectory
|
||||
readOnlyDir := filepath.Join(tempDir, "readonly")
|
||||
if err := os.Mkdir(readOnlyDir, 0555); err != nil {
|
||||
t.Fatalf("failed to create read-only dir: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to clone to read-only directory
|
||||
targetDir := filepath.Join(readOnlyDir, "cheatsheets")
|
||||
err = Clone(targetDir)
|
||||
|
||||
// Should fail because we can't write to read-only directory
|
||||
if err == nil {
|
||||
t.Error("expected error when cloning to read-only directory, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clone to invalid path", func(t *testing.T) {
|
||||
// Try to clone to a path with null bytes (invalid on most filesystems)
|
||||
err := Clone("/tmp/invalid\x00path")
|
||||
if err == nil {
|
||||
t.Error("expected error with invalid path, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
110
internal/repo/gitdir.go
Normal file
110
internal/repo/gitdir.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GitDir returns `true` if we are iterating over a directory contained within
|
||||
// a repositories `.git` directory.
|
||||
func GitDir(path string) (bool, error) {
|
||||
|
||||
/*
|
||||
A bit of context is called for here, because this functionality has
|
||||
previously caused a number of tricky, subtle bugs.
|
||||
|
||||
Fundamentally, here we are simply trying to avoid walking over the
|
||||
contents of the `.git` directory. Doing so potentially makes
|
||||
hundreds/thousands of needless syscalls, and can noticeably harm
|
||||
performance on machines with slow disks.
|
||||
|
||||
The earliest effort to solve this problem involved simply returning
|
||||
`fs.SkipDir` when the cheatsheet file path began with `.`, signifying a
|
||||
hidden directory. This, however, caused two problems:
|
||||
|
||||
1. The `.cheat` directory was ignored
|
||||
2. Cheatsheets installed by `brew` (which were by default installed to
|
||||
`~/.config/cheat`) were ignored
|
||||
|
||||
See: https://github.com/cheat/cheat/issues/690
|
||||
|
||||
To remedy this, the exclusion criteria were narrowed, and the search
|
||||
for a literal `.` was replaced with a search for a literal `.git`.
|
||||
This, however, broke user installations that stored cheatsheets in
|
||||
`git` submodules, because such an installation would contain a `.git`
|
||||
file that pointed to the upstream repository.
|
||||
|
||||
See: https://github.com/cheat/cheat/issues/694
|
||||
|
||||
The next attempt at solving this was to search for a `.git` literal
|
||||
string in the cheatsheet file path. If a match was not found, we would
|
||||
continue to walk the directory, as before.
|
||||
|
||||
If a match *was* found, we determined whether `.git` referred to a file
|
||||
or directory, and would only stop walking the path in the latter case.
|
||||
|
||||
This, however, caused crashes if a cheatpath contained a `.gitignore`
|
||||
file. (Presumably, a crash would likewise occur on the presence of
|
||||
`.gitattributes`, `.gitmodules`, etc.)
|
||||
|
||||
See: https://github.com/cheat/cheat/issues/699
|
||||
|
||||
Accounting for all of the above (hopefully?), the current solution is
|
||||
not to search for `.git`, but `.git/` (including the directory
|
||||
separator), and then only ceasing to walk the directory on a match.
|
||||
|
||||
To summarize, this code must account for the following possibilities:
|
||||
|
||||
1. A cheatpath is not a repository
|
||||
2. A cheatpath is a repository
|
||||
3. A cheatpath is a repository, and contains a `.git*` file
|
||||
4. A cheatpath is a submodule
|
||||
5. A cheatpath is a hidden directory
|
||||
|
||||
Care must be taken to support the above on both Unix and Windows
|
||||
systems, which have different directory separators and line-endings.
|
||||
|
||||
There is a lot of nuance to all of this, and it would be worthwhile to
|
||||
do two things to stop writing bugs here:
|
||||
|
||||
1. Build integration tests around all of this
|
||||
2. Discard string-matching solutions entirely, and use `go-git` instead
|
||||
|
||||
NB: A reasonable smoke-test for ensuring that skipping is being applied
|
||||
correctly is to run the following command:
|
||||
|
||||
make && strace ./dist/cheat -l | wc -l
|
||||
|
||||
That check should be run twice: once normally, and once after
|
||||
commenting out the "skip" check in `sheets.Load`.
|
||||
|
||||
The specific line counts don't matter; what matters is that the number
|
||||
of syscalls should be significantly lower with the skip check enabled.
|
||||
*/
|
||||
|
||||
// determine if the literal string `.git` appears within `path`
|
||||
pos := strings.Index(path, fmt.Sprintf(".git%s", string(os.PathSeparator)))
|
||||
|
||||
// if it does not, we know for certain that we are not within a `.git`
|
||||
// directory.
|
||||
if pos == -1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If `path` does contain the string `.git`, we need to determine if we're
|
||||
// inside of a `.git` directory, or if `path` points to a cheatsheet that's
|
||||
// stored within a `git` submodule.
|
||||
//
|
||||
// See: https://github.com/cheat/cheat/issues/694
|
||||
|
||||
// truncate `path` to the occurrence of `.git`
|
||||
f, err := os.Stat(path[:pos+5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to stat path %s: %v", path, err)
|
||||
}
|
||||
|
||||
// return true or false depending on whether the truncated path is a
|
||||
// directory
|
||||
return f.Mode().IsDir(), nil
|
||||
}
|
||||
177
internal/repo/gitdir_test.go
Normal file
177
internal/repo/gitdir_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGitDir(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create test directory structure
|
||||
testDirs := []string{
|
||||
filepath.Join(tempDir, ".git"),
|
||||
filepath.Join(tempDir, ".git", "objects"),
|
||||
filepath.Join(tempDir, ".git", "refs"),
|
||||
filepath.Join(tempDir, "regular"),
|
||||
filepath.Join(tempDir, "regular", ".git"),
|
||||
filepath.Join(tempDir, "submodule"),
|
||||
}
|
||||
|
||||
for _, dir := range testDirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("failed to create dir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test files
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(tempDir, ".gitignore"): "*.tmp\n",
|
||||
filepath.Join(tempDir, ".gitattributes"): "* text=auto\n",
|
||||
filepath.Join(tempDir, "submodule", ".git"): "gitdir: ../.git/modules/submodule\n",
|
||||
filepath.Join(tempDir, "regular", "sheet.txt"): "content\n",
|
||||
}
|
||||
|
||||
for file, content := range testFiles {
|
||||
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "not in git directory",
|
||||
path: filepath.Join(tempDir, "regular", "sheet.txt"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "in .git directory",
|
||||
path: filepath.Join(tempDir, ".git", "objects", "file"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "in .git/refs directory",
|
||||
path: filepath.Join(tempDir, ".git", "refs", "heads", "main"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: ".gitignore file",
|
||||
path: filepath.Join(tempDir, ".gitignore"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: ".gitattributes file",
|
||||
path: filepath.Join(tempDir, ".gitattributes"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "submodule with .git file",
|
||||
path: filepath.Join(tempDir, "submodule", "sheet.txt"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "path with .git in middle",
|
||||
path: filepath.Join(tempDir, "regular", ".git", "sheet.txt"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent path without .git",
|
||||
path: filepath.Join(tempDir, "nonexistent", "file"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GitDir(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GitDir() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GitDir() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDirEdgeCases(t *testing.T) {
|
||||
// Test with paths that have .git but not as a directory separator
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "file ending with .git",
|
||||
path: "/tmp/myfile.git",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "directory ending with .git",
|
||||
path: "/tmp/myrepo.git",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: ".github directory",
|
||||
path: "/tmp/.github/workflows",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "legitimate.git-repo name",
|
||||
path: "/tmp/legitimate.git-repo/file",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GitDir(tt.path)
|
||||
if err != nil {
|
||||
// It's ok if the path doesn't exist for these edge case tests
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDirPathSeparator(t *testing.T) {
|
||||
// Test that the function correctly uses os.PathSeparator
|
||||
// This is important for cross-platform compatibility
|
||||
|
||||
// Create a path with the wrong separator for the current OS
|
||||
var wrongSep string
|
||||
if os.PathSeparator == '/' {
|
||||
wrongSep = `\`
|
||||
} else {
|
||||
wrongSep = `/`
|
||||
}
|
||||
|
||||
// Path with wrong separator should not be detected as git dir
|
||||
path := fmt.Sprintf("some%spath%s.git%sfile", wrongSep, wrongSep, wrongSep)
|
||||
isGit, err := GitDir(path)
|
||||
|
||||
if err != nil {
|
||||
// Path doesn't exist, which is fine
|
||||
return
|
||||
}
|
||||
|
||||
if isGit {
|
||||
t.Errorf("GitDir() incorrectly detected git dir with wrong path separator")
|
||||
}
|
||||
}
|
||||
1
internal/repo/update.go
Normal file
1
internal/repo/update.go
Normal file
@@ -0,0 +1 @@
|
||||
package repo
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/cheat/cheat/internal/config"
|
||||
|
||||
"github.com/alecthomas/chroma/quick"
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
)
|
||||
|
||||
// Colorize applies syntax-highlighting to a cheatsheet's Text.
|
||||
|
||||
@@ -32,3 +32,29 @@ func TestColorize(t *testing.T) {
|
||||
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestColorizeError tests the error handling in Colorize
|
||||
func TestColorizeError(_ *testing.T) {
|
||||
// Create a sheet with content
|
||||
sheet := Sheet{
|
||||
Text: "some text",
|
||||
Syntax: "invalidlexer12345", // Use an invalid lexer that might cause issues
|
||||
}
|
||||
|
||||
// Create a config with invalid formatter/style
|
||||
conf := config.Config{
|
||||
Formatter: "invalidformatter",
|
||||
Style: "invalidstyle",
|
||||
}
|
||||
|
||||
// Store original text
|
||||
originalText := sheet.Text
|
||||
|
||||
// Colorize should not panic even with invalid settings
|
||||
sheet.Colorize(conf)
|
||||
|
||||
// The text might be unchanged if there was an error, or it might be colorized
|
||||
// We're mainly testing that it doesn't panic
|
||||
_ = sheet.Text
|
||||
_ = originalText
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ func (s *Sheet) Copy(dest string) error {
|
||||
// copy file contents
|
||||
_, err = io.Copy(outfile, infile)
|
||||
if err != nil {
|
||||
// Clean up the partially written file on error
|
||||
os.Remove(dest)
|
||||
return fmt.Errorf(
|
||||
"failed to copy file: infile: %s, outfile: %s, err: %v",
|
||||
s.Path,
|
||||
|
||||
192
internal/sheet/copy_error_test.go
Normal file
192
internal/sheet/copy_error_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCopyErrors tests error cases for the Copy method
|
||||
func TestCopyErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (*Sheet, string, func())
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "source file does not exist",
|
||||
setup: func() (*Sheet, string, func()) {
|
||||
// Create a sheet with non-existent path
|
||||
sheet := &Sheet{
|
||||
Title: "test",
|
||||
Path: "/non/existent/file.txt",
|
||||
CheatPath: "test",
|
||||
}
|
||||
dest := filepath.Join(os.TempDir(), "copy-test-dest.txt")
|
||||
cleanup := func() {
|
||||
os.Remove(dest)
|
||||
}
|
||||
return sheet, dest, cleanup
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "failed to open cheatsheet",
|
||||
},
|
||||
{
|
||||
name: "destination directory creation fails",
|
||||
setup: func() (*Sheet, string, func()) {
|
||||
// Create a source file
|
||||
src, err := os.CreateTemp("", "copy-test-src-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
src.WriteString("test content")
|
||||
src.Close()
|
||||
|
||||
sheet := &Sheet{
|
||||
Title: "test",
|
||||
Path: src.Name(),
|
||||
CheatPath: "test",
|
||||
}
|
||||
|
||||
// Create a file where we want a directory
|
||||
blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file")
|
||||
if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil {
|
||||
t.Fatalf("failed to create blocker file: %v", err)
|
||||
}
|
||||
|
||||
// Try to create dest under the blocker file (will fail)
|
||||
dest := filepath.Join(blockerFile, "subdir", "dest.txt")
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(src.Name())
|
||||
os.Remove(blockerFile)
|
||||
}
|
||||
return sheet, dest, cleanup
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "failed to create directory",
|
||||
},
|
||||
{
|
||||
name: "destination file creation fails",
|
||||
setup: func() (*Sheet, string, func()) {
|
||||
// Create a source file
|
||||
src, err := os.CreateTemp("", "copy-test-src-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
src.WriteString("test content")
|
||||
src.Close()
|
||||
|
||||
sheet := &Sheet{
|
||||
Title: "test",
|
||||
Path: src.Name(),
|
||||
CheatPath: "test",
|
||||
}
|
||||
|
||||
// Create a directory where we want the file
|
||||
destDir := filepath.Join(os.TempDir(), "copy-test-dir")
|
||||
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
|
||||
t.Fatalf("failed to create dest dir: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(src.Name())
|
||||
os.RemoveAll(destDir)
|
||||
}
|
||||
return sheet, destDir, cleanup
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "failed to create outfile",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sheet, dest, cleanup := tt.setup()
|
||||
defer cleanup()
|
||||
|
||||
err := sheet.Copy(dest)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Copy() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil && tt.errMsg != "" {
|
||||
if !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("Copy() error = %v, want error containing %q", err, tt.errMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyIOError tests the io.Copy error case
|
||||
func TestCopyIOError(t *testing.T) {
|
||||
// This is difficult to test without mocking io.Copy
|
||||
// The error case would occur if the source file is modified
|
||||
// or removed after opening but before copying
|
||||
t.Skip("Skipping io.Copy error test - requires file system race condition")
|
||||
}
|
||||
|
||||
// TestCopyCleanupOnError verifies that partially written files are cleaned up on error
|
||||
func TestCopyCleanupOnError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod does not restrict reads on Windows")
|
||||
}
|
||||
|
||||
// Create a source file that we'll make unreadable after opening
|
||||
src, err := os.CreateTemp("", "copy-test-cleanup-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(src.Name())
|
||||
|
||||
// Write some content
|
||||
content := "test content for cleanup"
|
||||
if _, err := src.WriteString(content); err != nil {
|
||||
t.Fatalf("failed to write content: %v", err)
|
||||
}
|
||||
src.Close()
|
||||
|
||||
sheet := &Sheet{
|
||||
Title: "test",
|
||||
Path: src.Name(),
|
||||
CheatPath: "test",
|
||||
}
|
||||
|
||||
// Destination path
|
||||
dest := filepath.Join(os.TempDir(), "copy-cleanup-test.txt")
|
||||
defer os.Remove(dest) // Clean up if test fails
|
||||
|
||||
// Make the source file unreadable (simulating a read error during copy)
|
||||
// This is platform-specific, but should work on Unix-like systems
|
||||
if err := os.Chmod(src.Name(), 0000); err != nil {
|
||||
t.Skip("Cannot change file permissions on this platform")
|
||||
}
|
||||
defer os.Chmod(src.Name(), 0644) // Restore permissions for cleanup
|
||||
|
||||
// Attempt to copy - this should fail during io.Copy
|
||||
err = sheet.Copy(dest)
|
||||
if err == nil {
|
||||
t.Error("Expected Copy to fail with permission error")
|
||||
}
|
||||
|
||||
// Verify the destination file was cleaned up
|
||||
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||
t.Error("Destination file should have been removed after copy failure")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
65
internal/sheet/doc.go
Normal file
65
internal/sheet/doc.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package sheet provides functionality for parsing and managing individual cheat sheets.
|
||||
//
|
||||
// A sheet represents a single cheatsheet file containing helpful commands, notes,
|
||||
// or documentation. Sheets can include optional YAML frontmatter for metadata
|
||||
// such as tags and syntax highlighting preferences.
|
||||
//
|
||||
// # Sheet Format
|
||||
//
|
||||
// Sheets are plain text files that may begin with YAML frontmatter:
|
||||
//
|
||||
// ---
|
||||
// syntax: bash
|
||||
// tags: [networking, linux, ssh]
|
||||
// ---
|
||||
// # Connect to remote server
|
||||
// ssh user@hostname
|
||||
//
|
||||
// # Copy files over SSH
|
||||
// scp local_file user@hostname:/remote/path
|
||||
//
|
||||
// The frontmatter is optional. If omitted, the sheet will use default values.
|
||||
//
|
||||
// # Core Types
|
||||
//
|
||||
// The Sheet type contains:
|
||||
// - Title: The sheet's name (derived from filename)
|
||||
// - Path: Full filesystem path to the sheet
|
||||
// - Text: The content of the sheet (without frontmatter)
|
||||
// - Tags: Categories assigned to the sheet
|
||||
// - Syntax: Language hint for syntax highlighting
|
||||
// - ReadOnly: Whether the sheet can be modified
|
||||
//
|
||||
// Key Functions
|
||||
//
|
||||
// - New: Creates a new Sheet from a file path
|
||||
// - Parse: Extracts frontmatter and content from sheet text
|
||||
// - Search: Searches sheet content using regular expressions
|
||||
// - Colorize: Applies syntax highlighting to sheet content
|
||||
//
|
||||
// # Syntax Highlighting
|
||||
//
|
||||
// The package integrates with the Chroma library to provide syntax highlighting.
|
||||
// Supported languages include bash, python, go, javascript, and many others.
|
||||
// The syntax can be specified in the frontmatter or auto-detected.
|
||||
//
|
||||
// Example Usage
|
||||
//
|
||||
// // Load a sheet from disk
|
||||
// s, err := sheet.New("/path/to/sheet", []string{"personal"}, false)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Search for content
|
||||
// matches, err := s.Search("ssh", false)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Apply syntax highlighting
|
||||
// colorized, err := s.Colorize(config)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
package sheet
|
||||
@@ -2,18 +2,17 @@ package sheet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v1"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Parse parses cheatsheet frontmatter
|
||||
func parse(markdown string) (frontmatter, string, error) {
|
||||
|
||||
// determine the appropriate line-break for the platform
|
||||
// detect the line-break style used in the content
|
||||
linebreak := "\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
if strings.Contains(markdown, "\r\n") {
|
||||
linebreak = "\r\n"
|
||||
}
|
||||
|
||||
|
||||
48
internal/sheet/parse_extended_test.go
Normal file
48
internal/sheet/parse_extended_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseWindowsLineEndings tests parsing with Windows line endings
|
||||
func TestParseWindowsLineEndings(t *testing.T) {
|
||||
// stub our cheatsheet content with Windows line endings
|
||||
markdown := "---\r\nsyntax: go\r\ntags: [ test ]\r\n---\r\nTo foo the bar: baz"
|
||||
|
||||
// parse the frontmatter
|
||||
fm, text, err := parse(markdown)
|
||||
|
||||
// assert expectations
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse markdown: %v", err)
|
||||
}
|
||||
|
||||
want := "To foo the bar: baz"
|
||||
if text != want {
|
||||
t.Errorf("failed to parse text: want: %s, got: %s", want, text)
|
||||
}
|
||||
|
||||
want = "go"
|
||||
if fm.Syntax != want {
|
||||
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInvalidYAML tests parsing with invalid YAML in frontmatter
|
||||
func TestParseInvalidYAML(t *testing.T) {
|
||||
// stub our cheatsheet content with invalid YAML
|
||||
markdown := `---
|
||||
syntax: go
|
||||
tags: [ test
|
||||
unclosed bracket
|
||||
---
|
||||
To foo the bar: baz`
|
||||
|
||||
// parse the frontmatter
|
||||
_, _, err := parse(markdown)
|
||||
|
||||
// assert that an error was returned for invalid YAML
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML, got nil")
|
||||
}
|
||||
}
|
||||
132
internal/sheet/parse_fuzz_test.go
Normal file
132
internal/sheet/parse_fuzz_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzParse tests the parse function with fuzzing to uncover edge cases
|
||||
// and potential panics in YAML frontmatter parsing
|
||||
func FuzzParse(f *testing.F) {
|
||||
// Add seed corpus with various valid and edge case inputs
|
||||
// Valid frontmatter
|
||||
f.Add("---\nsyntax: go\n---\nContent")
|
||||
f.Add("---\ntags: [a, b]\n---\n")
|
||||
f.Add("---\nsyntax: bash\ntags: [linux, shell]\n---\n#!/bin/bash\necho hello")
|
||||
|
||||
// No frontmatter
|
||||
f.Add("No frontmatter here")
|
||||
f.Add("")
|
||||
f.Add("Just plain text\nwith multiple lines")
|
||||
|
||||
// Edge cases with delimiters
|
||||
f.Add("---")
|
||||
f.Add("---\n")
|
||||
f.Add("---\n---")
|
||||
f.Add("---\n---\n")
|
||||
f.Add("---\n---\n---")
|
||||
f.Add("---\n---\n---\n---")
|
||||
f.Add("------\n------")
|
||||
|
||||
// Invalid YAML
|
||||
f.Add("---\n{invalid yaml\n---\n")
|
||||
f.Add("---\nsyntax: \"unclosed quote\n---\n")
|
||||
f.Add("---\ntags: [a, b,\n---\n")
|
||||
|
||||
// Windows line endings
|
||||
f.Add("---\r\nsyntax: go\r\n---\r\nContent")
|
||||
f.Add("---\r\n---\r\n")
|
||||
|
||||
// Mixed line endings
|
||||
f.Add("---\nsyntax: go\r\n---\nContent")
|
||||
f.Add("---\r\nsyntax: go\n---\r\nContent")
|
||||
|
||||
// Unicode and special characters
|
||||
f.Add("---\ntags: [emoji, 🎉]\n---\n")
|
||||
f.Add("---\nsyntax: 中文\n---\n")
|
||||
f.Add("---\ntags: [\x00, \x01]\n---\n")
|
||||
|
||||
// Very long inputs
|
||||
f.Add("---\ntags: [" + strings.Repeat("a,", 1000) + "a]\n---\n")
|
||||
f.Add("---\n" + strings.Repeat("field: value\n", 1000) + "---\n")
|
||||
|
||||
// Nested structures
|
||||
f.Add("---\ntags:\n - nested\n - list\n---\n")
|
||||
f.Add("---\nmeta:\n author: test\n version: 1.0\n---\n")
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
// The parse function should never panic, regardless of input
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("parse panicked with input %q: %v", input, r)
|
||||
}
|
||||
}()
|
||||
|
||||
fm, text, err := parse(input)
|
||||
|
||||
// Verify invariants
|
||||
if err == nil {
|
||||
// If parsing succeeded, validate the result
|
||||
|
||||
// The returned text should be a suffix of the input
|
||||
// (either the whole input if no frontmatter, or the part after frontmatter)
|
||||
if !strings.HasSuffix(input, text) && text != input {
|
||||
t.Errorf("returned text %q is not a valid suffix of input %q", text, input)
|
||||
}
|
||||
|
||||
// If input starts with delimiter and has valid frontmatter,
|
||||
// text should be shorter than input
|
||||
if strings.HasPrefix(input, "---\n") || strings.HasPrefix(input, "---\r\n") {
|
||||
if len(fm.Tags) > 0 || fm.Syntax != "" {
|
||||
// We successfully parsed frontmatter, so text should be shorter
|
||||
if len(text) >= len(input) {
|
||||
t.Errorf("text length %d should be less than input length %d when frontmatter is parsed",
|
||||
len(text), len(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Tags can be nil when frontmatter is not present or empty
|
||||
// This is expected behavior in Go for uninitialized slices
|
||||
} else {
|
||||
// If parsing failed, the original input should be returned as text
|
||||
if text != input {
|
||||
t.Errorf("on error, text should equal input: got %q, want %q", text, input)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzParseDelimiterHandling specifically tests delimiter edge cases
|
||||
func FuzzParseDelimiterHandling(f *testing.F) {
|
||||
// Seed corpus focusing on delimiter variations
|
||||
f.Add("---", "content")
|
||||
f.Add("", "---")
|
||||
f.Add("---", "---")
|
||||
f.Add("", "")
|
||||
|
||||
f.Fuzz(func(t *testing.T, prefix string, suffix string) {
|
||||
// Build input with controllable parts around delimiters
|
||||
inputs := []string{
|
||||
prefix + "---\n" + suffix,
|
||||
prefix + "---\r\n" + suffix,
|
||||
prefix + "---\n---\n" + suffix,
|
||||
prefix + "---\r\n---\r\n" + suffix,
|
||||
prefix + "---\n" + "yaml: data\n" + "---\n" + suffix,
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("parse panicked with constructed input: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
_, _, _ = parse(input)
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
func (s *Sheet) Search(reg *regexp.Regexp) string {
|
||||
|
||||
// record matches
|
||||
matches := ""
|
||||
var matches []string
|
||||
|
||||
// search through the cheatsheet's text line by line
|
||||
for _, line := range strings.Split(s.Text, "\n\n") {
|
||||
|
||||
// exit early if the line doesn't match the regex
|
||||
// save matching lines
|
||||
if reg.MatchString(line) {
|
||||
matches += line + "\n\n"
|
||||
matches = append(matches, line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(matches)
|
||||
// Join matches with the same delimiter used for splitting
|
||||
return strings.Join(matches, "\n\n")
|
||||
}
|
||||
|
||||
190
internal/sheet/search_fuzz_test.go
Normal file
190
internal/sheet/search_fuzz_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FuzzSearchRegex tests the regex compilation and search functionality
|
||||
// to ensure it handles malformed patterns gracefully and doesn't suffer
|
||||
// from catastrophic backtracking
|
||||
func FuzzSearchRegex(f *testing.F) {
|
||||
// Add seed corpus with various regex patterns
|
||||
// Valid patterns
|
||||
f.Add("test", "This is a test string")
|
||||
f.Add("(?i)test", "This is a TEST string")
|
||||
f.Add("foo|bar", "foo and bar")
|
||||
f.Add("^start", "start of line\nnext line")
|
||||
f.Add("end$", "at the end\nnext line")
|
||||
f.Add("\\d+", "123 numbers 456")
|
||||
f.Add("[a-z]+", "lowercase UPPERCASE")
|
||||
|
||||
// Edge cases and potentially problematic patterns
|
||||
f.Add("", "empty pattern")
|
||||
f.Add(".", "any character")
|
||||
f.Add(".*", "match everything")
|
||||
f.Add(".+", "match something")
|
||||
f.Add("\\", "backslash")
|
||||
f.Add("(", "unclosed paren")
|
||||
f.Add(")", "unmatched paren")
|
||||
f.Add("[", "unclosed bracket")
|
||||
f.Add("]", "unmatched bracket")
|
||||
f.Add("[^]", "negated empty class")
|
||||
f.Add("(?", "incomplete group")
|
||||
|
||||
// Patterns that might cause performance issues
|
||||
f.Add("(a+)+", "aaaaaaaaaaaaaaaaaaaaaaaab")
|
||||
f.Add("(a*)*", "aaaaaaaaaaaaaaaaaaaaaaaab")
|
||||
f.Add("(a|a)*", "aaaaaaaaaaaaaaaaaaaaaaaab")
|
||||
f.Add("(.*)*", "any text here")
|
||||
f.Add("(\\d+)+", "123456789012345678901234567890x")
|
||||
|
||||
// Unicode patterns
|
||||
f.Add("☺", "Unicode ☺ smiley")
|
||||
f.Add("[一-龯]", "Chinese 中文 characters")
|
||||
f.Add("\\p{L}+", "Unicode letters")
|
||||
|
||||
// Very long patterns
|
||||
f.Add(strings.Repeat("a", 1000), "long pattern")
|
||||
f.Add(strings.Repeat("(a|b)", 100), "complex pattern")
|
||||
|
||||
f.Fuzz(func(t *testing.T, pattern string, text string) {
|
||||
// Test 1: Regex compilation should not panic
|
||||
var reg *regexp.Regexp
|
||||
var compileErr error
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("regexp.Compile panicked with pattern %q: %v", pattern, r)
|
||||
}
|
||||
}()
|
||||
|
||||
reg, compileErr = regexp.Compile(pattern)
|
||||
}()
|
||||
|
||||
// If compilation failed, that's OK - we're testing error handling
|
||||
if compileErr != nil {
|
||||
// This is expected for invalid patterns
|
||||
return
|
||||
}
|
||||
|
||||
// Test 2: Create a sheet and test Search method
|
||||
sheet := Sheet{
|
||||
Title: "test",
|
||||
Text: text,
|
||||
}
|
||||
|
||||
// Search should not panic
|
||||
var result string
|
||||
done := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Search panicked with pattern %q on text %q: %v", pattern, text, r)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
result = sheet.Search(reg)
|
||||
}()
|
||||
|
||||
// Timeout after 100ms to catch catastrophic backtracking
|
||||
select {
|
||||
case <-done:
|
||||
// Search completed successfully
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Errorf("Search timed out (possible catastrophic backtracking) with pattern %q on text %q", pattern, text)
|
||||
}
|
||||
|
||||
// Test 3: Verify search result invariants
|
||||
if result != "" {
|
||||
// The Search function splits by "\n\n", so we need to compare using the same logic
|
||||
resultLines := strings.Split(result, "\n\n")
|
||||
textLines := strings.Split(text, "\n\n")
|
||||
|
||||
// Every result line should exist in the original text lines
|
||||
for _, rLine := range resultLines {
|
||||
found := false
|
||||
for _, tLine := range textLines {
|
||||
if rLine == tLine {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && rLine != "" {
|
||||
t.Errorf("Search result contains line not in original text: %q", rLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzSearchCatastrophicBacktracking specifically tests for regex patterns
|
||||
// that could cause performance issues
|
||||
func FuzzSearchCatastrophicBacktracking(f *testing.F) {
|
||||
// Seed with patterns known to potentially cause issues
|
||||
f.Add("a", 10, 5)
|
||||
f.Add("x", 20, 3)
|
||||
|
||||
f.Fuzz(func(t *testing.T, char string, repeats int, groups int) {
|
||||
// Limit the size to avoid memory issues in the test
|
||||
if repeats > 30 || repeats < 0 || groups > 10 || groups < 0 || len(char) > 5 {
|
||||
t.Skip("Skipping invalid or overly large test case")
|
||||
}
|
||||
|
||||
// Construct patterns that might cause backtracking
|
||||
patterns := []string{
|
||||
strings.Repeat(char, repeats),
|
||||
"(" + char + "+)+",
|
||||
"(" + char + "*)*",
|
||||
"(" + char + "|" + char + ")*",
|
||||
}
|
||||
|
||||
// Add nested groups
|
||||
if groups > 0 && groups < 10 {
|
||||
nested := char
|
||||
for i := 0; i < groups; i++ {
|
||||
nested = "(" + nested + ")+"
|
||||
}
|
||||
patterns = append(patterns, nested)
|
||||
}
|
||||
|
||||
// Test text that might trigger backtracking
|
||||
testText := strings.Repeat(char, repeats) + "x"
|
||||
|
||||
for _, pattern := range patterns {
|
||||
// Try to compile the pattern
|
||||
reg, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// Invalid pattern, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Test with timeout
|
||||
done := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Search panicked with backtracking pattern %q: %v", pattern, r)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
sheet := Sheet{Text: testText}
|
||||
_ = sheet.Search(reg)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Completed successfully
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Logf("Warning: potential backtracking issue with pattern %q (completed slowly)", pattern)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package sheet implements functions pertaining to parsing, searching, and
|
||||
// displaying cheatsheets.
|
||||
package sheet
|
||||
|
||||
import (
|
||||
|
||||
94
internal/sheet/tagged_fuzz_test.go
Normal file
94
internal/sheet/tagged_fuzz_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzTagged tests the Tagged function with potentially malicious tag inputs
|
||||
//
|
||||
// Threat model: An attacker crafts a malicious cheatsheet with specially
|
||||
// crafted tags that could cause issues when a user searches/filters by tags.
|
||||
// This is particularly relevant for shared community cheatsheets.
|
||||
func FuzzTagged(f *testing.F) {
|
||||
// Add seed corpus with potentially problematic inputs
|
||||
// These represent tags an attacker might use in a malicious cheatsheet
|
||||
f.Add("normal", "normal")
|
||||
f.Add("", "")
|
||||
f.Add(" ", " ")
|
||||
f.Add("\n", "\n")
|
||||
f.Add("\r\n", "\r\n")
|
||||
f.Add("\x00", "\x00") // Null byte
|
||||
f.Add("../../etc/passwd", "../../etc/passwd") // Path traversal attempt
|
||||
f.Add("'; DROP TABLE sheets;--", "sql") // SQL injection attempt
|
||||
f.Add("<script>alert('xss')</script>", "xss") // XSS attempt
|
||||
f.Add("${HOME}", "${HOME}") // Environment variable
|
||||
f.Add("$(whoami)", "$(whoami)") // Command substitution
|
||||
f.Add("`date`", "`date`") // Command substitution
|
||||
f.Add("\\x41\\x42", "\\x41\\x42") // Escape sequences
|
||||
f.Add("%00", "%00") // URL encoded null
|
||||
f.Add("tag\nwith\nnewlines", "tag")
|
||||
f.Add(strings.Repeat("a", 10000), "a") // Very long tag
|
||||
f.Add("🎉", "🎉") // Unicode
|
||||
f.Add("\U0001F4A9", "\U0001F4A9") // Unicode poop emoji
|
||||
f.Add("tag with spaces", "tag with spaces")
|
||||
f.Add("TAG", "tag") // Case sensitivity check
|
||||
f.Add("tag", "TAG") // Case sensitivity check
|
||||
|
||||
f.Fuzz(func(t *testing.T, sheetTag string, searchTag string) {
|
||||
// Create a sheet with the potentially malicious tag
|
||||
sheet := Sheet{
|
||||
Title: "test",
|
||||
Tags: []string{sheetTag},
|
||||
}
|
||||
|
||||
// The Tagged function should never panic regardless of input
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Tagged panicked with sheetTag=%q, searchTag=%q: %v",
|
||||
sheetTag, searchTag, r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := sheet.Tagged(searchTag)
|
||||
|
||||
// Verify the result is consistent with a simple string comparison
|
||||
expected := false
|
||||
for _, tag := range sheet.Tags {
|
||||
if tag == searchTag {
|
||||
expected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Tagged returned %v but expected %v for sheetTag=%q, searchTag=%q",
|
||||
result, expected, sheetTag, searchTag)
|
||||
}
|
||||
|
||||
// Additional invariant: Tagged should be case-sensitive
|
||||
if sheetTag != searchTag && result {
|
||||
t.Errorf("Tagged matched different strings: sheetTag=%q, searchTag=%q",
|
||||
sheetTag, searchTag)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test with multiple tags including the fuzzed one
|
||||
sheetMulti := Sheet{
|
||||
Title: "test",
|
||||
Tags: []string{"safe1", sheetTag, "safe2", sheetTag}, // Duplicate tags
|
||||
}
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Tagged panicked with multiple tags including %q: %v",
|
||||
sheetTag, r)
|
||||
}
|
||||
}()
|
||||
|
||||
_ = sheetMulti.Tagged(searchTag)
|
||||
}()
|
||||
})
|
||||
}
|
||||
4
internal/sheet/testdata/fuzz/FuzzSearchCatastrophicBacktracking/3ad1cae1b78a2478
vendored
Normal file
4
internal/sheet/testdata/fuzz/FuzzSearchCatastrophicBacktracking/3ad1cae1b78a2478
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
go test fuzz v1
|
||||
string("0")
|
||||
int(-6)
|
||||
int(5)
|
||||
3
internal/sheet/testdata/fuzz/FuzzSearchRegex/74c0a5e8e3464bfd
vendored
Normal file
3
internal/sheet/testdata/fuzz/FuzzSearchRegex/74c0a5e8e3464bfd
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
go test fuzz v1
|
||||
string(".")
|
||||
string(" 0000\n\n\n\n00000")
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package sheets implements functions pertaining to loading, sorting,
|
||||
// filtering, and tagging cheatsheets.
|
||||
package sheets
|
||||
|
||||
import (
|
||||
|
||||
65
internal/sheets/doc.go
Normal file
65
internal/sheets/doc.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package sheets manages collections of cheat sheets across multiple cheatpaths.
|
||||
//
|
||||
// The sheets package provides functionality to:
|
||||
// - Load sheets from multiple cheatpaths
|
||||
// - Consolidate duplicate sheets (with precedence rules)
|
||||
// - Filter sheets by tags
|
||||
// - Sort sheets alphabetically
|
||||
// - Extract unique tags across all sheets
|
||||
//
|
||||
// # Loading Sheets
|
||||
//
|
||||
// Sheets are loaded recursively from cheatpath directories, excluding:
|
||||
// - Hidden files (starting with .)
|
||||
// - Files in .git directories
|
||||
// - Files with extensions (sheets have no extension)
|
||||
//
|
||||
// # Consolidation
|
||||
//
|
||||
// When multiple cheatpaths contain sheets with the same name, consolidation
|
||||
// rules apply based on the order of cheatpaths. Sheets from earlier paths
|
||||
// override those from later paths, allowing personal sheets to override
|
||||
// community sheets.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cheatpaths:
|
||||
// 1. personal: ~/cheat
|
||||
// 2. community: ~/cheat/community
|
||||
//
|
||||
// If both contain "git", the version from "personal" is used.
|
||||
//
|
||||
// # Filtering
|
||||
//
|
||||
// Sheets can be filtered by:
|
||||
// - Tags: Include only sheets with specific tags
|
||||
// - Cheatpath: Include only sheets from specific paths
|
||||
//
|
||||
// Key Functions
|
||||
//
|
||||
// - Load: Loads all sheets from the given cheatpaths
|
||||
// - Filter: Filters sheets by tag
|
||||
// - Consolidate: Merges sheets from multiple paths with precedence
|
||||
// - Sort: Sorts sheets alphabetically by title
|
||||
// - Tags: Extracts all unique tags from sheets
|
||||
//
|
||||
// Example Usage
|
||||
//
|
||||
// // Load sheets from all cheatpaths
|
||||
// allSheets, err := sheets.Load(cheatpaths)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// // Consolidate to handle duplicates
|
||||
// consolidated := sheets.Consolidate(allSheets)
|
||||
//
|
||||
// // Filter by tag
|
||||
// filtered := sheets.Filter(consolidated, "networking")
|
||||
//
|
||||
// // Sort alphabetically
|
||||
// sheets.Sort(filtered)
|
||||
//
|
||||
// // Get all unique tags
|
||||
// tags := sheets.Tags(consolidated)
|
||||
package sheets
|
||||
@@ -2,6 +2,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
@@ -31,7 +32,8 @@ func Filter(
|
||||
// iterate over each tag. If the sheet does not match *all* tags, filter
|
||||
// it out.
|
||||
for _, tag := range tags {
|
||||
if !sheet.Tagged(strings.TrimSpace(tag)) {
|
||||
trimmed := strings.TrimSpace(tag)
|
||||
if trimmed == "" || !utf8.ValidString(trimmed) || !sheet.Tagged(trimmed) {
|
||||
keep = false
|
||||
}
|
||||
}
|
||||
|
||||
177
internal/sheets/filter_fuzz_test.go
Normal file
177
internal/sheets/filter_fuzz_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// FuzzFilter tests the Filter function with various tag combinations
|
||||
func FuzzFilter(f *testing.F) {
|
||||
// Add seed corpus with various tag scenarios
|
||||
// Format: "tags to filter by" (comma-separated)
|
||||
f.Add("linux")
|
||||
f.Add("linux,bash")
|
||||
f.Add("linux,bash,ssh")
|
||||
f.Add("")
|
||||
f.Add(" ")
|
||||
f.Add(" linux ")
|
||||
f.Add("linux,")
|
||||
f.Add(",linux")
|
||||
f.Add(",,")
|
||||
f.Add("linux,,bash")
|
||||
f.Add("tag-with-dash")
|
||||
f.Add("tag_with_underscore")
|
||||
f.Add("UPPERCASE")
|
||||
f.Add("miXedCase")
|
||||
f.Add("🎉emoji")
|
||||
f.Add("tag with spaces")
|
||||
f.Add("\ttab\ttag")
|
||||
f.Add("tag\nwith\nnewline")
|
||||
f.Add("very-long-tag-name-that-might-cause-issues-somewhere")
|
||||
f.Add(strings.Repeat("a,", 100) + "a")
|
||||
|
||||
f.Fuzz(func(t *testing.T, tagString string) {
|
||||
// Split the tag string into individual tags
|
||||
var tags []string
|
||||
if tagString != "" {
|
||||
tags = strings.Split(tagString, ",")
|
||||
}
|
||||
|
||||
// Create test data - some sheets with various tags
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
{
|
||||
"sheet1": sheet.Sheet{
|
||||
Title: "sheet1",
|
||||
Tags: []string{"linux", "bash"},
|
||||
},
|
||||
"sheet2": sheet.Sheet{
|
||||
Title: "sheet2",
|
||||
Tags: []string{"linux", "ssh", "networking"},
|
||||
},
|
||||
"sheet3": sheet.Sheet{
|
||||
Title: "sheet3",
|
||||
Tags: []string{"UPPERCASE", "miXedCase"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"sheet4": sheet.Sheet{
|
||||
Title: "sheet4",
|
||||
Tags: []string{"tag with spaces", "🎉emoji"},
|
||||
},
|
||||
"sheet5": sheet.Sheet{
|
||||
Title: "sheet5",
|
||||
Tags: []string{}, // No tags
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The function should not panic
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Filter panicked with tags %q: %v", tags, r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := Filter(cheatpaths, tags)
|
||||
|
||||
// Verify invariants
|
||||
// 1. Result should have same number of cheatpaths
|
||||
if len(result) != len(cheatpaths) {
|
||||
t.Errorf("Filter changed number of cheatpaths: got %d, want %d",
|
||||
len(result), len(cheatpaths))
|
||||
}
|
||||
|
||||
// 2. Each filtered sheet should contain all requested tags
|
||||
for _, filteredPath := range result {
|
||||
for title, sheet := range filteredPath {
|
||||
// Verify this sheet has all the tags we filtered for
|
||||
for _, tag := range tags {
|
||||
trimmedTag := strings.TrimSpace(tag)
|
||||
if trimmedTag == "" {
|
||||
continue // Skip empty tags
|
||||
}
|
||||
if !sheet.Tagged(trimmedTag) {
|
||||
t.Errorf("Sheet %q passed filter but doesn't have tag %q",
|
||||
title, trimmedTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Empty tag list should return all sheets
|
||||
if len(tags) == 0 || (len(tags) == 1 && tags[0] == "") {
|
||||
totalOriginal := 0
|
||||
totalFiltered := 0
|
||||
for _, path := range cheatpaths {
|
||||
totalOriginal += len(path)
|
||||
}
|
||||
for _, path := range result {
|
||||
totalFiltered += len(path)
|
||||
}
|
||||
if totalFiltered != totalOriginal {
|
||||
t.Errorf("Empty filter should return all sheets: got %d, want %d",
|
||||
totalFiltered, totalOriginal)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzFilterEdgeCases tests Filter with extreme inputs
|
||||
func FuzzFilterEdgeCases(f *testing.F) {
|
||||
// Seed with number of tags and tag length
|
||||
f.Add(0, 0)
|
||||
f.Add(1, 10)
|
||||
f.Add(10, 10)
|
||||
f.Add(100, 5)
|
||||
f.Add(1000, 3)
|
||||
|
||||
f.Fuzz(func(t *testing.T, numTags int, tagLen int) {
|
||||
// Limit to reasonable values to avoid memory issues
|
||||
if numTags > 1000 || numTags < 0 || tagLen > 100 || tagLen < 0 {
|
||||
t.Skip("Skipping unreasonable test case")
|
||||
}
|
||||
|
||||
// Generate tags
|
||||
tags := make([]string, numTags)
|
||||
for i := 0; i < numTags; i++ {
|
||||
// Create a tag of specified length
|
||||
if tagLen > 0 {
|
||||
tags[i] = strings.Repeat("a", tagLen) + string(rune(i%26+'a'))
|
||||
}
|
||||
}
|
||||
|
||||
// Create a sheet with no tags (should be filtered out)
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
{
|
||||
"test": sheet.Sheet{
|
||||
Title: "test",
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should not panic with many tags
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Filter panicked with %d tags of length %d: %v",
|
||||
numTags, tagLen, r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := Filter(cheatpaths, tags)
|
||||
|
||||
// With non-matching tags, result should be empty
|
||||
if numTags > 0 && tagLen > 0 {
|
||||
if len(result[0]) != 0 {
|
||||
t.Errorf("Expected empty result with non-matching tags, got %d sheets",
|
||||
len(result[0]))
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isGitDir returns `true` if `path` is within a `.git` directory, or `false`
|
||||
// otherwise
|
||||
func isGitDir(path string) (bool, error) {
|
||||
|
||||
/*
|
||||
A bit of context is called for here, because this functionality has
|
||||
previously caused a number of tricky, subtle bugs.
|
||||
|
||||
Fundamentally, here we are simply trying to avoid walking over the
|
||||
contents of the `.git` directory. Doing so potentially makes
|
||||
hundreds/thousands of needless syscalls, and can noticeably harm
|
||||
performance on machines with slow disks.
|
||||
|
||||
The earliest effort to solve this problem involved simply returning
|
||||
`fs.SkipDir` when the cheatsheet file path began with `.`, signifying a
|
||||
hidden directory. This, however, caused two problems:
|
||||
|
||||
1. The `.cheat` directory was ignored
|
||||
2. Cheatsheets installed by `brew` (which were by default installed to
|
||||
`~/.config/cheat`) were ignored
|
||||
|
||||
See: https://github.com/cheat/cheat/issues/690
|
||||
|
||||
To remedy this, the exclusion criteria were narrowed, and the search
|
||||
for a literal `.` was replaced with a search for a literal `.git`.
|
||||
This, however, broke user installations that stored cheatsheets in
|
||||
`git` submodules, because such an installation would contain a `.git`
|
||||
file that pointed to the upstream repository.
|
||||
|
||||
See: https://github.com/cheat/cheat/issues/694
|
||||
|
||||
Accounting for all of the above, we are now searching for the presence
|
||||
of a `.git` literal string in the cheatsheet file path. If it is not
|
||||
found, we continue to walk the directory, as before.
|
||||
|
||||
If it *is* found, we determine if `.git` refers to a file or directory,
|
||||
and only stop walking the path in the latter case.
|
||||
*/
|
||||
|
||||
// determine if the literal string `.git` appears within `path`
|
||||
pos := strings.Index(path, ".git")
|
||||
|
||||
// if it does not, we know for certain that we are not within a `.git`
|
||||
// directory.
|
||||
if pos == -1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If `path` does contain the string `.git`, we need to determine if we're
|
||||
// inside of a `.git` directory, or if `path` points to a cheatsheet that's
|
||||
// stored within a `git` submodule.
|
||||
//
|
||||
// See: https://github.com/cheat/cheat/issues/694
|
||||
|
||||
// truncate `path` to the occurrence of `.git`
|
||||
f, err := os.Stat(path[:pos+4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to stat path %s: %v", path, err)
|
||||
}
|
||||
|
||||
// return true or false depending on whether the truncated path is a
|
||||
// directory
|
||||
return f.Mode().IsDir(), nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||
"github.com/cheat/cheat/internal/repo"
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,7 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
|
||||
sheets := make([]map[string]sheet.Sheet, len(cheatpaths))
|
||||
|
||||
// iterate over each cheatpath
|
||||
for _, cheatpath := range cheatpaths {
|
||||
for i, cheatpath := range cheatpaths {
|
||||
|
||||
// vivify the map of cheatsheets on this specific cheatpath
|
||||
pathsheets := make(map[string]sheet.Sheet)
|
||||
@@ -42,6 +43,19 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get the base filename
|
||||
filename := filepath.Base(path)
|
||||
|
||||
// skip hidden files (files that start with a dot)
|
||||
if strings.HasPrefix(filename, ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skip files with extensions (cheatsheets have no extension)
|
||||
if filepath.Ext(filename) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculate the cheatsheet's "title" (the phrase with which it may be
|
||||
// accessed. Eg: `cheat tar` - `tar` is the title)
|
||||
title := strings.TrimPrefix(
|
||||
@@ -52,7 +66,7 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
|
||||
// Don't walk the `.git` directory. Doing so creates
|
||||
// hundreds/thousands of needless syscalls and could
|
||||
// potentially harm performance on machines with slow disks.
|
||||
skip, err := isGitDir(path)
|
||||
skip, err := repo.GitDir(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to identify .git directory: %v", err)
|
||||
}
|
||||
@@ -87,7 +101,7 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
|
||||
|
||||
// store the sheets on this cheatpath alongside the other cheatsheets on
|
||||
// other cheatpaths
|
||||
sheets = append(sheets, pathsheets)
|
||||
sheets[i] = pathsheets
|
||||
}
|
||||
|
||||
// return the cheatsheets, grouped by cheatpath
|
||||
|
||||
@@ -26,19 +26,26 @@ func TestLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
// load cheatsheets
|
||||
sheets, err := Load(cheatpaths)
|
||||
cheatpathSheets, err := Load(cheatpaths)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load cheatsheets: %v", err)
|
||||
}
|
||||
|
||||
// assert that the correct number of sheets loaded
|
||||
// (sheet load details are tested in `sheet_test.go`)
|
||||
totalSheets := 0
|
||||
for _, sheets := range cheatpathSheets {
|
||||
totalSheets += len(sheets)
|
||||
}
|
||||
|
||||
// we expect 4 total sheets (2 from community, 2 from personal)
|
||||
// hidden files and files with extensions are excluded
|
||||
want := 4
|
||||
if len(sheets) != want {
|
||||
if totalSheets != want {
|
||||
t.Errorf(
|
||||
"failed to load correct number of cheatsheets: want: %d, got: %d",
|
||||
want,
|
||||
len(sheets),
|
||||
totalSheets,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
@@ -16,7 +17,10 @@ func Tags(cheatpaths []map[string]sheet.Sheet) []string {
|
||||
for _, path := range cheatpaths {
|
||||
for _, sheet := range path {
|
||||
for _, tag := range sheet.Tags {
|
||||
tags[tag] = true
|
||||
// Skip invalid UTF-8 tags to prevent downstream issues
|
||||
if utf8.ValidString(tag) {
|
||||
tags[tag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
190
internal/sheets/tags_fuzz_test.go
Normal file
190
internal/sheets/tags_fuzz_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// FuzzTags tests the Tags function with various tag combinations
|
||||
func FuzzTags(f *testing.F) {
|
||||
// Add seed corpus
|
||||
// Format: comma-separated tags that will be distributed across sheets
|
||||
f.Add("linux,bash,ssh")
|
||||
f.Add("")
|
||||
f.Add("single")
|
||||
f.Add("duplicate,duplicate,duplicate")
|
||||
f.Add(" spaces , around , tags ")
|
||||
f.Add("MiXeD,UPPER,lower")
|
||||
f.Add("special-chars,under_score,dot.ted")
|
||||
f.Add("emoji🎉,unicode中文,symbols@#$")
|
||||
f.Add("\ttab,\nnewline,\rcarriage")
|
||||
f.Add(",,,,") // Multiple empty tags
|
||||
f.Add(strings.Repeat("tag,", 100)) // Many tags
|
||||
f.Add("a," + strings.Repeat("very-long-tag-name", 10)) // Long tag names
|
||||
|
||||
f.Fuzz(func(t *testing.T, tagString string) {
|
||||
// Split tags and distribute them across multiple sheets
|
||||
var allTags []string
|
||||
if tagString != "" {
|
||||
allTags = strings.Split(tagString, ",")
|
||||
}
|
||||
|
||||
// Create test cheatpaths with various tag distributions
|
||||
cheatpaths := []map[string]sheet.Sheet{}
|
||||
|
||||
// Distribute tags across 3 paths with overlapping tags
|
||||
for i := 0; i < 3; i++ {
|
||||
path := make(map[string]sheet.Sheet)
|
||||
|
||||
// Each path gets some subset of tags
|
||||
for j, tag := range allTags {
|
||||
if j%3 == i || j%(i+2) == 0 { // Create some overlap
|
||||
sheetName := string(rune('a' + j%26))
|
||||
path[sheetName] = sheet.Sheet{
|
||||
Title: sheetName,
|
||||
Tags: []string{tag},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a sheet with multiple tags
|
||||
if len(allTags) > 1 {
|
||||
path["multi"] = sheet.Sheet{
|
||||
Title: "multi",
|
||||
Tags: allTags[:len(allTags)/2+1], // First half of tags
|
||||
}
|
||||
}
|
||||
|
||||
cheatpaths = append(cheatpaths, path)
|
||||
}
|
||||
|
||||
// The function should not panic
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Tags panicked with input %q: %v", tagString, r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := Tags(cheatpaths)
|
||||
|
||||
// Verify invariants
|
||||
// 1. Result should be sorted
|
||||
for i := 1; i < len(result); i++ {
|
||||
if result[i-1] >= result[i] {
|
||||
t.Errorf("Tags not sorted: %q >= %q at positions %d, %d",
|
||||
result[i-1], result[i], i-1, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. No duplicates in result
|
||||
seen := make(map[string]bool)
|
||||
for _, tag := range result {
|
||||
if seen[tag] {
|
||||
t.Errorf("Duplicate tag in result: %q", tag)
|
||||
}
|
||||
seen[tag] = true
|
||||
}
|
||||
|
||||
// 3. All non-empty tags from input should be in result
|
||||
// (This is approximate since we distributed tags in a complex way)
|
||||
inputTags := make(map[string]bool)
|
||||
for _, tag := range allTags {
|
||||
if tag != "" {
|
||||
inputTags[tag] = true
|
||||
}
|
||||
}
|
||||
|
||||
resultTags := make(map[string]bool)
|
||||
for _, tag := range result {
|
||||
resultTags[tag] = true
|
||||
}
|
||||
|
||||
// Result might have fewer tags due to distribution logic,
|
||||
// but shouldn't have tags not in the input
|
||||
for tag := range resultTags {
|
||||
found := false
|
||||
for inputTag := range inputTags {
|
||||
if tag == inputTag {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && tag != "" {
|
||||
t.Errorf("Result contains tag %q not derived from input", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Valid UTF-8 (Tags function should filter out invalid UTF-8)
|
||||
for _, tag := range result {
|
||||
if !utf8.ValidString(tag) {
|
||||
t.Errorf("Invalid UTF-8 in tag: %q", tag)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzTagsStress tests Tags function with large numbers of tags
|
||||
func FuzzTagsStress(f *testing.F) {
|
||||
// Seed: number of unique tags, number of sheets, tags per sheet
|
||||
f.Add(10, 10, 5)
|
||||
f.Add(100, 50, 10)
|
||||
f.Add(1000, 100, 20)
|
||||
|
||||
f.Fuzz(func(t *testing.T, numUniqueTags int, numSheets int, tagsPerSheet int) {
|
||||
// Limit to reasonable values
|
||||
if numUniqueTags > 1000 || numUniqueTags < 0 ||
|
||||
numSheets > 1000 || numSheets < 0 ||
|
||||
tagsPerSheet > 100 || tagsPerSheet < 0 {
|
||||
t.Skip("Skipping unreasonable test case")
|
||||
}
|
||||
|
||||
// Generate unique tags
|
||||
uniqueTags := make([]string, numUniqueTags)
|
||||
for i := 0; i < numUniqueTags; i++ {
|
||||
uniqueTags[i] = "tag" + string(rune(i))
|
||||
}
|
||||
|
||||
// Create sheets with random tags
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
make(map[string]sheet.Sheet),
|
||||
}
|
||||
|
||||
for i := 0; i < numSheets; i++ {
|
||||
// Select random tags for this sheet
|
||||
sheetTags := make([]string, 0, tagsPerSheet)
|
||||
for j := 0; j < tagsPerSheet && j < numUniqueTags; j++ {
|
||||
// Distribute tags across sheets
|
||||
tagIndex := (i*tagsPerSheet + j) % numUniqueTags
|
||||
sheetTags = append(sheetTags, uniqueTags[tagIndex])
|
||||
}
|
||||
|
||||
cheatpaths[0]["sheet"+string(rune(i))] = sheet.Sheet{
|
||||
Title: "sheet" + string(rune(i)),
|
||||
Tags: sheetTags,
|
||||
}
|
||||
}
|
||||
|
||||
// Should handle large numbers efficiently
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Tags panicked with %d unique tags, %d sheets, %d tags/sheet: %v",
|
||||
numUniqueTags, numSheets, tagsPerSheet, r)
|
||||
}
|
||||
}()
|
||||
|
||||
result := Tags(cheatpaths)
|
||||
|
||||
// Should have at most numUniqueTags in result
|
||||
if len(result) > numUniqueTags {
|
||||
t.Errorf("More tags in result (%d) than unique tags created (%d)",
|
||||
len(result), numUniqueTags)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
2
internal/sheets/testdata/fuzz/FuzzFilter/4316c263ab833860
vendored
Normal file
2
internal/sheets/testdata/fuzz/FuzzFilter/4316c263ab833860
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
string("\xd7")
|
||||
2
internal/sheets/testdata/fuzz/FuzzTags/28f36ef487f23e6c
vendored
Normal file
2
internal/sheets/testdata/fuzz/FuzzTags/28f36ef487f23e6c
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
string("\xf0")
|
||||
12
vendor/dario.cat/mergo/.deepsource.toml
vendored
Normal file
12
vendor/dario.cat/mergo/.deepsource.toml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
version = 1
|
||||
|
||||
test_patterns = [
|
||||
"*_test.go"
|
||||
]
|
||||
|
||||
[[analyzers]]
|
||||
name = "go"
|
||||
enabled = true
|
||||
|
||||
[analyzers.meta]
|
||||
import_path = "dario.cat/mergo"
|
||||
36
vendor/dario.cat/mergo/.gitignore
vendored
Normal file
36
vendor/dario.cat/mergo/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
#### joe made this: http://goel.io/joe
|
||||
|
||||
#### go ####
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Golang/Intellij
|
||||
.idea
|
||||
|
||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||
.glide/
|
||||
|
||||
#### vim ####
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-v][a-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
*~
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
12
vendor/dario.cat/mergo/.travis.yml
vendored
Normal file
12
vendor/dario.cat/mergo/.travis.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
language: go
|
||||
arch:
|
||||
- amd64
|
||||
- ppc64le
|
||||
install:
|
||||
- go get -t
|
||||
- go get golang.org/x/tools/cmd/cover
|
||||
- go get github.com/mattn/goveralls
|
||||
script:
|
||||
- go test -race -v ./...
|
||||
after_script:
|
||||
- $HOME/gopath/bin/goveralls -service=travis-ci -repotoken $COVERALLS_TOKEN
|
||||
46
vendor/dario.cat/mergo/CODE_OF_CONDUCT.md
vendored
Normal file
46
vendor/dario.cat/mergo/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers 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, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at i@dario.im. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user