Compare commits

...

23 Commits
4.4.1 ... 4.5.2

Author SHA1 Message Date
Christopher Allen Lane
adb5a43810 chore: bump version to 4.5.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:35:59 -05:00
Christopher Allen Lane
cab039a9d8 docs: move ADRs to project root, remove boilerplate README
Move `doc/adr/` to `adr/` for discoverability. Remove the generic
ADR README — `ls adr/` serves the same purpose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:32:40 -05:00
Christopher Allen Lane
97e80beceb fix: match .git as complete path component, not suffix
Searching for `.git/` in file paths incorrectly matched directory names
ending with `.git` (e.g., `personal.git/cheat/hello`), causing sheets
under such paths to be silently skipped. Fix by requiring the path
separator on both sides (`/.git/`), so `.git` is only matched as a
complete path component.

Rewrites test suite with comprehensive coverage for all six documented
edge cases, including the #711 scenario and combination cases (e.g.,
a real .git directory inside a .git-suffixed parent).

Closes #711

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:32:35 -05:00
Christopher Allen Lane
1969423b5c fix: respect $VISUAL and $EDITOR env vars at runtime
Previously, env vars were only consulted during config generation
and baked into conf.yml. At runtime, the config file value was
always used, making it impossible to override the editor via
environment variables.

Now the precedence is: $VISUAL > $EDITOR > conf.yml > auto-detect.

Closes #589

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:54:42 -05:00
Christopher Allen Lane
4497ce1b84 ci: remove dead Homebrew formula bump workflow
This workflow has been failing for years due to an expired/missing
COMMITTER_TOKEN. Homebrew maintains their own automated version
bump pipeline, making this redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:43:01 -05:00
Christopher Allen Lane
5eee02bc40 build: produce static binaries with CGO_ENABLED=0
Eliminates glibc version mismatch errors when running release
binaries on systems with older glibc versions.

Closes #744

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:39:20 -05:00
Christopher Allen Lane
2d50c6a6eb chore: bump version to 4.5.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:26:17 -05:00
Christopher Allen Lane
6f919fd675 fix: comment out community cheatpath in --init output (#773)
cheat --init now comments out the community cheatpath by default and
includes a git clone instruction with the resolved path. This prevents
warnings about missing directories when users save the --init output
as their config without also cloning community cheatsheets.

Closes #773

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:21:48 -05:00
Christopher Allen Lane
fd1465ee38 fix: avoid stdin buffering bug in installer prompts
Prompt() created a new bufio.NewReader(os.Stdin) on each call, which
buffered all piped input on the first call and left nothing for
subsequent prompts. This made cheat un-scriptable (e.g., piping answers
via printf). Fix by reading one byte at a time from os.Stdin directly.

Also adds an end-to-end integration test for the first-run experience
(regression test for #721, #771, #730) and bumps the Dockerfile to
Go 1.26.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:51:30 -05:00
Christopher Allen Lane
00ec2c130d fix: resolve first-run experience errors (#721, #771, #730)
- cmdInit (--init) now substitutes EDITOR_PATH, PAGER_PATH, and
  WORK_PATH instead of leaving them as literal strings
- Installer now substitutes WORK_PATH and always creates personal
  and work directories regardless of community cheatsheet choice
- When community cheatsheets are declined, the community cheatpath
  is commented out in the generated config
- config.New() skips nonexistent cheatpaths with a warning instead
  of hard-erroring on EvalSymlinks failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:32:20 -05:00
Christopher Allen Lane
8eafa5adfe fix: cross-platform CI test fixes and parse bug fix
- Add .gitattributes to force LF in mock files (Windows autocrlf)
- Fix parse.go: detect line endings from content instead of runtime.GOOS
- Add fail-fast: false to CI matrix; trigger on all branch pushes
- Skip chmod-based tests on Windows (permissions work differently)
- Use filepath.Join for expected paths in Windows path tests
- Use platform-appropriate invalid paths in error tests
- Add Windows absolute path test case for ValidateSheetName
- Skip Unix-specific integration tests on Windows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:31:26 -05:00
Christopher Allen Lane
b604027205 fix: make tests pass on macOS CI runners
- Resolve symlinks in temp dir paths (macOS /var -> /private/var)
- Pre-create non-empty community dir to ensure clone fails reliably
  regardless of network access on CI runners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:05:16 -05:00
Christopher Allen Lane
2a19755804 chore: modernize CI and update Go toolchain
- Bump Go from 1.19 to 1.26 and update all dependencies
- Rewrite CI workflow with matrix strategy (Linux, macOS, Windows)
- Update GitHub Actions to current versions (checkout@v4, setup-go@v5)
- Update CodeQL actions from v1 to v3
- Fix cross-platform bug in mock/path.go (path.Join -> filepath.Join)
- Clean up dependabot config (weekly schedule, remove stale ignore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:58:51 -05:00
Christopher Allen Lane
cc85a4bdb1 chore: bump version to 4.5.0
Bug fixes:
- Fix inverted pager detection logic (returned error instead of path)
- Fix repo.Clone ignoring destination directory parameter
- Fix sheet loading using append on pre-sized slices
- Clean up partial files on copy failure
- Trim whitespace from editor config

Security:
- Add path traversal protection for cheatsheet names

Performance:
- Move regex compilation outside search loop
- Replace string concatenation with strings.Join in search

Build:
- Remove go:generate; embed config and usage as string literals
- Parallelize release builds
- Add fuzz testing infrastructure

Testing:
- Improve test coverage from 38.9% to 50.2%
- Add fuzz tests for search, filter, tags, and validation

Documentation:
- Fix inaccurate code examples in HACKING.md
- Add missing --conf and --all options to man page
- Add ADRs for path traversal, env parsing, and search parallelization
- Update CONTRIBUTING.md to reflect project policy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:56:19 -05:00
Chris Allen Lane
7908a678df Merge pull request #742 from cheat/dependabot/go_modules/github.com/cloudflare/circl-1.3.7
chore(deps): bump github.com/cloudflare/circl from 1.3.6 to 1.3.7
2024-01-08 15:34:48 -05:00
dependabot[bot]
7c0eacb53d chore(deps): bump github.com/cloudflare/circl from 1.3.6 to 1.3.7
Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.3.6 to 1.3.7.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.3.6...v1.3.7)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-08 16:53:49 +00:00
Chris Allen Lane
4bf804ac60 Merge pull request #741 from cheat/dependabot/go_modules/golang.org/x/crypto-0.17.0
chore(deps): bump golang.org/x/crypto from 0.16.0 to 0.17.0
2023-12-19 14:51:50 -05:00
dependabot[bot]
33c5918087 chore(deps): bump golang.org/x/crypto from 0.16.0 to 0.17.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-19 00:07:38 +00:00
Christopher Allen Lane
d34177729d chore: bump version to 4.4.2 2023-12-15 12:48:39 -05:00
Chris Allen Lane
7fa50328d7 Merge pull request #740 from chrisallenlane/chroma
chore(deps): bump chroma to v2 #735
2023-12-13 12:59:52 -05:00
Christopher Allen Lane
1790aec85d chore(deps): bump chroma to v2 #735
Bump `alecthomas/chroma` to `v2`:
https://github.com/cheat/cheat/issues/735
2023-12-13 12:54:32 -05:00
Mikel Olasagasti Uranga
6bf51e758f Use yaml.v3 rather than .v2 and .v1 2023-12-13 09:57:58 -05:00
Christopher Allen Lane
242da8c89a chore(build): remove plan9 support
Remove `plan9` support from the `Makefile` because the executable now
fails to build.
2023-12-13 09:45:21 -05:00
1290 changed files with 97644 additions and 50277 deletions

3
.gitattributes vendored Normal file
View 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

View File

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

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
---
name: homebrew
on:
push:
tags: '*'
jobs:
homebrew:
name: Bump Homebrew formula
runs-on: ubuntu-latest
steps:
- uses: mislav/bump-homebrew-formula-action@v1
with:
# A PR will be sent to github.com/Homebrew/homebrew-core to update
# this formula:
formula-name: cheat
env:
COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }}

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
dist
tags
.tmp
*.test
.claude

117
CLAUDE.md Normal file
View 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

View File

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

View File

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

View File

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

View File

@@ -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.4.1/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.4.1`) 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

115
Makefile
View File

@@ -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
@@ -24,6 +27,7 @@ ZIP := zip -m
docker_image := cheat-devel:latest
# build flags
export CGO_ENABLED := 0
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
GOBIN :=
TMPDIR := /tmp
@@ -31,6 +35,7 @@ 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 \
@@ -39,76 +44,83 @@ releases := \
$(dist_dir)/cheat-linux-arm7 \
$(dist_dir)/cheat-netbsd-amd64 \
$(dist_dir)/cheat-openbsd-amd64 \
$(dist_dir)/cheat-plan9-amd64 \
$(dist_dir)/cheat-solaris-amd64 \
$(dist_dir)/cheat-windows-amd64.exe
## build: build an executable for your architecture
.PHONY: build
build: | clean $(dist_dir) 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: prepare
$(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: prepare
$(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: prepare
$(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: prepare
$(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
@@ -116,9 +128,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
@@ -128,7 +140,8 @@ install: build
## clean: remove compiled executables
.PHONY: clean
clean:
$(RM) -f $(dist_dir)/* $(cmd_dir)/str_config.go $(cmd_dir)/str_usage.go
$(RM) -f $(dist_dir)/*
$(RM) -rf .tmp
## distclean: remove the tags file
.PHONY: distclean
@@ -139,7 +152,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
@@ -163,6 +177,7 @@ 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) mod tidy && $(GO) mod verify
@@ -186,18 +201,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: | clean $(dist_dir) 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

View File

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

View 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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

74
cmd/cheat/config.go Normal file
View 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>'. Overridden by $VISUAL or $EDITOR.
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`
}

View File

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

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

View File

@@ -1,8 +1,6 @@
// Package main serves as the executable entrypoint.
package main
//go:generate go run ../../build/embed.go
import (
"fmt"
"os"
@@ -17,7 +15,7 @@ import (
"github.com/cheat/cheat/internal/installer"
)
const version = "4.4.1"
const version = "4.5.2"
func main() {
@@ -45,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])

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

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

View File

@@ -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').
`)
}

View File

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

View File

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

View File

@@ -1,31 +1,14 @@
.\" Automatically generated by Pandoc 2.17.1.1
.\" Automatically generated by Pandoc 3.1.11.1
.\"
.\" Define V font for inline verbatim, using C font in formats
.\" that render this, and otherwise B font.
.ie "\f[CB]x\f[]"x" \{\
. ftr V B
. ftr VI BI
. ftr VB B
. ftr VBI BI
.\}
.el \{\
. ftr V CR
. ftr VI CI
. ftr VB CB
. ftr VBI CBI
.\}
.TH "CHEAT" "1" "" "" "General Commands Manual"
.hy
.SH NAME
.PP
\f[B]cheat\f[R] \[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[R] [options] [\f[I]CHEATSHEET\f[R]]
.SH DESCRIPTION
.PP
\f[B]cheat\f[R] allows you to create and view interactive cheatsheets on
the command-line.
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.
@@ -34,34 +17,40 @@ remember.
\[en]init
Print a config file to stdout.
.TP
-c, \[en]colorize
\[en]conf
Display the config file path.
.TP
\-a, \[en]all
Search among all cheatpaths.
.TP
\-c, \[en]colorize
Colorize output.
.TP
-d, \[en]directories
\-d, \[en]directories
List cheatsheet directories.
.TP
-e, \[en]edit=\f[I]CHEATSHEET\f[R]
\-e, \[en]edit=\f[I]CHEATSHEET\f[R]
Open \f[I]CHEATSHEET\f[R] for editing.
.TP
-l, \[en]list
\-l, \[en]list
List available cheatsheets.
.TP
-p, \[en]path=\f[I]PATH\f[R]
\-p, \[en]path=\f[I]PATH\f[R]
Filter only to sheets found on path \f[I]PATH\f[R].
.TP
-r, \[en]regex
\-r, \[en]regex
Treat search \f[I]PHRASE\f[R] as a regular expression.
.TP
-s, \[en]search=\f[I]PHRASE\f[R]
\-s, \[en]search=\f[I]PHRASE\f[R]
Search cheatsheets for \f[I]PHRASE\f[R].
.TP
-t, \[en]tag=\f[I]TAG\f[R]
\-t, \[en]tag=\f[I]TAG\f[R]
Filter only to sheets tagged with \f[I]TAG\f[R].
.TP
-T, \[en]tags
\-T, \[en]tags
List all tags in use.
.TP
-v, \[en]version
\-v, \[en]version
Print the version number.
.TP
\[en]rm=\f[I]CHEATSHEET\f[R]
@@ -72,37 +61,39 @@ To view the foo cheatsheet:
cheat \f[I]foo\f[R]
.TP
To edit (or create) the foo cheatsheet:
cheat -e \f[I]foo\f[R]
cheat \-e \f[I]foo\f[R]
.TP
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]
cheat \-p \f[I]work\f[R] \-e \f[I]foo/bar\f[R]
.TP
To view all cheatsheet directories:
cheat -d
cheat \-d
.TP
To list all available cheatsheets:
cheat -l
cheat \-l
.TP
To list all cheatsheets whose titles match `apt':
cheat -l \f[I]apt\f[R]
cheat \-l \f[I]apt\f[R]
.TP
To list all tags in use:
cheat -T
cheat \-T
.TP
To list available cheatsheets that are tagged as `personal':
cheat -l -t \f[I]personal\f[R]
cheat \-l \-t \f[I]personal\f[R]
.TP
To search for `ssh' among all cheatsheets, and colorize matches:
cheat -c -s \f[I]ssh\f[R]
cheat \-c \-s \f[I]ssh\f[R]
.TP
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]
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[R]
.TP
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[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
@@ -133,24 +124,28 @@ Alternatively, you may also generate a config file manually by running
\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[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[R], and viewed via
\f[B]cheat -d\f[R].
\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
.PP
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
<https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.bash
.UE \c
.IP \[bu] 2
<https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.fish
.UE \c
.IP \[bu] 2
<https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh
.UE \c
.PP
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
@@ -176,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[R]

View File

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

41
go.mod
View File

@@ -1,38 +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/go-git/go-git/v5 v5.11.0
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.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect
github.com/cloudflare/circl v1.3.6 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dlclark/regexp2 v1.10.0 // 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.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/skeema/knownhosts v1.2.1 // 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.16.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/tools v0.16.1 // 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
)

155
go.sum
View File

@@ -1,144 +1,121 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
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.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
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/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/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
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.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.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 v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
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.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
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.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
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/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
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.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/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
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.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
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=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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=

View File

@@ -2,6 +2,8 @@
// management.
package cheatpath
import "fmt"
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
Name string `yaml:"name"`
@@ -9,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
}

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

View File

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

View 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)
}
}()
}
})
}

View File

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

View File

@@ -10,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
@@ -40,7 +40,7 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
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)
}
@@ -64,7 +64,8 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
}
// 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)
@@ -83,6 +84,14 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
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,
@@ -93,10 +102,22 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
expanded = evaled
}
conf.Cheatpaths[i].Path = expanded
cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
}
conf.Cheatpaths = validPaths
// determine the editor: env vars override the config file value,
// following standard Unix convention (see #589)
if v := os.Getenv("VISUAL"); v != "" {
conf.Editor = v
} else if v := os.Getenv("EDITOR"); v != "" {
conf.Editor = v
} else {
conf.Editor = strings.TrimSpace(conf.Editor)
}
// if an editor was not provided in the configs, attempt to choose one
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {

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

View File

@@ -16,6 +16,16 @@ import (
// TestConfig asserts that the configs are loaded correctly
func TestConfigSuccessful(t *testing.T) {
// clear env vars so they don't override the config file value
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
@@ -75,37 +85,78 @@ 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) {
// TestEditorEnvOverride asserts that $VISUAL and $EDITOR override the
// config file value at runtime (regression test for #589)
func TestEditorEnvOverride(t *testing.T) {
// save and clear the environment variables
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// clear the environment variables
os.Setenv("VISUAL", "")
os.Setenv("EDITOR", "")
// with no env vars, the config file value should be used
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "vim" {
t.Errorf("expected config file editor: want: vim, got: %s", conf.Editor)
}
// initialize a config
// $EDITOR should override the config file value
os.Setenv("EDITOR", "nano")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "nano" {
t.Errorf("$EDITOR should override config: want: nano, got: %s", conf.Editor)
}
// $VISUAL should override both $EDITOR and the config file value
os.Setenv("VISUAL", "emacs")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "emacs" {
t.Errorf("$VISUAL should override all: want: emacs, got: %s", conf.Editor)
}
}
// TestEditorEnvFallback asserts that env vars are used as fallback when
// no editor is specified in the config file
func TestEditorEnvFallback(t *testing.T) {
// save and clear the environment variables
oldVisual := os.Getenv("VISUAL")
oldEditor := os.Getenv("EDITOR")
defer func() {
os.Setenv("VISUAL", oldVisual)
os.Setenv("EDITOR", oldEditor)
}()
// set $EDITOR and assert it's used when config has no editor
os.Unsetenv("VISUAL")
os.Setenv("EDITOR", "foo")
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to initialize test: %v", err)
}
// set editor, and assert that it is respected
os.Setenv("EDITOR", "foo")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to init configs: %v", err)
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "foo" {
t.Errorf("failed to respect editor: want: foo, got: %s", conf.Editor)
t.Errorf("failed to respect $EDITOR: want: foo, got: %s", conf.Editor)
}
// set visual, and assert that it overrides editor
// set $VISUAL and assert it takes precedence over $EDITOR
os.Setenv("VISUAL", "bar")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to init configs: %v", err)
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "bar" {
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor)
t.Errorf("failed to respect $VISUAL: want: bar, got: %s", conf.Editor)
}
}

52
internal/config/doc.go Normal file
View 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

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

View File

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

135
internal/config/new_test.go Normal file
View File

@@ -0,0 +1,135 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestNewTrimsWhitespace(t *testing.T) {
// clear env vars so they don't override the config file value
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 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)
}
}

View File

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

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

View File

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

View File

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

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

View File

@@ -3,7 +3,6 @@
package installer
import (
"bufio"
"fmt"
"os"
"strings"
@@ -12,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 {

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

View File

@@ -17,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)
@@ -44,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 := 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)
}
}

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

View File

@@ -3,7 +3,6 @@ package mock
import (
"fmt"
"path"
"path/filepath"
"runtime"
)
@@ -16,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,

View File

@@ -8,11 +8,11 @@ import (
"github.com/go-git/go-git/v5"
)
// Clone clones the repo available at `url`
func Clone(url string) error {
// Clone clones the community cheatsheets repository to the specified directory
func Clone(dir string) error {
// clone the community cheatsheets
_, err := git.PlainClone(url, false, &git.CloneOptions{
_, err := git.PlainClone(dir, false, &git.CloneOptions{
URL: "https://github.com/cheat/cheatsheets.git",
Depth: 1,
Progress: os.Stdout,

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

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

View File

@@ -6,6 +6,11 @@ import (
"strings"
)
// gitSep is the `.git` path component surrounded by path separators.
// Used to match `.git` as a complete path component, not as a suffix
// of a directory name (e.g., `personal.git`).
var gitSep = string(os.PathSeparator) + ".git" + string(os.PathSeparator)
// GitDir returns `true` if we are iterating over a directory contained within
// a repositories `.git` directory.
func GitDir(path string) (bool, error) {
@@ -50,9 +55,20 @@ func GitDir(path string) (bool, error) {
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.
Accounting for all of the above, the next solution was to search not
for `.git`, but `.git/` (including the directory separator), and then
only ceasing to walk the directory on a match.
This, however, also had a bug: searching for `.git/` also matched
directory names that *ended with* `.git`, like `personal.git/`. This
caused cheatsheets stored under such paths to be silently skipped.
See: https://github.com/cheat/cheat/issues/711
The current (and hopefully final) solution requires the path separator
on *both* sides of `.git`, i.e., searching for `/.git/`. This ensures
that `.git` is matched only as a complete path component, not as a
suffix of a directory name.
To summarize, this code must account for the following possibilities:
@@ -61,17 +77,16 @@ func GitDir(path string) (bool, error) {
3. A cheatpath is a repository, and contains a `.git*` file
4. A cheatpath is a submodule
5. A cheatpath is a hidden directory
6. A cheatpath is inside a directory whose name ends with `.git`
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:
NB: `filepath.Walk` always passes absolute paths to the walk function,
so `.git` will never appear as the first path component. This is what
makes the "separator on both sides" approach safe.
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
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
@@ -83,8 +98,8 @@ func GitDir(path string) (bool, error) {
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)))
// determine if `.git` appears as a complete path component
pos := strings.Index(path, gitSep)
// if it does not, we know for certain that we are not within a `.git`
// directory.

View File

@@ -0,0 +1,355 @@
package repo
import (
"os"
"path/filepath"
"testing"
)
// setupGitDirTestTree creates a temporary directory structure that exercises
// every case documented in GitDir's comment block. The caller must defer
// os.RemoveAll on the returned root.
//
// Layout:
//
// root/
// ├── plain/ # not a repository
// │ └── sheet
// ├── repo/ # a repository (.git is a directory)
// │ ├── .git/
// │ │ ├── HEAD
// │ │ ├── objects/
// │ │ │ └── pack/
// │ │ └── refs/
// │ │ └── heads/
// │ ├── .gitignore
// │ ├── .gitattributes
// │ └── sheet
// ├── submodule/ # a submodule (.git is a file)
// │ ├── .git # file, not directory
// │ └── sheet
// ├── dotgit-suffix.git/ # directory name ends in .git (#711)
// │ └── cheat/
// │ └── sheet
// ├── dotgit-mid.git/ # .git suffix mid-path (#711)
// │ └── nested/
// │ └── sheet
// ├── .github/ # .github directory (not .git)
// │ └── workflows/
// │ └── ci.yml
// └── .hidden/ # generic hidden directory
// └── sheet
func setupGitDirTestTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
dirs := []string{
// case 1: not a repository
filepath.Join(root, "plain"),
// case 2: a repository (.git directory with contents)
filepath.Join(root, "repo", ".git", "objects", "pack"),
filepath.Join(root, "repo", ".git", "refs", "heads"),
// case 4: a submodule (.git is a file)
filepath.Join(root, "submodule"),
// case 6: directory name ending in .git (#711)
filepath.Join(root, "dotgit-suffix.git", "cheat"),
filepath.Join(root, "dotgit-mid.git", "nested"),
// .github (should not be confused with .git)
filepath.Join(root, ".github", "workflows"),
// generic hidden directory
filepath.Join(root, ".hidden"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("failed to create dir %s: %v", dir, err)
}
}
files := map[string]string{
// sheets
filepath.Join(root, "plain", "sheet"): "plain sheet",
filepath.Join(root, "repo", "sheet"): "repo sheet",
filepath.Join(root, "submodule", "sheet"): "submod sheet",
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"): "dotgit sheet",
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"): "dotgit nested",
filepath.Join(root, ".hidden", "sheet"): "hidden sheet",
// git metadata
filepath.Join(root, "repo", ".git", "HEAD"): "ref: refs/heads/main\n",
filepath.Join(root, "repo", ".gitignore"): "*.tmp\n",
filepath.Join(root, "repo", ".gitattributes"): "* text=auto\n",
filepath.Join(root, "submodule", ".git"): "gitdir: ../.git/modules/sub\n",
filepath.Join(root, ".github", "workflows", "ci.yml"): "name: CI\n",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}
return root
}
func TestGitDir(t *testing.T) {
root := setupGitDirTestTree(t)
tests := []struct {
name string
path string
want bool
}{
// Case 1: not a repository — no .git anywhere in path
{
name: "plain directory, no repo",
path: filepath.Join(root, "plain", "sheet"),
want: false,
},
// Case 2: a repository — paths *inside* .git/ should be detected
{
name: "inside .git directory",
path: filepath.Join(root, "repo", ".git", "HEAD"),
want: true,
},
{
name: "inside .git/objects",
path: filepath.Join(root, "repo", ".git", "objects", "pack", "somefile"),
want: true,
},
{
name: "inside .git/refs",
path: filepath.Join(root, "repo", ".git", "refs", "heads", "main"),
want: true,
},
// Case 2 (cont.): files *alongside* .git should NOT be detected
{
name: "sheet in repo root (beside .git dir)",
path: filepath.Join(root, "repo", "sheet"),
want: false,
},
// Case 3: .git* files (like .gitignore) should NOT trigger
{
name: ".gitignore file",
path: filepath.Join(root, "repo", ".gitignore"),
want: false,
},
{
name: ".gitattributes file",
path: filepath.Join(root, "repo", ".gitattributes"),
want: false,
},
// Case 4: submodule — .git is a file, not a directory
{
name: "sheet in submodule (where .git is a file)",
path: filepath.Join(root, "submodule", "sheet"),
want: false,
},
// Case 6: directory name ends with .git (#711)
{
name: "sheet under directory ending in .git",
path: filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
want: false,
},
{
name: "sheet under .git-suffixed dir, nested deeper",
path: filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
want: false,
},
// .github directory — must not be confused with .git
{
name: "file inside .github directory",
path: filepath.Join(root, ".github", "workflows", "ci.yml"),
want: false,
},
// Hidden directory that is not .git
{
name: "file inside generic hidden directory",
path: filepath.Join(root, ".hidden", "sheet"),
want: false,
},
// Path with no .git at all
{
name: "path with no .git component whatsoever",
path: filepath.Join(root, "nonexistent", "file"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GitDir(tt.path)
if err != nil {
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
}
if got != tt.want {
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
// TestGitDirWithNestedGitDir tests a repo inside a .git-suffixed parent
// directory. This is the nastiest combination: a real .git directory that
// appears *after* a .git suffix in the path.
func TestGitDirWithNestedGitDir(t *testing.T) {
root := t.TempDir()
// Create: root/cheats.git/repo/.git/HEAD
// root/cheats.git/repo/sheet
gitDir := filepath.Join(root, "cheats.git", "repo", ".git")
if err := os.MkdirAll(gitDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "cheats.git", "repo", "sheet"), []byte("content"), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
path string
want bool
}{
{
name: "sheet beside .git in .git-suffixed parent",
path: filepath.Join(root, "cheats.git", "repo", "sheet"),
want: false,
},
{
name: "file inside .git inside .git-suffixed parent",
path: filepath.Join(root, "cheats.git", "repo", ".git", "HEAD"),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GitDir(tt.path)
if err != nil {
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
}
if got != tt.want {
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
// TestGitDirSubmoduleInsideDotGitSuffix tests a submodule (.git file)
// inside a .git-suffixed parent directory.
func TestGitDirSubmoduleInsideDotGitSuffix(t *testing.T) {
root := t.TempDir()
// Create: root/personal.git/submod/.git (file)
// root/personal.git/submod/sheet
subDir := filepath.Join(root, "personal.git", "submod")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
// .git as a file (submodule pointer)
if err := os.WriteFile(filepath.Join(subDir, ".git"), []byte("gitdir: ../../.git/modules/sub\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subDir, "sheet"), []byte("content"), 0644); err != nil {
t.Fatal(err)
}
got, err := GitDir(filepath.Join(subDir, "sheet"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Error("GitDir should return false for sheet in submodule under .git-suffixed parent")
}
}
// TestGitDirIntegrationWalk simulates what sheets.Load does: walking a
// directory tree and checking each path with GitDir. This verifies that
// the function works correctly in the context of filepath.Walk, which is
// how it is actually called.
func TestGitDirIntegrationWalk(t *testing.T) {
root := setupGitDirTestTree(t)
// Walk the tree and collect which paths GitDir says to skip
var skipped []string
var visited []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
isGit, err := GitDir(path)
if err != nil {
return err
}
if isGit {
skipped = append(skipped, path)
} else {
visited = append(visited, path)
}
return nil
})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
// Files inside .git/ should be skipped
expectSkipped := []string{
filepath.Join(root, "repo", ".git", "HEAD"),
}
for _, want := range expectSkipped {
found := false
for _, got := range skipped {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("expected %q to be skipped, but it was not", want)
}
}
// Sheets should NOT be skipped — including the #711 case
expectVisited := []string{
filepath.Join(root, "plain", "sheet"),
filepath.Join(root, "repo", "sheet"),
filepath.Join(root, "submodule", "sheet"),
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
filepath.Join(root, ".hidden", "sheet"),
}
for _, want := range expectVisited {
found := false
for _, got := range visited {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("expected %q to be visited (not skipped), but it was not found in visited paths", want)
}
}
}

View File

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

View File

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

View File

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

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

View File

@@ -2,18 +2,17 @@ package sheet
import (
"fmt"
"runtime"
"strings"
"gopkg.in/yaml.v2"
"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"
}

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

View 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)
}()
}
})
}

View File

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

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

View 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)
}()
})
}

View File

@@ -0,0 +1,4 @@
go test fuzz v1
string("0")
int(-6)
int(5)

View File

@@ -0,0 +1,3 @@
go test fuzz v1
string(".")
string(" 0000\n\n\n\n00000")

65
internal/sheets/doc.go Normal file
View 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

View File

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

View 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]))
}
}
}()
})
}

View File

@@ -20,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)
@@ -43,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(
@@ -88,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

View File

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

View File

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

View 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)
}
}()
})
}

View File

@@ -0,0 +1,2 @@
go test fuzz v1
string("\xd7")

View File

@@ -0,0 +1,2 @@
go test fuzz v1
string("\xf0")

View File

@@ -13,6 +13,9 @@
# 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/

7
vendor/dario.cat/mergo/FUNDING.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"drips": {
"ethereum": {
"ownedBy": "0x6160020e7102237aC41bdb156e94401692D76930"
}
}
}

View File

@@ -44,13 +44,21 @@ Also a lovely [comune](http://en.wikipedia.org/wiki/Mergo) (municipality) in the
## Status
It is ready for production use. [It is used in several projects by Docker, Google, The Linux Foundation, VMWare, Shopify, Microsoft, etc](https://github.com/imdario/mergo#mergo-in-the-wild).
Mergo is stable and frozen, ready for production. Check a short list of the projects using at large scale it [here](https://github.com/imdario/mergo#mergo-in-the-wild).
No new features are accepted. They will be considered for a future v2 that improves the implementation and fixes bugs for corner cases.
### Important notes
#### 1.0.0
In [1.0.0](//github.com/imdario/mergo/releases/tag/1.0.0) Mergo moves to a vanity URL `dario.cat/mergo`.
In [1.0.0](//github.com/imdario/mergo/releases/tag/1.0.0) Mergo moves to a vanity URL `dario.cat/mergo`. No more v1 versions will be released.
If the vanity URL is causing issues in your project due to a dependency pulling Mergo - it isn't a direct dependency in your project - it is recommended to use [replace](https://github.com/golang/go/wiki/Modules#when-should-i-use-the-replace-directive) to pin the version to the last one with the old import URL:
```
replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16
```
#### 0.3.9
@@ -64,55 +72,23 @@ If you were using Mergo before April 6th, 2015, please check your project works
If Mergo is useful to you, consider buying me a coffee, a beer, or making a monthly donation to allow me to keep building great free software. :heart_eyes:
<a href='https://ko-fi.com/B0B58839' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://az743702.vo.msecnd.net/cdn/kofi1.png?v=0' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
<a href="https://liberapay.com/dario/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a>
<a href='https://github.com/sponsors/imdario' target='_blank'><img alt="Become my sponsor" src="https://img.shields.io/github/sponsors/imdario?style=for-the-badge" /></a>
### Mergo in the wild
- [moby/moby](https://github.com/moby/moby)
- [kubernetes/kubernetes](https://github.com/kubernetes/kubernetes)
- [vmware/dispatch](https://github.com/vmware/dispatch)
- [Shopify/themekit](https://github.com/Shopify/themekit)
- [imdario/zas](https://github.com/imdario/zas)
- [matcornic/hermes](https://github.com/matcornic/hermes)
- [OpenBazaar/openbazaar-go](https://github.com/OpenBazaar/openbazaar-go)
- [kataras/iris](https://github.com/kataras/iris)
- [michaelsauter/crane](https://github.com/michaelsauter/crane)
- [go-task/task](https://github.com/go-task/task)
- [sensu/uchiwa](https://github.com/sensu/uchiwa)
- [ory/hydra](https://github.com/ory/hydra)
- [sisatech/vcli](https://github.com/sisatech/vcli)
- [dairycart/dairycart](https://github.com/dairycart/dairycart)
- [projectcalico/felix](https://github.com/projectcalico/felix)
- [resin-os/balena](https://github.com/resin-os/balena)
- [go-kivik/kivik](https://github.com/go-kivik/kivik)
- [Telefonica/govice](https://github.com/Telefonica/govice)
- [supergiant/supergiant](supergiant/supergiant)
- [SergeyTsalkov/brooce](https://github.com/SergeyTsalkov/brooce)
- [soniah/dnsmadeeasy](https://github.com/soniah/dnsmadeeasy)
- [ohsu-comp-bio/funnel](https://github.com/ohsu-comp-bio/funnel)
- [EagerIO/Stout](https://github.com/EagerIO/Stout)
- [lynndylanhurley/defsynth-api](https://github.com/lynndylanhurley/defsynth-api)
- [russross/canvasassignments](https://github.com/russross/canvasassignments)
- [rdegges/cryptly-api](https://github.com/rdegges/cryptly-api)
- [casualjim/exeggutor](https://github.com/casualjim/exeggutor)
- [divshot/gitling](https://github.com/divshot/gitling)
- [RWJMurphy/gorl](https://github.com/RWJMurphy/gorl)
- [andrerocker/deploy42](https://github.com/andrerocker/deploy42)
- [elwinar/rambler](https://github.com/elwinar/rambler)
- [tmaiaroto/gopartman](https://github.com/tmaiaroto/gopartman)
- [jfbus/impressionist](https://github.com/jfbus/impressionist)
- [Jmeyering/zealot](https://github.com/Jmeyering/zealot)
- [godep-migrator/rigger-host](https://github.com/godep-migrator/rigger-host)
- [Dronevery/MultiwaySwitch-Go](https://github.com/Dronevery/MultiwaySwitch-Go)
- [thoas/picfit](https://github.com/thoas/picfit)
- [mantasmatelis/whooplist-server](https://github.com/mantasmatelis/whooplist-server)
- [jnuthong/item_search](https://github.com/jnuthong/item_search)
- [bukalapak/snowboard](https://github.com/bukalapak/snowboard)
- [containerssh/containerssh](https://github.com/containerssh/containerssh)
- [goreleaser/goreleaser](https://github.com/goreleaser/goreleaser)
- [tjpnz/structbot](https://github.com/tjpnz/structbot)
Mergo is used by [thousands](https://deps.dev/go/dario.cat%2Fmergo/v1.0.0/dependents) [of](https://deps.dev/go/github.com%2Fimdario%2Fmergo/v0.3.16/dependents) [projects](https://deps.dev/go/github.com%2Fimdario%2Fmergo/v0.3.12), including:
* [containerd/containerd](https://github.com/containerd/containerd)
* [datadog/datadog-agent](https://github.com/datadog/datadog-agent)
* [docker/cli/](https://github.com/docker/cli/)
* [goreleaser/goreleaser](https://github.com/goreleaser/goreleaser)
* [go-micro/go-micro](https://github.com/go-micro/go-micro)
* [grafana/loki](https://github.com/grafana/loki)
* [masterminds/sprig](github.com/Masterminds/sprig)
* [moby/moby](https://github.com/moby/moby)
* [slackhq/nebula](https://github.com/slackhq/nebula)
* [volcano-sh/volcano](https://github.com/volcano-sh/volcano)
## Install
@@ -141,6 +117,39 @@ if err := mergo.Merge(&dst, src, mergo.WithOverride); err != nil {
}
```
If you need to override pointers, so the source pointer's value is assigned to the destination's pointer, you must use `WithoutDereference`:
```go
package main
import (
"fmt"
"dario.cat/mergo"
)
type Foo struct {
A *string
B int64
}
func main() {
first := "first"
second := "second"
src := Foo{
A: &first,
B: 2,
}
dest := Foo{
A: &second,
B: 1,
}
mergo.Merge(&dest, src, mergo.WithOverride, mergo.WithoutDereference)
}
```
Additionally, you can map a `map[string]interface{}` to a struct (and otherwise, from struct to map), following the same restrictions as in `Merge()`. Keys are capitalized to find each corresponding exported field.
```go
@@ -181,10 +190,6 @@ func main() {
}
```
Note: if test are failing due missing package, please execute:
go get gopkg.in/yaml.v3
### Transformers
Transformers allow to merge specific types differently than in the default behavior. In other words, now you can customize how some types are merged. For example, `time.Time` is a struct; it doesn't have zero value but IsZero can return true because it has fields with zero value. How can we merge a non-zero `time.Time`?

View File

@@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 0.3.x | :white_check_mark: |
| < 0.3 | :x: |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
## Security contact information

View File

@@ -58,7 +58,7 @@ func deepMap(dst, src reflect.Value, visited map[uintptr]*visit, depth int, conf
}
fieldName := field.Name
fieldName = changeInitialCase(fieldName, unicode.ToLower)
if v, ok := dstMap[fieldName]; !ok || (isEmptyValue(reflect.ValueOf(v), !config.ShouldNotDereference) || overwrite) {
if _, ok := dstMap[fieldName]; !ok || (!isEmptyValue(reflect.ValueOf(src.Field(i).Interface()), !config.ShouldNotDereference) && overwrite) || config.overwriteWithEmptyValue {
dstMap[fieldName] = src.Field(i).Interface()
}
}

View File

@@ -269,7 +269,7 @@ func deepMerge(dst, src reflect.Value, visited map[uintptr]*visit, depth int, co
if err = deepMerge(dst.Elem(), src.Elem(), visited, depth+1, config); err != nil {
return
}
} else {
} else if src.Elem().Kind() != reflect.Struct {
if overwriteWithEmptySrc || (overwrite && !src.IsNil()) || dst.IsNil() {
dst.Set(src)
}

View File

@@ -1,7 +1,3 @@
run:
skip-dirs:
- pkg/etw/sample
linters:
enable:
# style
@@ -20,9 +16,13 @@ linters:
- gofmt # files are gofmt'ed
- gosec # security
- nilerr # returns nil even with non-nil error
- thelper # test helpers without t.Helper()
- unparam # unused function params
issues:
exclude-dirs:
- pkg/etw/sample
exclude-rules:
# err is very often shadowed in nested scopes
- linters:
@@ -69,9 +69,7 @@ linters-settings:
# struct order is often for Win32 compat
# also, ignore pointer bytes/GC issues for now until performance becomes an issue
- fieldalignment
check-shadowing: true
nolintlint:
allow-leading-space: false
require-explanation: true
require-specific: true
revive:

View File

@@ -10,14 +10,14 @@ import (
"io"
"os"
"runtime"
"syscall"
"unicode/utf16"
"github.com/Microsoft/go-winio/internal/fs"
"golang.org/x/sys/windows"
)
//sys backupRead(h syscall.Handle, b []byte, bytesRead *uint32, abort bool, processSecurity bool, context *uintptr) (err error) = BackupRead
//sys backupWrite(h syscall.Handle, b []byte, bytesWritten *uint32, abort bool, processSecurity bool, context *uintptr) (err error) = BackupWrite
//sys backupRead(h windows.Handle, b []byte, bytesRead *uint32, abort bool, processSecurity bool, context *uintptr) (err error) = BackupRead
//sys backupWrite(h windows.Handle, b []byte, bytesWritten *uint32, abort bool, processSecurity bool, context *uintptr) (err error) = BackupWrite
const (
BackupData = uint32(iota + 1)
@@ -104,7 +104,7 @@ func (r *BackupStreamReader) Next() (*BackupHeader, error) {
if err := binary.Read(r.r, binary.LittleEndian, name); err != nil {
return nil, err
}
hdr.Name = syscall.UTF16ToString(name)
hdr.Name = windows.UTF16ToString(name)
}
if wsi.StreamID == BackupSparseBlock {
if err := binary.Read(r.r, binary.LittleEndian, &hdr.Offset); err != nil {
@@ -205,7 +205,7 @@ func NewBackupFileReader(f *os.File, includeSecurity bool) *BackupFileReader {
// Read reads a backup stream from the file by calling the Win32 API BackupRead().
func (r *BackupFileReader) Read(b []byte) (int, error) {
var bytesRead uint32
err := backupRead(syscall.Handle(r.f.Fd()), b, &bytesRead, false, r.includeSecurity, &r.ctx)
err := backupRead(windows.Handle(r.f.Fd()), b, &bytesRead, false, r.includeSecurity, &r.ctx)
if err != nil {
return 0, &os.PathError{Op: "BackupRead", Path: r.f.Name(), Err: err}
}
@@ -220,7 +220,7 @@ func (r *BackupFileReader) Read(b []byte) (int, error) {
// the underlying file.
func (r *BackupFileReader) Close() error {
if r.ctx != 0 {
_ = backupRead(syscall.Handle(r.f.Fd()), nil, nil, true, false, &r.ctx)
_ = backupRead(windows.Handle(r.f.Fd()), nil, nil, true, false, &r.ctx)
runtime.KeepAlive(r.f)
r.ctx = 0
}
@@ -244,7 +244,7 @@ func NewBackupFileWriter(f *os.File, includeSecurity bool) *BackupFileWriter {
// Write restores a portion of the file using the provided backup stream.
func (w *BackupFileWriter) Write(b []byte) (int, error) {
var bytesWritten uint32
err := backupWrite(syscall.Handle(w.f.Fd()), b, &bytesWritten, false, w.includeSecurity, &w.ctx)
err := backupWrite(windows.Handle(w.f.Fd()), b, &bytesWritten, false, w.includeSecurity, &w.ctx)
if err != nil {
return 0, &os.PathError{Op: "BackupWrite", Path: w.f.Name(), Err: err}
}
@@ -259,7 +259,7 @@ func (w *BackupFileWriter) Write(b []byte) (int, error) {
// close the underlying file.
func (w *BackupFileWriter) Close() error {
if w.ctx != 0 {
_ = backupWrite(syscall.Handle(w.f.Fd()), nil, nil, true, false, &w.ctx)
_ = backupWrite(windows.Handle(w.f.Fd()), nil, nil, true, false, &w.ctx)
runtime.KeepAlive(w.f)
w.ctx = 0
}
@@ -271,17 +271,14 @@ func (w *BackupFileWriter) Close() error {
//
// If the file opened was a directory, it cannot be used with Readdir().
func OpenForBackup(path string, access uint32, share uint32, createmode uint32) (*os.File, error) {
winPath, err := syscall.UTF16FromString(path)
if err != nil {
return nil, err
}
h, err := syscall.CreateFile(&winPath[0],
access,
share,
h, err := fs.CreateFile(path,
fs.AccessMask(access),
fs.FileShareMode(share),
nil,
createmode,
syscall.FILE_FLAG_BACKUP_SEMANTICS|syscall.FILE_FLAG_OPEN_REPARSE_POINT,
0)
fs.FileCreationDisposition(createmode),
fs.FILE_FLAG_BACKUP_SEMANTICS|fs.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
err = &os.PathError{Op: "open", Path: path, Err: err}
return nil, err

View File

@@ -15,26 +15,11 @@ import (
"golang.org/x/sys/windows"
)
//sys cancelIoEx(file syscall.Handle, o *syscall.Overlapped) (err error) = CancelIoEx
//sys createIoCompletionPort(file syscall.Handle, port syscall.Handle, key uintptr, threadCount uint32) (newport syscall.Handle, err error) = CreateIoCompletionPort
//sys getQueuedCompletionStatus(port syscall.Handle, bytes *uint32, key *uintptr, o **ioOperation, timeout uint32) (err error) = GetQueuedCompletionStatus
//sys setFileCompletionNotificationModes(h syscall.Handle, flags uint8) (err error) = SetFileCompletionNotificationModes
//sys wsaGetOverlappedResult(h syscall.Handle, o *syscall.Overlapped, bytes *uint32, wait bool, flags *uint32) (err error) = ws2_32.WSAGetOverlappedResult
type atomicBool int32
func (b *atomicBool) isSet() bool { return atomic.LoadInt32((*int32)(b)) != 0 }
func (b *atomicBool) setFalse() { atomic.StoreInt32((*int32)(b), 0) }
func (b *atomicBool) setTrue() { atomic.StoreInt32((*int32)(b), 1) }
//revive:disable-next-line:predeclared Keep "new" to maintain consistency with "atomic" pkg
func (b *atomicBool) swap(new bool) bool {
var newInt int32
if new {
newInt = 1
}
return atomic.SwapInt32((*int32)(b), newInt) == 1
}
//sys cancelIoEx(file windows.Handle, o *windows.Overlapped) (err error) = CancelIoEx
//sys createIoCompletionPort(file windows.Handle, port windows.Handle, key uintptr, threadCount uint32) (newport windows.Handle, err error) = CreateIoCompletionPort
//sys getQueuedCompletionStatus(port windows.Handle, bytes *uint32, key *uintptr, o **ioOperation, timeout uint32) (err error) = GetQueuedCompletionStatus
//sys setFileCompletionNotificationModes(h windows.Handle, flags uint8) (err error) = SetFileCompletionNotificationModes
//sys wsaGetOverlappedResult(h windows.Handle, o *windows.Overlapped, bytes *uint32, wait bool, flags *uint32) (err error) = ws2_32.WSAGetOverlappedResult
var (
ErrFileClosed = errors.New("file has already been closed")
@@ -50,7 +35,7 @@ func (*timeoutError) Temporary() bool { return true }
type timeoutChan chan struct{}
var ioInitOnce sync.Once
var ioCompletionPort syscall.Handle
var ioCompletionPort windows.Handle
// ioResult contains the result of an asynchronous IO operation.
type ioResult struct {
@@ -60,12 +45,12 @@ type ioResult struct {
// ioOperation represents an outstanding asynchronous Win32 IO.
type ioOperation struct {
o syscall.Overlapped
o windows.Overlapped
ch chan ioResult
}
func initIO() {
h, err := createIoCompletionPort(syscall.InvalidHandle, 0, 0, 0xffffffff)
h, err := createIoCompletionPort(windows.InvalidHandle, 0, 0, 0xffffffff)
if err != nil {
panic(err)
}
@@ -76,10 +61,10 @@ func initIO() {
// win32File implements Reader, Writer, and Closer on a Win32 handle without blocking in a syscall.
// It takes ownership of this handle and will close it if it is garbage collected.
type win32File struct {
handle syscall.Handle
handle windows.Handle
wg sync.WaitGroup
wgLock sync.RWMutex
closing atomicBool
closing atomic.Bool
socket bool
readDeadline deadlineHandler
writeDeadline deadlineHandler
@@ -90,11 +75,11 @@ type deadlineHandler struct {
channel timeoutChan
channelLock sync.RWMutex
timer *time.Timer
timedout atomicBool
timedout atomic.Bool
}
// makeWin32File makes a new win32File from an existing file handle.
func makeWin32File(h syscall.Handle) (*win32File, error) {
func makeWin32File(h windows.Handle) (*win32File, error) {
f := &win32File{handle: h}
ioInitOnce.Do(initIO)
_, err := createIoCompletionPort(h, ioCompletionPort, 0, 0xffffffff)
@@ -110,7 +95,12 @@ func makeWin32File(h syscall.Handle) (*win32File, error) {
return f, nil
}
// Deprecated: use NewOpenFile instead.
func MakeOpenFile(h syscall.Handle) (io.ReadWriteCloser, error) {
return NewOpenFile(windows.Handle(h))
}
func NewOpenFile(h windows.Handle) (io.ReadWriteCloser, error) {
// If we return the result of makeWin32File directly, it can result in an
// interface-wrapped nil, rather than a nil interface value.
f, err := makeWin32File(h)
@@ -124,13 +114,13 @@ func MakeOpenFile(h syscall.Handle) (io.ReadWriteCloser, error) {
func (f *win32File) closeHandle() {
f.wgLock.Lock()
// Atomically set that we are closing, releasing the resources only once.
if !f.closing.swap(true) {
if !f.closing.Swap(true) {
f.wgLock.Unlock()
// cancel all IO and wait for it to complete
_ = cancelIoEx(f.handle, nil)
f.wg.Wait()
// at this point, no new IO can start
syscall.Close(f.handle)
windows.Close(f.handle)
f.handle = 0
} else {
f.wgLock.Unlock()
@@ -145,14 +135,14 @@ func (f *win32File) Close() error {
// IsClosed checks if the file has been closed.
func (f *win32File) IsClosed() bool {
return f.closing.isSet()
return f.closing.Load()
}
// prepareIO prepares for a new IO operation.
// The caller must call f.wg.Done() when the IO is finished, prior to Close() returning.
func (f *win32File) prepareIO() (*ioOperation, error) {
f.wgLock.RLock()
if f.closing.isSet() {
if f.closing.Load() {
f.wgLock.RUnlock()
return nil, ErrFileClosed
}
@@ -164,12 +154,12 @@ func (f *win32File) prepareIO() (*ioOperation, error) {
}
// ioCompletionProcessor processes completed async IOs forever.
func ioCompletionProcessor(h syscall.Handle) {
func ioCompletionProcessor(h windows.Handle) {
for {
var bytes uint32
var key uintptr
var op *ioOperation
err := getQueuedCompletionStatus(h, &bytes, &key, &op, syscall.INFINITE)
err := getQueuedCompletionStatus(h, &bytes, &key, &op, windows.INFINITE)
if op == nil {
panic(err)
}
@@ -182,11 +172,11 @@ func ioCompletionProcessor(h syscall.Handle) {
// asyncIO processes the return value from ReadFile or WriteFile, blocking until
// the operation has actually completed.
func (f *win32File) asyncIO(c *ioOperation, d *deadlineHandler, bytes uint32, err error) (int, error) {
if err != syscall.ERROR_IO_PENDING { //nolint:errorlint // err is Errno
if err != windows.ERROR_IO_PENDING { //nolint:errorlint // err is Errno
return int(bytes), err
}
if f.closing.isSet() {
if f.closing.Load() {
_ = cancelIoEx(f.handle, &c.o)
}
@@ -201,8 +191,8 @@ func (f *win32File) asyncIO(c *ioOperation, d *deadlineHandler, bytes uint32, er
select {
case r = <-c.ch:
err = r.err
if err == syscall.ERROR_OPERATION_ABORTED { //nolint:errorlint // err is Errno
if f.closing.isSet() {
if err == windows.ERROR_OPERATION_ABORTED { //nolint:errorlint // err is Errno
if f.closing.Load() {
err = ErrFileClosed
}
} else if err != nil && f.socket {
@@ -214,7 +204,7 @@ func (f *win32File) asyncIO(c *ioOperation, d *deadlineHandler, bytes uint32, er
_ = cancelIoEx(f.handle, &c.o)
r = <-c.ch
err = r.err
if err == syscall.ERROR_OPERATION_ABORTED { //nolint:errorlint // err is Errno
if err == windows.ERROR_OPERATION_ABORTED { //nolint:errorlint // err is Errno
err = ErrTimeout
}
}
@@ -235,23 +225,22 @@ func (f *win32File) Read(b []byte) (int, error) {
}
defer f.wg.Done()
if f.readDeadline.timedout.isSet() {
if f.readDeadline.timedout.Load() {
return 0, ErrTimeout
}
var bytes uint32
err = syscall.ReadFile(f.handle, b, &bytes, &c.o)
err = windows.ReadFile(f.handle, b, &bytes, &c.o)
n, err := f.asyncIO(c, &f.readDeadline, bytes, err)
runtime.KeepAlive(b)
// Handle EOF conditions.
if err == nil && n == 0 && len(b) != 0 {
return 0, io.EOF
} else if err == syscall.ERROR_BROKEN_PIPE { //nolint:errorlint // err is Errno
} else if err == windows.ERROR_BROKEN_PIPE { //nolint:errorlint // err is Errno
return 0, io.EOF
} else {
return n, err
}
return n, err
}
// Write writes to a file handle.
@@ -262,12 +251,12 @@ func (f *win32File) Write(b []byte) (int, error) {
}
defer f.wg.Done()
if f.writeDeadline.timedout.isSet() {
if f.writeDeadline.timedout.Load() {
return 0, ErrTimeout
}
var bytes uint32
err = syscall.WriteFile(f.handle, b, &bytes, &c.o)
err = windows.WriteFile(f.handle, b, &bytes, &c.o)
n, err := f.asyncIO(c, &f.writeDeadline, bytes, err)
runtime.KeepAlive(b)
return n, err
@@ -282,7 +271,7 @@ func (f *win32File) SetWriteDeadline(deadline time.Time) error {
}
func (f *win32File) Flush() error {
return syscall.FlushFileBuffers(f.handle)
return windows.FlushFileBuffers(f.handle)
}
func (f *win32File) Fd() uintptr {
@@ -299,7 +288,7 @@ func (d *deadlineHandler) set(deadline time.Time) error {
}
d.timer = nil
}
d.timedout.setFalse()
d.timedout.Store(false)
select {
case <-d.channel:
@@ -314,7 +303,7 @@ func (d *deadlineHandler) set(deadline time.Time) error {
}
timeoutIO := func() {
d.timedout.setTrue()
d.timedout.Store(true)
close(d.channel)
}

View File

@@ -18,9 +18,18 @@ type FileBasicInfo struct {
_ uint32 // padding
}
// alignedFileBasicInfo is a FileBasicInfo, but aligned to uint64 by containing
// uint64 rather than windows.Filetime. Filetime contains two uint32s. uint64
// alignment is necessary to pass this as FILE_BASIC_INFO.
type alignedFileBasicInfo struct {
CreationTime, LastAccessTime, LastWriteTime, ChangeTime uint64
FileAttributes uint32
_ uint32 // padding
}
// GetFileBasicInfo retrieves times and attributes for a file.
func GetFileBasicInfo(f *os.File) (*FileBasicInfo, error) {
bi := &FileBasicInfo{}
bi := &alignedFileBasicInfo{}
if err := windows.GetFileInformationByHandleEx(
windows.Handle(f.Fd()),
windows.FileBasicInfo,
@@ -30,16 +39,21 @@ func GetFileBasicInfo(f *os.File) (*FileBasicInfo, error) {
return nil, &os.PathError{Op: "GetFileInformationByHandleEx", Path: f.Name(), Err: err}
}
runtime.KeepAlive(f)
return bi, nil
// Reinterpret the alignedFileBasicInfo as a FileBasicInfo so it matches the
// public API of this module. The data may be unnecessarily aligned.
return (*FileBasicInfo)(unsafe.Pointer(bi)), nil
}
// SetFileBasicInfo sets times and attributes for a file.
func SetFileBasicInfo(f *os.File, bi *FileBasicInfo) error {
// Create an alignedFileBasicInfo based on a FileBasicInfo. The copy is
// suitable to pass to GetFileInformationByHandleEx.
biAligned := *(*alignedFileBasicInfo)(unsafe.Pointer(bi))
if err := windows.SetFileInformationByHandle(
windows.Handle(f.Fd()),
windows.FileBasicInfo,
(*byte)(unsafe.Pointer(bi)),
uint32(unsafe.Sizeof(*bi)),
(*byte)(unsafe.Pointer(&biAligned)),
uint32(unsafe.Sizeof(biAligned)),
); err != nil {
return &os.PathError{Op: "SetFileInformationByHandle", Path: f.Name(), Err: err}
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"net"
"os"
"syscall"
"time"
"unsafe"
@@ -181,13 +180,13 @@ type HvsockConn struct {
var _ net.Conn = &HvsockConn{}
func newHVSocket() (*win32File, error) {
fd, err := syscall.Socket(afHVSock, syscall.SOCK_STREAM, 1)
fd, err := windows.Socket(afHVSock, windows.SOCK_STREAM, 1)
if err != nil {
return nil, os.NewSyscallError("socket", err)
}
f, err := makeWin32File(fd)
if err != nil {
syscall.Close(fd)
windows.Close(fd)
return nil, err
}
f.socket = true
@@ -197,16 +196,24 @@ func newHVSocket() (*win32File, error) {
// ListenHvsock listens for connections on the specified hvsock address.
func ListenHvsock(addr *HvsockAddr) (_ *HvsockListener, err error) {
l := &HvsockListener{addr: *addr}
sock, err := newHVSocket()
var sock *win32File
sock, err = newHVSocket()
if err != nil {
return nil, l.opErr("listen", err)
}
defer func() {
if err != nil {
_ = sock.Close()
}
}()
sa := addr.raw()
err = socket.Bind(windows.Handle(sock.handle), &sa)
err = socket.Bind(sock.handle, &sa)
if err != nil {
return nil, l.opErr("listen", os.NewSyscallError("socket", err))
}
err = syscall.Listen(sock.handle, 16)
err = windows.Listen(sock.handle, 16)
if err != nil {
return nil, l.opErr("listen", os.NewSyscallError("listen", err))
}
@@ -246,7 +253,7 @@ func (l *HvsockListener) Accept() (_ net.Conn, err error) {
var addrbuf [addrlen * 2]byte
var bytes uint32
err = syscall.AcceptEx(l.sock.handle, sock.handle, &addrbuf[0], 0 /* rxdatalen */, addrlen, addrlen, &bytes, &c.o)
err = windows.AcceptEx(l.sock.handle, sock.handle, &addrbuf[0], 0 /* rxdatalen */, addrlen, addrlen, &bytes, &c.o)
if _, err = l.sock.asyncIO(c, nil, bytes, err); err != nil {
return nil, l.opErr("accept", os.NewSyscallError("acceptex", err))
}
@@ -263,7 +270,7 @@ func (l *HvsockListener) Accept() (_ net.Conn, err error) {
conn.remote.fromRaw((*rawHvsockAddr)(unsafe.Pointer(&addrbuf[addrlen])))
// initialize the accepted socket and update its properties with those of the listening socket
if err = windows.Setsockopt(windows.Handle(sock.handle),
if err = windows.Setsockopt(sock.handle,
windows.SOL_SOCKET, windows.SO_UPDATE_ACCEPT_CONTEXT,
(*byte)(unsafe.Pointer(&l.sock.handle)), int32(unsafe.Sizeof(l.sock.handle))); err != nil {
return nil, conn.opErr("accept", os.NewSyscallError("setsockopt", err))
@@ -334,7 +341,7 @@ func (d *HvsockDialer) Dial(ctx context.Context, addr *HvsockAddr) (conn *Hvsock
}()
sa := addr.raw()
err = socket.Bind(windows.Handle(sock.handle), &sa)
err = socket.Bind(sock.handle, &sa)
if err != nil {
return nil, conn.opErr(op, os.NewSyscallError("bind", err))
}
@@ -347,7 +354,7 @@ func (d *HvsockDialer) Dial(ctx context.Context, addr *HvsockAddr) (conn *Hvsock
var bytes uint32
for i := uint(0); i <= d.Retries; i++ {
err = socket.ConnectEx(
windows.Handle(sock.handle),
sock.handle,
&sa,
nil, // sendBuf
0, // sendDataLen
@@ -367,7 +374,7 @@ func (d *HvsockDialer) Dial(ctx context.Context, addr *HvsockAddr) (conn *Hvsock
// update the connection properties, so shutdown can be used
if err = windows.Setsockopt(
windows.Handle(sock.handle),
sock.handle,
windows.SOL_SOCKET,
windows.SO_UPDATE_CONNECT_CONTEXT,
nil, // optvalue
@@ -378,7 +385,7 @@ func (d *HvsockDialer) Dial(ctx context.Context, addr *HvsockAddr) (conn *Hvsock
// get the local name
var sal rawHvsockAddr
err = socket.GetSockName(windows.Handle(sock.handle), &sal)
err = socket.GetSockName(sock.handle, &sal)
if err != nil {
return nil, conn.opErr(op, os.NewSyscallError("getsockname", err))
}
@@ -421,7 +428,7 @@ func (d *HvsockDialer) redialWait(ctx context.Context) (err error) {
return ctx.Err()
}
// assumes error is a plain, unwrapped syscall.Errno provided by direct syscall.
// assumes error is a plain, unwrapped windows.Errno provided by direct syscall.
func canRedial(err error) bool {
//nolint:errorlint // guaranteed to be an Errno
switch err {
@@ -447,9 +454,9 @@ func (conn *HvsockConn) Read(b []byte) (int, error) {
return 0, conn.opErr("read", err)
}
defer conn.sock.wg.Done()
buf := syscall.WSABuf{Buf: &b[0], Len: uint32(len(b))}
buf := windows.WSABuf{Buf: &b[0], Len: uint32(len(b))}
var flags, bytes uint32
err = syscall.WSARecv(conn.sock.handle, &buf, 1, &bytes, &flags, &c.o, nil)
err = windows.WSARecv(conn.sock.handle, &buf, 1, &bytes, &flags, &c.o, nil)
n, err := conn.sock.asyncIO(c, &conn.sock.readDeadline, bytes, err)
if err != nil {
var eno windows.Errno
@@ -482,9 +489,9 @@ func (conn *HvsockConn) write(b []byte) (int, error) {
return 0, conn.opErr("write", err)
}
defer conn.sock.wg.Done()
buf := syscall.WSABuf{Buf: &b[0], Len: uint32(len(b))}
buf := windows.WSABuf{Buf: &b[0], Len: uint32(len(b))}
var bytes uint32
err = syscall.WSASend(conn.sock.handle, &buf, 1, &bytes, 0, &c.o, nil)
err = windows.WSASend(conn.sock.handle, &buf, 1, &bytes, 0, &c.o, nil)
n, err := conn.sock.asyncIO(c, &conn.sock.writeDeadline, bytes, err)
if err != nil {
var eno windows.Errno
@@ -511,7 +518,7 @@ func (conn *HvsockConn) shutdown(how int) error {
return socket.ErrSocketClosed
}
err := syscall.Shutdown(conn.sock.handle, how)
err := windows.Shutdown(conn.sock.handle, how)
if err != nil {
// If the connection was closed, shutdowns fail with "not connected"
if errors.Is(err, windows.WSAENOTCONN) ||
@@ -525,7 +532,7 @@ func (conn *HvsockConn) shutdown(how int) error {
// CloseRead shuts down the read end of the socket, preventing future read operations.
func (conn *HvsockConn) CloseRead() error {
err := conn.shutdown(syscall.SHUT_RD)
err := conn.shutdown(windows.SHUT_RD)
if err != nil {
return conn.opErr("closeread", err)
}
@@ -535,7 +542,7 @@ func (conn *HvsockConn) CloseRead() error {
// CloseWrite shuts down the write end of the socket, preventing future write operations and
// notifying the other endpoint that no more data will be written.
func (conn *HvsockConn) CloseWrite() error {
err := conn.shutdown(syscall.SHUT_WR)
err := conn.shutdown(windows.SHUT_WR)
if err != nil {
return conn.opErr("closewrite", err)
}

View File

@@ -11,12 +11,14 @@ import (
//go:generate go run github.com/Microsoft/go-winio/tools/mkwinsyscall -output zsyscall_windows.go fs.go
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
//sys CreateFile(name string, access AccessMask, mode FileShareMode, sa *syscall.SecurityAttributes, createmode FileCreationDisposition, attrs FileFlagOrAttribute, templatefile windows.Handle) (handle windows.Handle, err error) [failretval==windows.InvalidHandle] = CreateFileW
//sys CreateFile(name string, access AccessMask, mode FileShareMode, sa *windows.SecurityAttributes, createmode FileCreationDisposition, attrs FileFlagOrAttribute, templatefile windows.Handle) (handle windows.Handle, err error) [failretval==windows.InvalidHandle] = CreateFileW
const NullHandle windows.Handle = 0
// AccessMask defines standard, specific, and generic rights.
//
// Used with CreateFile and NtCreateFile (and co.).
//
// Bitmask:
// 3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1
// 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
@@ -47,6 +49,12 @@ const (
// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew#parameters
FILE_ANY_ACCESS AccessMask = 0
GENERIC_READ AccessMask = 0x8000_0000
GENERIC_WRITE AccessMask = 0x4000_0000
GENERIC_EXECUTE AccessMask = 0x2000_0000
GENERIC_ALL AccessMask = 0x1000_0000
ACCESS_SYSTEM_SECURITY AccessMask = 0x0100_0000
// Specific Object Access
// from ntioapi.h
@@ -124,14 +132,32 @@ const (
TRUNCATE_EXISTING FileCreationDisposition = 0x05
)
// Create disposition values for NtCreate*
type NTFileCreationDisposition uint32
//nolint:revive // SNAKE_CASE is not idiomatic in Go, but aligned with Win32 API.
const (
// From ntioapi.h
FILE_SUPERSEDE NTFileCreationDisposition = 0x00
FILE_OPEN NTFileCreationDisposition = 0x01
FILE_CREATE NTFileCreationDisposition = 0x02
FILE_OPEN_IF NTFileCreationDisposition = 0x03
FILE_OVERWRITE NTFileCreationDisposition = 0x04
FILE_OVERWRITE_IF NTFileCreationDisposition = 0x05
FILE_MAXIMUM_DISPOSITION NTFileCreationDisposition = 0x05
)
// CreateFile and co. take flags or attributes together as one parameter.
// Define alias until we can use generics to allow both
//
// https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
type FileFlagOrAttribute uint32
//nolint:revive // SNAKE_CASE is not idiomatic in Go, but aligned with Win32 API.
const ( // from winnt.h
const (
// from winnt.h
FILE_FLAG_WRITE_THROUGH FileFlagOrAttribute = 0x8000_0000
FILE_FLAG_OVERLAPPED FileFlagOrAttribute = 0x4000_0000
FILE_FLAG_NO_BUFFERING FileFlagOrAttribute = 0x2000_0000
@@ -145,17 +171,51 @@ const ( // from winnt.h
FILE_FLAG_FIRST_PIPE_INSTANCE FileFlagOrAttribute = 0x0008_0000
)
// NtCreate* functions take a dedicated CreateOptions parameter.
//
// https://learn.microsoft.com/en-us/windows/win32/api/Winternl/nf-winternl-ntcreatefile
//
// https://learn.microsoft.com/en-us/windows/win32/devnotes/nt-create-named-pipe-file
type NTCreateOptions uint32
//nolint:revive // SNAKE_CASE is not idiomatic in Go, but aligned with Win32 API.
const (
// From ntioapi.h
FILE_DIRECTORY_FILE NTCreateOptions = 0x0000_0001
FILE_WRITE_THROUGH NTCreateOptions = 0x0000_0002
FILE_SEQUENTIAL_ONLY NTCreateOptions = 0x0000_0004
FILE_NO_INTERMEDIATE_BUFFERING NTCreateOptions = 0x0000_0008
FILE_SYNCHRONOUS_IO_ALERT NTCreateOptions = 0x0000_0010
FILE_SYNCHRONOUS_IO_NONALERT NTCreateOptions = 0x0000_0020
FILE_NON_DIRECTORY_FILE NTCreateOptions = 0x0000_0040
FILE_CREATE_TREE_CONNECTION NTCreateOptions = 0x0000_0080
FILE_COMPLETE_IF_OPLOCKED NTCreateOptions = 0x0000_0100
FILE_NO_EA_KNOWLEDGE NTCreateOptions = 0x0000_0200
FILE_DISABLE_TUNNELING NTCreateOptions = 0x0000_0400
FILE_RANDOM_ACCESS NTCreateOptions = 0x0000_0800
FILE_DELETE_ON_CLOSE NTCreateOptions = 0x0000_1000
FILE_OPEN_BY_FILE_ID NTCreateOptions = 0x0000_2000
FILE_OPEN_FOR_BACKUP_INTENT NTCreateOptions = 0x0000_4000
FILE_NO_COMPRESSION NTCreateOptions = 0x0000_8000
)
type FileSQSFlag = FileFlagOrAttribute
//nolint:revive // SNAKE_CASE is not idiomatic in Go, but aligned with Win32 API.
const ( // from winbase.h
const (
// from winbase.h
SECURITY_ANONYMOUS FileSQSFlag = FileSQSFlag(SecurityAnonymous << 16)
SECURITY_IDENTIFICATION FileSQSFlag = FileSQSFlag(SecurityIdentification << 16)
SECURITY_IMPERSONATION FileSQSFlag = FileSQSFlag(SecurityImpersonation << 16)
SECURITY_DELEGATION FileSQSFlag = FileSQSFlag(SecurityDelegation << 16)
SECURITY_SQOS_PRESENT FileSQSFlag = 0x00100000
SECURITY_VALID_SQOS_FLAGS FileSQSFlag = 0x001F0000
SECURITY_SQOS_PRESENT FileSQSFlag = 0x0010_0000
SECURITY_VALID_SQOS_FLAGS FileSQSFlag = 0x001F_0000
)
// GetFinalPathNameByHandle flags

View File

@@ -33,9 +33,6 @@ func errnoErr(e syscall.Errno) error {
case errnoERROR_IO_PENDING:
return errERROR_IO_PENDING
}
// TODO: add more here, after collecting data on the common
// error values see on Windows. (perhaps when running
// all.bat?)
return e
}
@@ -45,7 +42,7 @@ var (
procCreateFileW = modkernel32.NewProc("CreateFileW")
)
func CreateFile(name string, access AccessMask, mode FileShareMode, sa *syscall.SecurityAttributes, createmode FileCreationDisposition, attrs FileFlagOrAttribute, templatefile windows.Handle) (handle windows.Handle, err error) {
func CreateFile(name string, access AccessMask, mode FileShareMode, sa *windows.SecurityAttributes, createmode FileCreationDisposition, attrs FileFlagOrAttribute, templatefile windows.Handle) (handle windows.Handle, err error) {
var _p0 *uint16
_p0, err = syscall.UTF16PtrFromString(name)
if err != nil {
@@ -54,8 +51,8 @@ func CreateFile(name string, access AccessMask, mode FileShareMode, sa *syscall.
return _CreateFile(_p0, access, mode, sa, createmode, attrs, templatefile)
}
func _CreateFile(name *uint16, access AccessMask, mode FileShareMode, sa *syscall.SecurityAttributes, createmode FileCreationDisposition, attrs FileFlagOrAttribute, templatefile windows.Handle) (handle windows.Handle, err error) {
r0, _, e1 := syscall.Syscall9(procCreateFileW.Addr(), 7, uintptr(unsafe.Pointer(name)), uintptr(access), uintptr(mode), uintptr(unsafe.Pointer(sa)), uintptr(createmode), uintptr(attrs), uintptr(templatefile), 0, 0)
func _CreateFile(name *uint16, access AccessMask, mode FileShareMode, sa *windows.SecurityAttributes, createmode FileCreationDisposition, attrs FileFlagOrAttribute, templatefile windows.Handle) (handle windows.Handle, err error) {
r0, _, e1 := syscall.SyscallN(procCreateFileW.Addr(), uintptr(unsafe.Pointer(name)), uintptr(access), uintptr(mode), uintptr(unsafe.Pointer(sa)), uintptr(createmode), uintptr(attrs), uintptr(templatefile))
handle = windows.Handle(r0)
if handle == windows.InvalidHandle {
err = errnoErr(e1)

View File

@@ -156,9 +156,7 @@ func connectEx(
bytesSent *uint32,
overlapped *windows.Overlapped,
) (err error) {
// todo: after upgrading to 1.18, switch from syscall.Syscall9 to syscall.SyscallN
r1, _, e1 := syscall.Syscall9(connectExFunc.addr,
7,
r1, _, e1 := syscall.SyscallN(connectExFunc.addr,
uintptr(s),
uintptr(name),
uintptr(namelen),
@@ -166,8 +164,8 @@ func connectEx(
uintptr(sendDataLen),
uintptr(unsafe.Pointer(bytesSent)),
uintptr(unsafe.Pointer(overlapped)),
0,
0)
)
if r1 == 0 {
if e1 != 0 {
err = error(e1)

Some files were not shown because too many files have changed in this diff Show More