Compare commits

...

21 Commits
4.5.0 ... 4.7.1

Author SHA1 Message Date
Christopher Allen Lane
5ad1a3c39f chore: housekeeping and refactoring (bump to 4.7.1)
- Remove unused parameters, dead files, and inaccurate doc.go files
- Extract shared helpers, eliminate duplication
- Rename cheatpath.Cheatpath to cheatpath.Path
- Optimize filesystem walks (WalkDir, skip .git)
- Move sheet name validation to sheet.Validate
- Move integration tests to test/integration/
- Consolidate internal/mock into mocks/
- Move fuzz.sh to test/
- Inline loadSheets helper into command callers
- Extract config.New into its own file
- Fix stale references in HACKING.md and CLAUDE.md
- Restore plan9 build target
- Remove redundant and low-value tests
- Clean up project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:11:19 -05:00
Christopher Allen Lane
d4a8a79628 chore: bump version to 4.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:24:54 -05:00
Christopher Allen Lane
007c9f9efe Merge branch 'brief-list-flag' 2026-02-15 12:24:42 -05:00
Christopher Allen Lane
f61203ac1b feat: add -b/--brief flag for compact list output (#505)
Add a --brief flag that lists cheatsheets with only title and tags,
omitting the file path column. Can be used standalone (-b) or combined
with --list (-lb), and composes with existing filters (-t, -p).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:21:35 -05:00
Christopher Allen Lane
f1db4ee378 Merge branch 'recursive-cheat-directory-search'
feat: walk up directory tree to find .cheat directory (#602)
2026-02-15 11:23:37 -05:00
Christopher Allen Lane
366d63afdc chore: bump version to 4.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:23:31 -05:00
Christopher Allen Lane
c1551683a3 feat: walk up directory tree to find .cheat directory (#602)
Previously cheat only checked the current working directory for a .cheat
subdirectory. Now it walks upward through ancestor directories, stopping
at the first .cheat directory found. This mirrors how git discovers .git
directories, so users can place .cheat at their project root and have it
work from any subdirectory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:30:29 -05:00
Christopher Allen Lane
09aad6f8ea docs: document supported syntax values in README
Adds a note explaining that syntax highlighting is powered by Chroma,
with a link to Chroma's supported languages list.

Closes #766

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:55:37 -05:00
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
740 changed files with 51316 additions and 33518 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 - package-ecosystem: gomod
directory: "/" directory: "/"
schedule: schedule:
interval: daily interval: weekly
open-pull-requests-limit: 10 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: on:
push: push:
branches: [master]
pull_request:
branches: [master]
jobs: jobs:
# TODO: is it possible to DRY out these jobs? Aside from `runs-on`, they are lint:
# identical. runs-on: ubuntu-latest
# See: https://github.com/actions/runner/issues/1182
build-linux:
runs-on: [ubuntu-latest]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Go - uses: actions/setup-go@v5
uses: actions/setup-go@v2
with: with:
go-version: 1.19 go-version: stable
- name: Set up Revive (linter) - name: Install revive
run: go get -u github.com/boyter/scc github.com/mgechev/revive run: go install github.com/mgechev/revive@latest
env: - name: Lint
GO111MODULE: "off" run: revive -exclude vendor/... ./...
- name: Build - name: Vet
run: make build run: go vet ./...
- name: Test - name: Check formatting
run: make test run: test -z "$(gofmt -l . | grep -v vendor/)"
build-osx: test:
runs-on: [macos-latest] strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Go - uses: actions/setup-go@v5
uses: actions/setup-go@v2
with: with:
go-version: 1.19 go-version: stable
- name: Set up Revive (linter)
run: go get -u github.com/boyter/scc github.com/mgechev/revive
env:
GO111MODULE: "off"
- name: Build - name: Build
run: make build run: go build -mod vendor ./cmd/cheat
- name: Test - name: Test
run: make test run: go test ./...

View File

@@ -19,12 +19,12 @@ jobs:
language: [go] language: [go]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v1 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v1 uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis - 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 }}

View File

@@ -85,7 +85,11 @@ The `cheat` command-line tool is organized into several key packages:
- Writes to stdout or pager - Writes to stdout or pager
- Handles text formatting and indentation - Handles text formatting and indentation
6. **`internal/repo`**: Git repository management 6. **`internal/installer`**: First-run installer
- Prompts user for initial configuration choices
- Generates default `conf.yml` and downloads community cheatsheets
7. **`internal/repo`**: Git repository management
- Clones community cheatsheet repositories - Clones community cheatsheet repositories
- Updates existing repositories - Updates existing repositories
@@ -95,6 +99,7 @@ The `cheat` command-line tool is organized into several key packages:
- **Override mechanism**: Local sheets override community sheets with same name - **Override mechanism**: Local sheets override community sheets with same name
- **Tag system**: Sheets can be categorized with tags in frontmatter - **Tag system**: Sheets can be categorized with tags in frontmatter
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets - **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
- **Directory-scoped discovery**: Walks up from cwd to find the nearest `.cheat` directory (like `.git` discovery)
### Sheet Format ### Sheet Format
@@ -114,4 +119,4 @@ ssh -L 8080:localhost:80 user@remote
- Use `go-git` for repository operations, not exec'ing git commands - Use `go-git` for repository operations, not exec'ing git commands
- Platform-specific paths are handled in `internal/config/paths.go` - Platform-specific paths are handled in `internal/config/paths.go`
- Color output uses ANSI codes via the Chroma library - Color output uses ANSI codes via the Chroma library
- Test files use the `internal/mock` package for test data - Test files use the `mocks` package for test data

View File

@@ -1,5 +1,4 @@
Contributing # Contributing
============
Thank you for your interest in `cheat`. Thank you for your interest in `cheat`.
@@ -11,4 +10,8 @@ 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 the [issue tracker][issues]. Before doing so, please search through the
existing open issues to make sure it hasn't already been reported. existing open issues to make sure it hasn't already been reported.
Feature requests may be filed, but are unlikely to be implemented. The project
is now mature and the maintainer considers its feature set to be essentially
complete.
[issues]: https://github.com/cheat/cheat/issues [issues]: https://github.com/cheat/cheat/issues

View File

@@ -1,7 +1,7 @@
# NB: this image isn't used anywhere in the build pipeline. It exists to # NB: this image isn't used anywhere in the build pipeline. It exists to
# conveniently facilitate ad-hoc experimentation in a sandboxed environment # conveniently facilitate ad-hoc experimentation in a sandboxed environment
# during development. # during development.
FROM golang:1.15-alpine FROM golang:1.26-alpine
RUN apk add git less make RUN apk add git less make

View File

@@ -88,7 +88,7 @@ The main configuration structure:
type Config struct { type Config struct {
Colorize bool `yaml:"colorize"` Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"` Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"` Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"` Style string `yaml:"style"`
Formatter string `yaml:"formatter"` Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"` Pager string `yaml:"pager"`
@@ -97,7 +97,7 @@ type Config struct {
``` ```
Key functions: Key functions:
- `New(opts, confPath, resolve)` - Load config from file - `New(confPath, resolve)` - Load config from file
- `Validate()` - Validate configuration values - `Validate()` - Validate configuration values
- `Editor()` - Get editor from environment or defaults (package-level function) - `Editor()` - Get editor from environment or defaults (package-level function)
- `Pager()` - Get pager from environment or defaults (package-level function) - `Pager()` - Get pager from environment or defaults (package-level function)
@@ -107,7 +107,7 @@ Key functions:
Represents a directory containing cheatsheets: Represents a directory containing cheatsheets:
```go ```go
type Cheatpath struct { type Path struct {
Name string // Friendly name (e.g., "personal") Name string // Friendly name (e.g., "personal")
Path string // Filesystem path Path string // Filesystem path
Tags []string // Tags applied to all sheets in this path Tags []string // Tags applied to all sheets in this path
@@ -202,7 +202,7 @@ go test ./... # Go test directly
Test files follow Go conventions: Test files follow Go conventions:
- `*_test.go` files in same package - `*_test.go` files in same package
- Table-driven tests for multiple scenarios - Table-driven tests for multiple scenarios
- Mock data in `internal/mock` package - Mock data in `mocks` package
## Error Handling ## Error Handling

View File

@@ -1,30 +1,29 @@
Installing # Installing
==========
`cheat` has no runtime dependencies. As such, installing it is generally `cheat` has no runtime dependencies. As such, installing it is generally
straightforward. There are a few methods available: straightforward. There are a few methods available:
### Install manually ## Install manually
#### Unix-like ### Unix-like
On Unix-like systems, you may simply paste the following snippet into your terminal: On Unix-like systems, you may simply paste the following snippet into your terminal:
```sh ```sh
cd /tmp \ cd /tmp \
&& wget https://github.com/cheat/cheat/releases/download/4.5.0/cheat-linux-amd64.gz \ && wget https://github.com/cheat/cheat/releases/download/4.7.0/cheat-linux-amd64.gz \
&& gunzip cheat-linux-amd64.gz \ && gunzip cheat-linux-amd64.gz \
&& chmod +x cheat-linux-amd64 \ && chmod +x cheat-linux-amd64 \
&& sudo mv cheat-linux-amd64 /usr/local/bin/cheat && sudo mv cheat-linux-amd64 /usr/local/bin/cheat
``` ```
You may need to need to change the version number (`4.5.0`) and the archive You may need to need to change the version number (`4.7.0`) and the archive
(`cheat-linux-amd64.gz`) depending on your platform. (`cheat-linux-amd64.gz`) depending on your platform.
See the [releases page][releases] for a list of supported platforms. See the [releases page][releases] for a list of supported platforms.
#### Windows ### Windows
On Windows, download the appropriate binary from the [releases page][releases], On Windows, download the appropriate binary from the [releases page][releases],
unzip the archive, and place the `cheat.exe` executable on your `PATH`. unzip the archive, and place the `cheat.exe` executable on your `PATH`.
### Install via `go install` ## Install via `go install`
If you have `go` version `>=1.17` available on your `PATH`, you can install If you have `go` version `>=1.17` available on your `PATH`, you can install
`cheat` via `go install`: `cheat` via `go install`:
@@ -32,7 +31,7 @@ If you have `go` version `>=1.17` available on your `PATH`, you can install
go install github.com/cheat/cheat/cmd/cheat@latest go install github.com/cheat/cheat/cmd/cheat@latest
``` ```
### Install via package manager ## Install via package manager
Several community-maintained packages are also available: Several community-maintained packages are also available:
Package manager | Package(s) Package manager | Package(s)
@@ -43,8 +42,6 @@ docker | [docker-cheat][pkg-docker]
nix | [nixos.cheat][pkg-nix] nix | [nixos.cheat][pkg-nix]
snap | [cheat][pkg-snap] snap | [cheat][pkg-snap]
<!--[pacman][] |-->
## Configuring ## Configuring
Three things must be done before you can use `cheat`: Three things must be done before you can use `cheat`:
1. A config file must be generated 1. A config file must be generated
@@ -56,7 +53,7 @@ automatically. After the installer is complete, it is strongly advised that you
view the configuration file that was generated, as you may want to change some view the configuration file that was generated, as you may want to change some
of its default values (to enable colorization, change the paginator, etc). of its default values (to enable colorization, change the paginator, etc).
### conf.yml ### ### conf.yml
`cheat` is configured by a YAML file that will be auto-generated on first run. `cheat` is configured by a YAML file that will be auto-generated on first run.
By default, the config file is assumed to exist on an XDG-compliant By default, the config file is assumed to exist on an XDG-compliant
@@ -72,8 +69,8 @@ export CHEAT_CONFIG_PATH="~/.dotfiles/cheat/conf.yml"
[community]: https://github.com/cheat/cheatsheets/ [community]: https://github.com/cheat/cheatsheets/
[pkg-aur-cheat-bin]: https://aur.archlinux.org/packages/cheat-bin [pkg-aur-cheat-bin]: https://aur.archlinux.org/packages/cheat-bin
[pkg-aur-cheat]: https://aur.archlinux.org/packages/cheat [pkg-aur-cheat]: https://aur.archlinux.org/packages/cheat
[pkg-brew]: https://formulae.brew.sh/formula/cheat [pkg-brew]: https://formulae.brew.sh/formula/cheat
[pkg-docker]: https://github.com/bannmann/docker-cheat [pkg-docker]: https://github.com/bannmann/docker-cheat
[pkg-nix]: https://search.nixos.org/packages?channel=unstable&show=cheat&from=0&size=50&sort=relevance&type=packages&query=cheat [pkg-nix]: https://search.nixos.org/packages?channel=unstable&show=cheat&from=0&size=50&sort=relevance&type=packages&query=cheat
[pkg-snap]: https://snapcraft.io/cheat [pkg-snap]: https://snapcraft.io/cheat
[releases]: https://github.com/cheat/cheat/releases [releases]: https://github.com/cheat/cheat/releases

View File

@@ -27,6 +27,7 @@ ZIP := zip -m
docker_image := cheat-devel:latest docker_image := cheat-devel:latest
# build flags # build flags
export CGO_ENABLED := 0
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
GOBIN := GOBIN :=
TMPDIR := /tmp TMPDIR := /tmp
@@ -43,6 +44,7 @@ releases := \
$(dist_dir)/cheat-linux-arm7 \ $(dist_dir)/cheat-linux-arm7 \
$(dist_dir)/cheat-netbsd-amd64 \ $(dist_dir)/cheat-netbsd-amd64 \
$(dist_dir)/cheat-openbsd-amd64 \ $(dist_dir)/cheat-openbsd-amd64 \
$(dist_dir)/cheat-plan9-amd64 \
$(dist_dir)/cheat-solaris-amd64 \ $(dist_dir)/cheat-solaris-amd64 \
$(dist_dir)/cheat-windows-amd64.exe $(dist_dir)/cheat-windows-amd64.exe
@@ -212,12 +214,12 @@ test-all: test test-integration
## test-fuzz: run quick fuzz tests for security-critical functions ## test-fuzz: run quick fuzz tests for security-critical functions
.PHONY: test-fuzz .PHONY: test-fuzz
test-fuzz: test-fuzz:
@./build/fuzz.sh 15s @./test/fuzz.sh 15s
## test-fuzz-long: run extended fuzz tests (10 minutes each) ## test-fuzz-long: run extended fuzz tests (10 minutes each)
.PHONY: test-fuzz-long .PHONY: test-fuzz-long
test-fuzz-long: test-fuzz-long:
@./build/fuzz.sh 10m @./test/fuzz.sh 10m
## coverage: generate a test coverage report ## coverage: generate a test coverage report
.PHONY: coverage .PHONY: coverage
@@ -239,22 +241,22 @@ coverage-text: .tmp
## benchmark: run performance benchmarks ## benchmark: run performance benchmarks
.PHONY: benchmark .PHONY: benchmark
benchmark: .tmp benchmark: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./cmd/cheat | tee .tmp/benchmark-latest.txt && \ $(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./test/integration | tee .tmp/benchmark-latest.txt && \
$(RM) -f cheat.test $(RM) -f integration.test
## benchmark-cpu: run benchmarks with CPU profiling ## benchmark-cpu: run benchmarks with CPU profiling
.PHONY: benchmark-cpu .PHONY: benchmark-cpu
benchmark-cpu: .tmp benchmark-cpu: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./cmd/cheat && \ $(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./test/integration && \
$(RM) -f cheat.test && \ $(RM) -f integration.test && \
echo "CPU profile saved to .tmp/cpu.prof" && \ echo "CPU profile saved to .tmp/cpu.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof" echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof"
## benchmark-mem: run benchmarks with memory profiling ## benchmark-mem: run benchmarks with memory profiling
.PHONY: benchmark-mem .PHONY: benchmark-mem
benchmark-mem: .tmp benchmark-mem: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./cmd/cheat && \ $(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./test/integration && \
$(RM) -f cheat.test && \ $(RM) -f integration.test && \
echo "Memory profile saved to .tmp/mem.prof" && \ echo "Memory profile saved to .tmp/mem.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/mem.prof" echo "View with: go tool pprof -http=:8080 .tmp/mem.prof"

View File

@@ -1,8 +1,6 @@
![Workflow status](https://github.com/cheat/cheat/actions/workflows/build.yml/badge.svg) ![Workflow status](https://github.com/cheat/cheat/actions/workflows/build.yml/badge.svg)
# cheat
cheat
=====
`cheat` allows you to create and view interactive cheatsheets on the `cheat` allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind \*nix system administrators of command-line. It was designed to help remind \*nix system administrators of
@@ -13,9 +11,7 @@ remember.
Use `cheat` with [cheatsheets][]. Use `cheat` with [cheatsheets][].
## Example
Example
-------
The next time you're forced to disarm a nuclear weapon without consulting The next time you're forced to disarm a nuclear weapon without consulting
Google, you may run: Google, you may run:
@@ -42,8 +38,10 @@ tar -xjvf '/path/to/foo.tgz'
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/' tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
``` ```
Usage ## Installing
----- For installation and configuration instructions, see [INSTALLING.md][].
## Usage
To view a cheatsheet: To view a cheatsheet:
```sh ```sh
@@ -70,6 +68,12 @@ To list all available cheatsheets:
cheat -l cheat -l
``` ```
To briefly list all cheatsheets (names and tags only):
```sh
cheat -b
```
To list all cheatsheets that are tagged with "networking": To list all cheatsheets that are tagged with "networking":
```sh ```sh
@@ -101,14 +105,7 @@ Flags may be combined in intuitive ways. Example: to search sheets on the
cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}' cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
``` ```
## Cheatsheets
Installing
----------
For installation and configuration instructions, see [INSTALLING.md][].
Cheatsheets
-----------
Cheatsheets are plain-text files with no file extension, and are named Cheatsheets are plain-text files with no file extension, and are named
according to the command used to view them: according to the command used to view them:
@@ -129,12 +126,15 @@ tags: [ array, map ]
const squares = [1, 2, 3, 4].map(x => x * x); const squares = [1, 2, 3, 4].map(x => x * x);
``` ```
Syntax highlighting is provided by [Chroma][], and the `syntax` value may be
set to any lexer name that Chroma supports. See Chroma's [supported
languages][] for a complete list.
The `cheat` executable includes no cheatsheets, but [community-sourced The `cheat` executable includes no cheatsheets, but [community-sourced
cheatsheets are available][cheatsheets]. You will be asked if you would like to cheatsheets are available][cheatsheets]. You will be asked if you would like to
install the community-sourced cheatsheets the first time you run `cheat`. install the community-sourced cheatsheets the first time you run `cheat`.
Cheatpaths ## Cheatpaths
----------
Cheatsheets are stored on "cheatpaths", which are directories that contain Cheatsheets are stored on "cheatpaths", which are directories that contain
cheatsheets. Cheatpaths are specified in the `conf.yml` file. cheatsheets. Cheatpaths are specified in the `conf.yml` file.
@@ -166,14 +166,15 @@ If a user attempts to edit a cheatsheet on a read-only cheatpath, `cheat` will
transparently copy that sheet to a writeable directory before opening it for transparently copy that sheet to a writeable directory before opening it for
editing. editing.
### Directory-scoped Cheatpaths ### ### Directory-scoped Cheatpaths
At times, it can be useful to closely associate cheatsheets with a directory on At times, it can be useful to closely associate cheatsheets with a directory on
your filesystem. `cheat` facilitates this by searching for a `.cheat` folder in your filesystem. `cheat` facilitates this by searching for a `.cheat` directory
the current working directory. If found, the `.cheat` directory will in the current working directory and its ancestors (similar to how `git` locates
(temporarily) be added to the cheatpaths. `.git` directories). The nearest `.cheat` directory found will (temporarily) be
added to the cheatpaths. This means you can place a `.cheat` directory at your
project root and it will be available from any subdirectory within that project.
Autocompletion ## Autocompletion
--------------
Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy
the relevant [completion script][completions] into the appropriate directory on the relevant [completion script][completions] into the appropriate directory on
your filesystem to enable autocompletion. (This directory will vary depending your filesystem to enable autocompletion. (This directory will vary depending
@@ -189,5 +190,6 @@ Additionally, `cheat` supports enhanced autocompletion via integration with
[Releases]: https://github.com/cheat/cheat/releases [Releases]: https://github.com/cheat/cheat/releases
[cheatsheets]: https://github.com/cheat/cheatsheets [cheatsheets]: https://github.com/cheat/cheatsheets
[completions]: https://github.com/cheat/cheat/tree/master/scripts [completions]: https://github.com/cheat/cheat/tree/master/scripts
[fzf]: https://github.com/junegunn/fzf [Chroma]: https://github.com/alecthomas/chroma
[go]: https://golang.org [supported languages]: https://github.com/alecthomas/chroma#supported-languages
[fzf]: https://github.com/junegunn/fzf

View File

@@ -38,10 +38,10 @@ The validation is performed at the application layer before any file operations
### Validation Function ### Validation Function
The validation is implemented in `internal/cheatpath/validate.go`: The validation is implemented in `internal/sheet/validate.go`:
```go ```go
func ValidateSheetName(name string) error { func Validate(name string) error {
// Reject empty names // Reject empty names
if name == "" { if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty") return fmt.Errorf("cheatsheet name cannot be empty")
@@ -133,7 +133,7 @@ The following patterns are explicitly allowed:
Comprehensive tests ensure the validation works correctly: Comprehensive tests ensure the validation works correctly:
1. **Unit tests** (`internal/cheatpath/validate_test.go`) verify the validation logic 1. **Unit tests** (`internal/sheet/validate_test.go`) verify the validation logic
2. **Integration tests** verify the actual binary blocks malicious inputs 2. **Integration tests** verify the actual binary blocks malicious inputs
3. **No system files are accessed** during testing - all tests use isolated directories 3. **No system files are accessed** during testing - all tests use isolated directories

View File

@@ -0,0 +1,80 @@
# ADR-004: Recursive `.cheat` Directory Search
Date: 2026-02-15
## Status
Accepted
## Context
Previously, `cheat` only checked the current working directory for a `.cheat`
subdirectory to use as a directory-scoped cheatpath. If a user was in
`~/projects/myapp/src/handlers/` but the `.cheat` directory lived at
`~/projects/myapp/.cheat`, it would not be found. Users requested (#602) that
`cheat` walk up the directory hierarchy to find the nearest `.cheat`
directory, mirroring the discovery pattern used by `git` for `.git`
directories.
## Decision
Walk upward from the current working directory to the filesystem root, and
stop at the first `.cheat` directory found. Only directories are matched (a
file named `.cheat` is ignored).
### Stop at first `.cheat` found
Rather than collecting multiple `.cheat` directories from ancestor directories:
- Matches `.git` discovery semantics, which users already understand
- Fits the existing single-cheatpath-named-`"cwd"` code without structural
changes
- Avoids precedence and naming complexity when multiple `.cheat` directories
exist in the ancestor chain
- `cheat` already supports multiple cheatpaths via `conf.yml` for users who
want that; directory-scoped `.cheat` serves the project-context use case
### Walk to filesystem root (not `$HOME`)
Rather than stopping the search at `$HOME`:
- Simpler implementation with no platform-specific home-directory detection
- Supports sysadmins working in `/etc`, `/srv`, `/var`, or other paths
outside `$HOME`
- The boundary only matters on the failure path (no `.cheat` found anywhere),
where the cost is a few extra `stat` calls
- Security is not a concern since cheatsheets are display-only text, not
executable code
## Consequences
### Positive
- Users can place `.cheat` at their project root and it works from any
subdirectory, matching their mental model
- No configuration changes needed; existing `.cheat` directories continue to
work identically
- Minimal code change (one small helper function)
### Negative
- A `.cheat` directory in an unexpected ancestor could be picked up
unintentionally, though this is unlikely in practice and matches how `.git`
works
### Neutral
- The cheatpath name remains `"cwd"` regardless of which ancestor the `.cheat`
was found in
## Alternatives Considered
### 1. Stop at `$HOME`
**Rejected**: Adds platform-specific complexity for minimal benefit. The only
downside of walking to root is a few extra `stat` calls on the failure path.
### 2. Collect multiple `.cheat` directories
**Rejected**: Introduces precedence and naming complexity. Users who want
multiple cheatpaths can configure them in `conf.yml`.
## References
- GitHub issue: #602
- Implementation: `findLocalCheatpath()` in `internal/config/config.go`

View File

@@ -9,6 +9,7 @@ import (
"github.com/cheat/cheat/internal/cheatpath" "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets" "github.com/cheat/cheat/internal/sheets"
) )
@@ -18,7 +19,7 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--edit"].(string) cheatsheet := opts["--edit"].(string)
// validate the cheatsheet name // validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil { if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err) fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -29,8 +30,6 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -52,55 +51,36 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
// if the sheet exists and is not read-only, edit it in place // if the sheet exists and is not read-only, edit it in place
if ok && !sheet.ReadOnly { if ok && !sheet.ReadOnly {
editpath = sheet.Path editpath = sheet.Path
} else {
// if the sheet exists but is read-only, copy it before editing // for read-only or non-existent sheets, resolve a writeable path
} else if ok && sheet.ReadOnly {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths) writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err) fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// compute the new edit path // use the existing title for read-only copies, the requested name otherwise
editpath = filepath.Join(writepath.Path, sheet.Title) title := cheatsheet
if ok {
title = sheet.Title
}
editpath = filepath.Join(writepath.Path, title)
// create any necessary subdirectories if ok {
dirs := filepath.Dir(editpath) // copy the read-only sheet to the writeable path
if dirs != "." { // (Copy handles MkdirAll internally)
if err := os.MkdirAll(dirs, 0755); err != nil { if err := sheet.Copy(editpath); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err) fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} } else {
// create any necessary subdirectories for the new sheet
// copy the sheet to the new edit path dirs := filepath.Dir(editpath)
err = sheet.Copy(editpath) if dirs != "." {
if err != nil { if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err) fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1) os.Exit(1)
} }
// if the sheet does not exist, create it
} else {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1)
}
// compute the new edit path
editpath = filepath.Join(writepath.Path, cheatsheet)
// create any necessary subdirectories
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1)
} }
} }
} }

View File

@@ -3,54 +3,27 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
) )
// cmdInit displays an example config file. // cmdInit displays an example config file.
func cmdInit() { func cmdInit(home string, envvars map[string]string) {
// get the user's home directory // identify the os-specific paths at which configs may be located
home, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
os.Exit(1)
}
// read the envvars into a map of strings
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
envvars[pair[0]] = pair[1]
}
// load the config template
configs := configs()
// identify the os-specifc paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars) confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err) fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confpath := confpaths[0] confpath := confpaths[0]
confdir := filepath.Dir(confpath)
// create paths for community and personal cheatsheets // expand template placeholders and comment out community cheatpath
community := filepath.Join(confdir, "cheatsheets", "community") configs := installer.ExpandTemplate(configs(), confpath)
personal := filepath.Join(confdir, "cheatsheets", "personal") configs = installer.CommentCommunity(configs, confpath)
// template the above paths into the default configs
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
// output the templated configs // output the templated configs
fmt.Println(configs) fmt.Println(configs)

View File

@@ -24,8 +24,6 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatsheets by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -87,12 +85,17 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
var out bytes.Buffer var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0) w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
// write a header row
fmt.Fprintln(w, "title:\tfile:\ttags:")
// generate sorted, columnized output // generate sorted, columnized output
for _, sheet := range flattened { if opts["--brief"].(bool) {
fmt.Fprintf(w, "%s\t%s\t%s\n", sheet.Title, sheet.Path, strings.Join(sheet.Tags, ",")) fmt.Fprintln(w, "title:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\n", sheet.Title, strings.Join(sheet.Tags, ","))
}
} else {
fmt.Fprintln(w, "title:\tfile:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\t%s\n", sheet.Title, sheet.Path, strings.Join(sheet.Tags, ","))
}
} }
// write columnized output to stdout // write columnized output to stdout

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"strings" "strings"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets" "github.com/cheat/cheat/internal/sheets"
) )
@@ -16,7 +16,7 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--rm"].(string) cheatsheet := opts["--rm"].(string)
// validate the cheatsheet name // validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil { if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err) fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -27,8 +27,6 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,

View File

@@ -22,8 +22,6 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -80,7 +78,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
// append the cheatsheet title // append the cheatsheet title
sheet.Title, sheet.Title,
// append the cheatsheet path // append the cheatsheet path
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf), display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(opts)),
// indent each line of content // indent each line of content
display.Indent(sheet.Text), display.Indent(sheet.Text),
) )

View File

@@ -21,8 +21,6 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err) fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil { if opts["--tag"] != nil {
cheatsheets = sheets.Filter( cheatsheets = sheets.Filter(
cheatsheets, cheatsheets,
@@ -42,7 +40,7 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
// identify the matching cheatsheet // identify the matching cheatsheet
out += fmt.Sprintf("%s %s\n", out += fmt.Sprintf("%s %s\n",
sheet.Title, sheet.Title,
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf), display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(opts)),
) )
// apply colorization if requested // apply colorization if requested

View File

@@ -3,7 +3,7 @@ package main
// configs returns the default configuration template // configs returns the default configuration template
func configs() string { func configs() string {
return `--- return `---
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL. # The editor to use with 'cheat -e <sheet>'. Overridden by $VISUAL or $EDITOR.
editor: EDITOR_PATH editor: EDITOR_PATH
# Should 'cheat' always colorize output? # Should 'cheat' always colorize output?
@@ -56,7 +56,8 @@ cheatpaths:
tags: [ work ] tags: [ work ]
readonly: false readonly: false
# Community cheatsheets are stored here by default: # Community cheatsheets (https://github.com/cheat/cheatsheets):
# To install: git clone https://github.com/cheat/cheatsheets COMMUNITY_PATH
- name: community - name: community
path: COMMUNITY_PATH path: COMMUNITY_PATH
tags: [ community ] tags: [ community ]

View File

@@ -15,7 +15,7 @@ import (
"github.com/cheat/cheat/internal/installer" "github.com/cheat/cheat/internal/installer"
) )
const version = "4.5.0" const version = "4.7.1"
func main() { func main() {
@@ -26,13 +26,6 @@ func main() {
panic(fmt.Errorf("docopt failed to parse: %v", err)) panic(fmt.Errorf("docopt failed to parse: %v", err))
} }
// if --init was passed, we don't want to attempt to load a config file.
// Instead, just execute cmd_init and exit
if opts["--init"] != nil && opts["--init"] == true {
cmdInit()
os.Exit(0)
}
// get the user's home directory // get the user's home directory
home, err := homedir.Dir() home, err := homedir.Dir()
if err != nil { if err != nil {
@@ -51,6 +44,13 @@ func main() {
envvars[pair[0]] = pair[1] envvars[pair[0]] = pair[1]
} }
// if --init was passed, we don't want to attempt to load a config file.
// Instead, just execute cmd_init and exit
if opts["--init"] == true {
cmdInit(home, envvars)
os.Exit(0)
}
// identify the os-specifc paths at which configs may be located // identify the os-specifc paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars) confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil { if err != nil {
@@ -92,7 +92,7 @@ func main() {
} }
// initialize the configs // initialize the configs
conf, err := config.New(opts, confpath, true) conf, err := config.New(confpath, true)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -129,7 +129,7 @@ func main() {
case opts["--edit"] != nil: case opts["--edit"] != nil:
cmd = cmdEdit cmd = cmdEdit
case opts["--list"].(bool): case opts["--list"].(bool), opts["--brief"].(bool):
cmd = cmdList cmd = cmdList
case opts["--tags"].(bool): case opts["--tags"].(bool):

View File

@@ -8,6 +8,7 @@ func usage() string {
Options: Options:
--init Write a default config file to stdout --init Write a default config file to stdout
-a --all Search among all cheatpaths -a --all Search among all cheatpaths
-b --brief List cheatsheets without file paths
-c --colorize Colorize output -c --colorize Colorize output
-d --directories List cheatsheet directories -d --directories List cheatsheet directories
-e --edit=<cheatsheet> Edit <cheatsheet> -e --edit=<cheatsheet> Edit <cheatsheet>
@@ -41,8 +42,8 @@ Examples:
To list all available cheatsheets: To list all available cheatsheets:
cheat -l cheat -l
To list all cheatsheets whose titles match "apt": To briefly list all cheatsheets whose titles match "apt":
cheat -l apt cheat -b apt
To list all tags in use: To list all tags in use:
cheat -T cheat -T

View File

@@ -1,38 +0,0 @@
# Architecture Decision Records
This directory contains Architecture Decision Records (ADRs) for the cheat project.
## What is an ADR?
An Architecture Decision Record captures an important architectural decision made along with its context and consequences. ADRs help us:
- Document why decisions were made
- Understand the context and trade-offs
- Review decisions when requirements change
- Onboard new contributors
## ADR Format
Each ADR follows this template:
1. **Title**: ADR-NNN: Brief description
2. **Date**: When the decision was made
3. **Status**: Proposed, Accepted, Deprecated, Superseded
4. **Context**: What prompted this decision?
5. **Decision**: What did we decide to do?
6. **Consequences**: What are the positive, negative, and neutral outcomes?
## Index of ADRs
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [001](001-path-traversal-protection.md) | Path Traversal Protection for Cheatsheet Names | Accepted | 2025-01-21 |
| [002](002-environment-variable-parsing.md) | No Defensive Checks for Environment Variable Parsing | Accepted | 2025-01-21 |
| [003](003-search-parallelization.md) | No Parallelization for Search Operations | Accepted | 2025-01-22 |
## Creating a New ADR
1. Copy the template from an existing ADR
2. Use the next sequential number
3. Fill in all sections
4. Include the ADR alongside the commit implementing the decision

View File

@@ -23,6 +23,9 @@ Display the config file path.
\-a, \[en]all \-a, \[en]all
Search among all cheatpaths. Search among all cheatpaths.
.TP .TP
\-b, \[en]brief
List cheatsheets without file paths.
.TP
\-c, \[en]colorize \-c, \[en]colorize
Colorize output. Colorize output.
.TP .TP
@@ -72,8 +75,8 @@ cheat \-d
To list all available cheatsheets: To list all available cheatsheets:
cheat \-l cheat \-l
.TP .TP
To list all cheatsheets whose titles match `apt': To briefly list all cheatsheets whose titles match `apt':
cheat \-l \f[I]apt\f[R] cheat \-b \f[I]apt\f[R]
.TP .TP
To list all tags in use: To list all tags in use:
cheat \-T cheat \-T

View File

@@ -29,6 +29,9 @@ OPTIONS
-a, --all -a, --all
: Search among all cheatpaths. : Search among all cheatpaths.
-b, --brief
: List cheatsheets without file paths.
-c, --colorize -c, --colorize
: Colorize output. : Colorize output.
@@ -81,8 +84,8 @@ To view all cheatsheet directories:
To list all available cheatsheets: To list all available cheatsheets:
: cheat -l : cheat -l
To list all cheatsheets whose titles match 'apt': To briefly list all cheatsheets whose titles match 'apt':
: cheat -l _apt_ : cheat -b _apt_
To list all tags in use: To list all tags in use:
: cheat -T : cheat -T

39
go.mod
View File

@@ -1,38 +1,37 @@
module github.com/cheat/cheat module github.com/cheat/cheat
go 1.19 go 1.26
require ( require (
github.com/alecthomas/chroma/v2 v2.12.0 github.com/alecthomas/chroma/v2 v2.23.1
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 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/mattn/go-isatty v0.0.20
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emirpasic/gods v1.18.1 // 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/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.5.0 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/skeema/knownhosts v1.2.1 // 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 github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.17.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/tools v0.16.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )

152
go.sum
View File

@@ -1,140 +1,118 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 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.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 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 h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 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 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 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.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
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/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= 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.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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 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 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 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.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/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.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 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 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/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.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 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/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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 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.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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
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/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-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-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-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-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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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 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-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 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 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 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.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -2,27 +2,10 @@
// management. // management.
package cheatpath package cheatpath
import "fmt" // Path encapsulates cheatsheet path information
type Path struct {
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Path string `yaml:"path"` Path string `yaml:"path"`
ReadOnly bool `yaml:"readonly"` ReadOnly bool `yaml:"readonly"`
Tags []string `yaml:"tags"` 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

@@ -8,13 +8,13 @@ import (
func TestCheatpathValidate(t *testing.T) { func TestCheatpathValidate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cheatpath Cheatpath cheatpath Path
wantErr bool wantErr bool
errMsg string errMsg string
}{ }{
{ {
name: "valid cheatpath", name: "valid cheatpath",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "personal", Name: "personal",
Path: "/home/user/.config/cheat/personal", Path: "/home/user/.config/cheat/personal",
ReadOnly: false, ReadOnly: false,
@@ -24,7 +24,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "empty name", name: "empty name",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "", Name: "",
Path: "/home/user/.config/cheat/personal", Path: "/home/user/.config/cheat/personal",
ReadOnly: false, ReadOnly: false,
@@ -35,7 +35,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "empty path", name: "empty path",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "personal", Name: "personal",
Path: "", Path: "",
ReadOnly: false, ReadOnly: false,
@@ -46,7 +46,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "both empty", name: "both empty",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "", Name: "",
Path: "", Path: "",
ReadOnly: true, ReadOnly: true,
@@ -57,7 +57,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "minimal valid", name: "minimal valid",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "x", Name: "x",
Path: "/", Path: "/",
}, },
@@ -65,7 +65,7 @@ func TestCheatpathValidate(t *testing.T) {
}, },
{ {
name: "with readonly and tags", name: "with readonly and tags",
cheatpath: Cheatpath{ cheatpath: Path{
Name: "community", Name: "community",
Path: "/usr/share/cheat", Path: "/usr/share/cheat",
ReadOnly: true, ReadOnly: true,
@@ -88,26 +88,3 @@ func TestCheatpathValidate(t *testing.T) {
}) })
} }
} }
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)
}
}

View File

@@ -1,63 +0,0 @@
// 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

@@ -5,15 +5,15 @@ import (
) )
// Filter filters all cheatpaths that are not named `name` // Filter filters all cheatpaths that are not named `name`
func Filter(paths []Cheatpath, name string) ([]Cheatpath, error) { func Filter(paths []Path, name string) ([]Path, error) {
// if a path of the given name exists, return it // if a path of the given name exists, return it
for _, path := range paths { for _, path := range paths {
if path.Name == name { if path.Name == name {
return []Cheatpath{path}, nil return []Path{path}, nil
} }
} }
// otherwise, return an error // otherwise, return an error
return []Cheatpath{}, fmt.Errorf("cheatpath does not exist: %s", name) return []Path{}, fmt.Errorf("cheatpath does not exist: %s", name)
} }

View File

@@ -9,10 +9,10 @@ import (
func TestFilterSuccess(t *testing.T) { func TestFilterSuccess(t *testing.T) {
// init cheatpaths // init cheatpaths
paths := []Cheatpath{ paths := []Path{
Cheatpath{Name: "foo"}, Path{Name: "foo"},
Cheatpath{Name: "bar"}, Path{Name: "bar"},
Cheatpath{Name: "baz"}, Path{Name: "baz"},
} }
// filter the paths // filter the paths
@@ -39,10 +39,10 @@ func TestFilterSuccess(t *testing.T) {
func TestFilterFailure(t *testing.T) { func TestFilterFailure(t *testing.T) {
// init cheatpaths // init cheatpaths
paths := []Cheatpath{ paths := []Path{
Cheatpath{Name: "foo"}, Path{Name: "foo"},
Cheatpath{Name: "bar"}, Path{Name: "bar"},
Cheatpath{Name: "baz"}, Path{Name: "baz"},
} }
// filter the paths // filter the paths

View File

@@ -2,39 +2,15 @@ package cheatpath
import ( import (
"fmt" "fmt"
"path/filepath"
"strings"
) )
// ValidateSheetName ensures that a cheatsheet name does not contain // Validate ensures that the Path is valid
// directory traversal sequences or other potentially dangerous patterns. func (c Path) Validate() error {
func ValidateSheetName(name string) error { if c.Name == "" {
// Reject empty names return fmt.Errorf("cheatpath name cannot be empty")
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
} }
if c.Path == "" {
// Reject names containing directory traversal return fmt.Errorf("cheatpath path cannot be empty")
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 return nil
} }

View File

@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
) )
// Writeable returns a writeable Cheatpath // Writeable returns a writeable Path
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) { func Writeable(cheatpaths []Path) (Path, error) {
// iterate backwards over the cheatpaths // iterate backwards over the cheatpaths
// NB: we're going backwards because we assume that the most "local" // NB: we're going backwards because we assume that the most "local"
@@ -18,5 +18,5 @@ func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
} }
// otherwise, return an error // otherwise, return an error
return Cheatpath{}, fmt.Errorf("no writeable cheatpaths found") return Path{}, fmt.Errorf("no writeable cheatpaths found")
} }

View File

@@ -9,10 +9,10 @@ import (
func TestWriteableOK(t *testing.T) { func TestWriteableOK(t *testing.T) {
// initialize some cheatpaths // initialize some cheatpaths
cheatpaths := []Cheatpath{ cheatpaths := []Path{
Cheatpath{Path: "/foo", ReadOnly: true}, Path{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: false}, Path{Path: "/bar", ReadOnly: false},
Cheatpath{Path: "/baz", ReadOnly: true}, Path{Path: "/baz", ReadOnly: true},
} }
// get the writeable cheatpath // get the writeable cheatpath
@@ -34,10 +34,10 @@ func TestWriteableOK(t *testing.T) {
func TestWriteableNotOK(t *testing.T) { func TestWriteableNotOK(t *testing.T) {
// initialize some cheatpaths // initialize some cheatpaths
cheatpaths := []Cheatpath{ cheatpaths := []Path{
Cheatpath{Path: "/foo", ReadOnly: true}, Path{Path: "/foo", ReadOnly: true},
Cheatpath{Path: "/bar", ReadOnly: true}, Path{Path: "/bar", ReadOnly: true},
Cheatpath{Path: "/baz", ReadOnly: true}, Path{Path: "/baz", ReadOnly: true},
} }
// get the writeable cheatpath // get the writeable cheatpath

View File

@@ -2,123 +2,16 @@
package config package config
import ( import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath" cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
) )
// Config encapsulates configuration parameters // Config encapsulates configuration parameters
type Config struct { type Config struct {
Colorize bool `yaml:"colorize"` Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"` Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"` Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"` Style string `yaml:"style"`
Formatter string `yaml:"formatter"` Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"` Pager string `yaml:"pager"`
Path string Path string
} }
// New returns a new Config struct
func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists locally, append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
local := filepath.Join(cwd, ".cheat")
if _, err := os.Stat(local); err == nil {
path := cp.Cheatpath{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
for i, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
conf.Cheatpaths[i].Path = expanded
}
// trim editor whitespace
conf.Editor = strings.TrimSpace(conf.Editor)
// if an editor was not provided in the configs, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// if a chroma style was not provided, set a default
if conf.Style == "" {
conf.Style = "bw"
}
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}

View File

@@ -5,7 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestConfigYAMLErrors tests YAML parsing errors // TestConfigYAMLErrors tests YAML parsing errors
@@ -18,71 +18,22 @@ func TestConfigYAMLErrors(t *testing.T) {
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
invalidYAML := filepath.Join(tempDir, "invalid.yml") invalidYAML := filepath.Join(tempDir, "invalid.yml")
err = os.WriteFile(invalidYAML, []byte("invalid: yaml: content:\n - no closing"), 0644) err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
if err != nil { if err != nil {
t.Fatalf("failed to write invalid yaml: %v", err) t.Fatalf("failed to write invalid yaml: %v", err)
} }
// Attempt to load invalid YAML // Attempt to load invalid YAML
_, err = New(map[string]interface{}{}, invalidYAML, false) _, err = New(invalidYAML, false)
if err == nil { if err == nil {
t.Error("expected error for invalid YAML, got 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)
// 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 // TestConfigDefaults tests default values
func TestConfigDefaults(t *testing.T) { func TestConfigDefaults(t *testing.T) {
// Load empty config // Load empty config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false) conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to load config: %v", err) t.Errorf("failed to load config: %v", err)
} }
@@ -106,6 +57,12 @@ func TestConfigSymlinkResolution(t *testing.T) {
} }
defer os.RemoveAll(tempDir) 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 // Create target directory
targetDir := filepath.Join(tempDir, "target") targetDir := filepath.Join(tempDir, "target")
err = os.Mkdir(targetDir, 0755) err = os.Mkdir(targetDir, 0755)
@@ -135,13 +92,16 @@ cheatpaths:
} }
// Load config with symlink resolution // Load config with symlink resolution
conf, err := New(map[string]interface{}{}, configFile, true) conf, err := New(configFile, true)
if err != nil { if err != nil {
t.Errorf("failed to load config: %v", err) t.Errorf("failed to load config: %v", err)
} }
// Verify symlink was resolved // Verify symlink was resolved
if len(conf.Cheatpaths) > 0 && conf.Cheatpaths[0].Path != targetDir { if len(conf.Cheatpaths) == 0 {
t.Fatal("expected at least one cheatpath, got none")
}
if conf.Cheatpaths[0].Path != targetDir {
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path) t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
} }
} }
@@ -176,72 +136,13 @@ cheatpaths:
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
// Load config with symlink resolution should fail // Load config with symlink resolution should skip the broken cheatpath
_, err = New(map[string]interface{}{}, configFile, true) // (warn to stderr) rather than hard-error
if err == nil { conf, err := New(configFile, true)
t.Error("expected error for broken symlink, got nil") 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) {
// 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

@@ -0,0 +1,67 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// FuzzFindLocalCheatpath exercises findLocalCheatpath with randomised
// directory depths and .cheat placements. For each fuzz input it builds a
// temporary directory hierarchy, places a single .cheat directory at a
// computed level, and asserts that the function always returns it.
func FuzzFindLocalCheatpath(f *testing.F) {
// Seed corpus: (totalDepth, cheatPlacement)
f.Add(uint8(1), uint8(0)) // depth 1, .cheat at root
f.Add(uint8(3), uint8(0)) // depth 3, .cheat at root
f.Add(uint8(5), uint8(3)) // depth 5, .cheat at level 3
f.Add(uint8(1), uint8(1)) // depth 1, .cheat at same level as search dir
f.Add(uint8(10), uint8(5)) // deep hierarchy
f.Fuzz(func(t *testing.T, totalDepth uint8, cheatPlacement uint8) {
// Clamp to reasonable values to keep I/O bounded
depth := int(totalDepth%15) + 1 // 1..15
cheatAt := int(cheatPlacement) % (depth + 1) // 0..depth (0 = tempDir itself)
tempDir := t.TempDir()
// Build chain: tempDir/d0/d1/…/d{depth-1}
dirs := make([]string, 0, depth+1)
dirs = append(dirs, tempDir)
current := tempDir
for i := 0; i < depth; i++ {
current = filepath.Join(current, fmt.Sprintf("d%d", i))
if err := os.Mkdir(current, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
dirs = append(dirs, current)
}
// Place .cheat at dirs[cheatAt]
cheatDir := filepath.Join(dirs[cheatAt], ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("mkdir .cheat: %v", err)
}
// Search from the deepest directory
result := findLocalCheatpath(current)
// Invariant 1: must find the .cheat we placed
if result != cheatDir {
t.Errorf("depth=%d cheatAt=%d: expected %s, got %s",
depth, cheatAt, cheatDir, result)
}
// Invariant 2: result must end with /.cheat
if !strings.HasSuffix(result, string(filepath.Separator)+".cheat") {
t.Errorf("result %q does not end with /.cheat", result)
}
// Invariant 3: result must be under tempDir
if !strings.HasPrefix(result, tempDir) {
t.Errorf("result %q is not under tempDir %s", result, tempDir)
}
})
}

View File

@@ -4,20 +4,289 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"testing" "testing"
"github.com/davecgh/go-spew/spew" "github.com/davecgh/go-spew/spew"
"github.com/mitchellh/go-homedir" "github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/cheatpath" "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
func TestFindLocalCheatpathInCurrentDir(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
result := findLocalCheatpath(tempDir)
if result != cheatDir {
t.Errorf("expected %s, got %s", cheatDir, result)
}
}
// TestFindLocalCheatpathInParent tests walking up to a parent directory
func TestFindLocalCheatpathInParent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
result := findLocalCheatpath(subDir)
if result != cheatDir {
t.Errorf("expected %s, got %s", cheatDir, result)
}
}
// TestFindLocalCheatpathInGrandparent tests walking up multiple levels
func TestFindLocalCheatpathInGrandparent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
deepDir := filepath.Join(tempDir, "a", "b", "c")
if err := os.MkdirAll(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
result := findLocalCheatpath(deepDir)
if result != cheatDir {
t.Errorf("expected %s, got %s", cheatDir, result)
}
}
// TestFindLocalCheatpathNearestWins tests that the closest .cheat is returned
func TestFindLocalCheatpathNearestWins(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create .cheat at root level
if err := os.Mkdir(filepath.Join(tempDir, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create root .cheat dir: %v", err)
}
// Create sub/.cheat (the nearer one)
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
nearCheatDir := filepath.Join(subDir, ".cheat")
if err := os.Mkdir(nearCheatDir, 0755); err != nil {
t.Fatalf("failed to create sub .cheat dir: %v", err)
}
// Search from sub/deep/
deepDir := filepath.Join(subDir, "deep")
if err := os.Mkdir(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
result := findLocalCheatpath(deepDir)
if result != nearCheatDir {
t.Errorf("expected nearest %s, got %s", nearCheatDir, result)
}
}
// TestFindLocalCheatpathNotFound tests that empty string is returned when no .cheat exists
func TestFindLocalCheatpathNotFound(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
result := findLocalCheatpath(tempDir)
if result != "" {
t.Errorf("expected empty string, got %s", result)
}
}
// TestFindLocalCheatpathSkipsFile tests that a file named .cheat is not matched
func TestFindLocalCheatpathSkipsFile(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create .cheat as a file, not a directory
cheatFile := filepath.Join(tempDir, ".cheat")
if err := os.WriteFile(cheatFile, []byte("not a directory"), 0644); err != nil {
t.Fatalf("failed to create .cheat file: %v", err)
}
result := findLocalCheatpath(tempDir)
if result != "" {
t.Errorf("expected empty string for .cheat file, got %s", result)
}
}
// TestFindLocalCheatpathSymlink tests that a .cheat symlink to a directory is found
func TestFindLocalCheatpathSymlink(t *testing.T) {
tempDir := t.TempDir()
// Create the real directory
realDir := filepath.Join(tempDir, "real-cheat")
if err := os.Mkdir(realDir, 0755); err != nil {
t.Fatalf("failed to create real dir: %v", err)
}
// Symlink .cheat -> real-cheat
cheatLink := filepath.Join(tempDir, ".cheat")
if err := os.Symlink(realDir, cheatLink); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
result := findLocalCheatpath(tempDir)
if result != cheatLink {
t.Errorf("expected %s, got %s", cheatLink, result)
}
}
// TestFindLocalCheatpathSymlinkInAncestor tests discovery through a symlinked
// ancestor directory. When the cwd is reached via a symlink, filepath.Dir
// walks the symlinked path (not the real path), so .cheat must be findable
// through that chain.
func TestFindLocalCheatpathSymlinkInAncestor(t *testing.T) {
tempDir := t.TempDir()
// Create real/project/.cheat
realProject := filepath.Join(tempDir, "real", "project")
if err := os.MkdirAll(realProject, 0755); err != nil {
t.Fatalf("failed to create real project dir: %v", err)
}
if err := os.Mkdir(filepath.Join(realProject, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create symlink: linked -> real/project
linkedProject := filepath.Join(tempDir, "linked")
if err := os.Symlink(realProject, linkedProject); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Create sub inside the symlinked path
subDir := filepath.Join(linkedProject, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
// Search from linked/sub — should find linked/.cheat
// (os.Stat follows symlinks, so linked/.cheat resolves to real/project/.cheat)
result := findLocalCheatpath(subDir)
expected := filepath.Join(linkedProject, ".cheat")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
// TestFindLocalCheatpathPermissionDenied tests that unreadable ancestor
// directories are skipped and the walk continues upward.
func TestFindLocalCheatpathPermissionDenied(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permissions do not apply on Windows")
}
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
tempDir := t.TempDir()
// Resolve symlinks (macOS /var -> /private/var)
tempDir, err := filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve symlinks: %v", err)
}
// Create tempDir/.cheat (the target we want found)
cheatDir := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(cheatDir, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create tempDir/restricted/ with its own .cheat and sub/
restricted := filepath.Join(tempDir, "restricted")
if err := os.Mkdir(restricted, 0755); err != nil {
t.Fatalf("failed to create restricted dir: %v", err)
}
if err := os.Mkdir(filepath.Join(restricted, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create restricted .cheat dir: %v", err)
}
subDir := filepath.Join(restricted, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
// Make restricted/ unreadable — blocks stat of children
if err := os.Chmod(restricted, 0000); err != nil {
t.Fatalf("failed to chmod: %v", err)
}
t.Cleanup(func() { os.Chmod(restricted, 0755) })
// Walk from restricted/sub: stat("restricted/sub/.cheat") fails (EACCES),
// stat("restricted/.cheat") fails (EACCES), walk continues to tempDir/.cheat
result := findLocalCheatpath(subDir)
if result != cheatDir {
t.Errorf("expected %s (walked past restricted dir), got %s", cheatDir, result)
}
}
// TestConfig asserts that the configs are loaded correctly // TestConfig asserts that the configs are loaded correctly
func TestConfigSuccessful(t *testing.T) { func TestConfigSuccessful(t *testing.T) {
// Chdir into a temp directory so no ancestor .cheat directory can
// leak into the cheatpaths (findLocalCheatpath walks the full
// ancestor chain).
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
// 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 // initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false) conf, err := New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to parse config file: %v", err) t.Errorf("failed to parse config file: %v", err)
} }
@@ -37,18 +306,18 @@ func TestConfigSuccessful(t *testing.T) {
} }
// assert that the cheatpaths are correct // assert that the cheatpaths are correct
want := []cheatpath.Cheatpath{ want := []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "community"), Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
ReadOnly: true, ReadOnly: true,
Tags: []string{"community"}, Tags: []string{"community"},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "work"), Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
ReadOnly: false, ReadOnly: false,
Tags: []string{"work"}, Tags: []string{"work"},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"), Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
ReadOnly: false, ReadOnly: false,
Tags: []string{"personal"}, Tags: []string{"personal"},
@@ -69,43 +338,84 @@ func TestConfigSuccessful(t *testing.T) {
func TestConfigFailure(t *testing.T) { func TestConfigFailure(t *testing.T) {
// attempt to read a non-existent config file // attempt to read a non-existent config file
_, err := New(map[string]interface{}{}, "/does-not-exit", false) _, err := New("/does-not-exit", false)
if err == nil { if err == nil {
t.Errorf("failed to error on unreadable config") t.Errorf("failed to error on unreadable config")
} }
} }
// TestEmptyEditor asserts that envvars are respected if an editor is not // TestEditorEnvOverride asserts that $VISUAL and $EDITOR override the
// specified in the configs // config file value at runtime (regression test for #589)
func TestEmptyEditor(t *testing.T) { 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 // with no env vars, the config file value should be used
os.Setenv("VISUAL", "") os.Unsetenv("VISUAL")
os.Setenv("EDITOR", "") os.Unsetenv("EDITOR")
conf, err := New(mocks.Path("conf/conf.yml"), false)
// initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to initialize test: %v", err) t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "vim" {
t.Errorf("expected config file editor: want: vim, got: %s", conf.Editor)
} }
// set editor, and assert that it is respected // $EDITOR should override the config file value
os.Setenv("EDITOR", "foo") os.Setenv("EDITOR", "nano")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false) conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }
if conf.Editor != "foo" { if conf.Editor != "nano" {
t.Errorf("failed to respect editor: want: foo, got: %s", conf.Editor) t.Errorf("$EDITOR should override config: want: nano, got: %s", conf.Editor)
} }
// set visual, and assert that it overrides editor // $VISUAL should override both $EDITOR and the config file value
os.Setenv("VISUAL", "bar") os.Setenv("VISUAL", "emacs")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false) conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil { if err != nil {
t.Errorf("failed to init configs: %v", err) t.Fatalf("failed to init configs: %v", err)
} }
if conf.Editor != "bar" { if conf.Editor != "emacs" {
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor) 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(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "foo" {
t.Errorf("failed to respect $EDITOR: want: foo, got: %s", conf.Editor)
}
// set $VISUAL and assert it takes precedence over $EDITOR
os.Setenv("VISUAL", "bar")
conf, err = New(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "bar" {
t.Errorf("failed to respect $VISUAL: want: bar, got: %s", conf.Editor)
} }
} }

View File

@@ -1,52 +0,0 @@
// 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

@@ -3,7 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "runtime"
"testing" "testing"
) )
@@ -74,18 +74,21 @@ func TestInitCreateDirectory(t *testing.T) {
// TestInitWriteError tests error handling when file write fails // TestInitWriteError tests error handling when file write fails
func TestInitWriteError(t *testing.T) { func TestInitWriteError(t *testing.T) {
// Skip this test if running as root (can write anywhere) // Skip this test if running as root (can write anywhere)
if os.Getuid() == 0 { if runtime.GOOS != "windows" && os.Getuid() == 0 {
t.Skip("Cannot test write errors as root") 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 // Try to write to a read-only directory
err := Init("/dev/null/impossible/path/conf.yml", "test") err := Init(invalidPath, "test")
if err == nil { if err == nil {
t.Error("expected error when writing to invalid path, got 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 // TestInitExistingFile tests that Init overwrites existing files

147
internal/config/new.go Normal file
View File

@@ -0,0 +1,147 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
)
// New returns a new Config struct
func New(confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists in the current directory or any ancestor,
// append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
if local := findLocalCheatpath(cwd); local != "" {
path := cp.Path{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Path
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
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,
err,
)
}
expanded = evaled
}
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 still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// if a chroma style was not provided, set a default
if conf.Style == "" {
conf.Style = "bw"
}
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
// It returns the path to the first .cheat directory found, or an empty string
// if none exists. This mirrors the discovery pattern used by git for .git
// directories.
func findLocalCheatpath(dir string) string {
for {
candidate := filepath.Join(dir, ".cheat")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

View File

@@ -7,6 +7,16 @@ import (
) )
func TestNewTrimsWhitespace(t *testing.T) { 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 // Create a temporary config file with whitespace in editor and pager
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml") configPath := filepath.Join(tmpDir, "config.yml")
@@ -28,7 +38,7 @@ cheatpaths:
} }
// Load the config // Load the config
conf, err := New(map[string]interface{}{}, configPath, false) conf, err := New(configPath, false)
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
@@ -78,7 +88,7 @@ cheatpaths:
} }
// Load the config // Load the config
conf, err := New(map[string]interface{}{}, configPath, false) conf, err := New(configPath, false)
if err != nil { if err != nil {
// It's OK if this fails due to no editor being found // It's OK if this fails due to no editor being found
// The important thing is it doesn't panic // The important thing is it doesn't panic
@@ -113,7 +123,7 @@ cheatpaths:
} }
// Load the config // Load the config
conf, err := New(map[string]interface{}{}, configPath, false) conf, err := New(configPath, false)
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"path/filepath"
"runtime" "runtime"
"testing" "testing"
) )
@@ -44,29 +45,20 @@ func TestPager(t *testing.T) {
os.Setenv("PAGER", "") os.Setenv("PAGER", "")
pager := Pager() pager := Pager()
// Should find one of the fallback pagers or return empty string if pager == "" {
return // no pager found is acceptable
}
// Should find one of the known fallback pagers
validPagers := map[string]bool{ validPagers := map[string]bool{
"": true, // no pager found
"pager": true, "pager": true,
"less": true, "less": true,
"more": true, "more": true,
} }
// Check if it's a path to one of these base := filepath.Base(pager)
found := false if !validPagers[base] {
for p := range validPagers { t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
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)
} }
}) })

View File

@@ -1,7 +1,9 @@
package config package config
import ( import (
"path/filepath"
"reflect" "reflect"
"runtime"
"testing" "testing"
"github.com/davecgh/go-spew/spew" "github.com/davecgh/go-spew/spew"
@@ -10,6 +12,9 @@ import (
// TestValidatePathsNix asserts that the proper config paths are returned on // TestValidatePathsNix asserts that the proper config paths are returned on
// *nix platforms // *nix platforms
func TestValidatePathsNix(t *testing.T) { func TestValidatePathsNix(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filepath.Join uses backslashes on Windows")
}
// mock the user's home directory // mock the user's home directory
home := "/home/foo" home := "/home/foo"
@@ -57,6 +62,9 @@ func TestValidatePathsNix(t *testing.T) {
// TestValidatePathsNixNoXDG asserts that the proper config paths are returned // TestValidatePathsNixNoXDG asserts that the proper config paths are returned
// on *nix platforms when `XDG_CONFIG_HOME is not set // on *nix platforms when `XDG_CONFIG_HOME is not set
func TestValidatePathsNixNoXDG(t *testing.T) { func TestValidatePathsNixNoXDG(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filepath.Join uses backslashes on Windows")
}
// mock the user's home directory // mock the user's home directory
home := "/home/foo" home := "/home/foo"
@@ -106,8 +114,8 @@ func TestValidatePathsWindows(t *testing.T) {
// mock some envvars // mock some envvars
envvars := map[string]string{ envvars := map[string]string{
"APPDATA": "/apps", "APPDATA": filepath.Join("C:", "apps"),
"PROGRAMDATA": "/programs", "PROGRAMDATA": filepath.Join("C:", "programs"),
} }
// get the paths for the platform // get the paths for the platform
@@ -118,8 +126,8 @@ func TestValidatePathsWindows(t *testing.T) {
// specify the expected output // specify the expected output
want := []string{ want := []string{
"/apps/cheat/conf.yml", filepath.Join("C:", "apps", "cheat", "conf.yml"),
"/programs/cheat/conf.yml", filepath.Join("C:", "programs", "cheat", "conf.yml"),
} }
// assert that output matches expectations // assert that output matches expectations

View File

@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
conf := Config{ conf := Config{
Colorize: true, Colorize: true,
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
@@ -71,19 +71,28 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
} }
} }
// TestMissingInvalidFormatters asserts that configs which contain invalid // TestInvalidateInvalidFormatter asserts that configs which contain invalid
// formatters are invalidated // formatters are invalidated
func TestMissingInvalidFormatters(t *testing.T) { func TestInvalidateInvalidFormatter(t *testing.T) {
// mock a config // mock a config with a valid editor and cheatpaths but invalid formatter
conf := Config{ conf := Config{
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "html",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
},
} }
// assert that no errors are returned // assert that the config is invalidated due to the formatter
if err := conf.Validate(); err == nil { if err := conf.Validate(); err == nil {
t.Errorf("failed to invalidate config without formatter") t.Errorf("failed to invalidate config with invalid formatter")
} }
} }
@@ -96,14 +105,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
Tags: []string{}, Tags: []string{},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/bar", Path: "/bar",
ReadOnly: false, ReadOnly: false,
@@ -127,14 +136,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
Colorize: true, Colorize: true,
Editor: "vim", Editor: "vim",
Formatter: "terminal16m", Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{ Cheatpaths: []cheatpath.Path{
cheatpath.Cheatpath{ cheatpath.Path{
Name: "foo", Name: "foo",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,
Tags: []string{}, Tags: []string{},
}, },
cheatpath.Cheatpath{ cheatpath.Path{
Name: "bar", Name: "bar",
Path: "/foo", Path: "/foo",
ReadOnly: false, ReadOnly: false,

View File

@@ -1,45 +0,0 @@
// 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

@@ -2,17 +2,13 @@
// cheatsheet content to stdout, or alternatively the system pager. // cheatsheet content to stdout, or alternatively the system pager.
package display package display
import ( import "fmt"
"fmt"
"github.com/cheat/cheat/internal/config"
)
// Faint returns a faintly-colored string that's used to de-prioritize text // Faint returns a faintly-colored string that's used to de-prioritize text
// written to stdout // written to stdout
func Faint(str string, conf config.Config) string { func Faint(str string, colorize bool) string {
// make `str` faint only if colorization has been requested // make `str` faint only if colorization has been requested
if conf.Colorize { if colorize {
return fmt.Sprintf("\033[2m%s\033[0m", str) return fmt.Sprintf("\033[2m%s\033[0m", str)
} }

View File

@@ -1,26 +1,20 @@
package display package display
import ( import "testing"
"testing"
"github.com/cheat/cheat/internal/config"
)
// TestFaint asserts that Faint applies faint formatting // TestFaint asserts that Faint applies faint formatting
func TestFaint(t *testing.T) { func TestFaint(t *testing.T) {
// case: apply colorization // case: apply colorization
conf := config.Config{Colorize: true}
want := "\033[2mfoo\033[0m" want := "\033[2mfoo\033[0m"
got := Faint("foo", conf) got := Faint("foo", true)
if want != got { if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got) t.Errorf("failed to faint: want: %s, got: %s", want, got)
} }
// case: do not apply colorization // case: do not apply colorization
conf.Colorize = false
want = "foo" want = "foo"
got = Faint("foo", conf) got = Faint("foo", false)
if want != got { if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got) t.Errorf("failed to faint: want: %s, got: %s", want, got)
} }

View File

@@ -24,7 +24,7 @@ func Write(out string, conf config.Config) {
// writeToPager writes output through a pager command // writeToPager writes output through a pager command
func writeToPager(out string, conf config.Config) { func writeToPager(out string, conf config.Config) {
parts := strings.Split(conf.Pager, " ") parts := strings.Fields(conf.Pager)
pager := parts[0] pager := parts[0]
args := parts[1:] args := parts[1:]

View File

@@ -3,7 +3,6 @@
package installer package installer
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -12,20 +11,34 @@ import (
// Prompt prompts the user for a answer // Prompt prompts the user for a answer
func Prompt(prompt string, def bool) (bool, error) { func Prompt(prompt string, def bool) (bool, error) {
// initialize a line reader
reader := bufio.NewReader(os.Stdin)
// display the prompt // display the prompt
fmt.Printf("%s: ", prompt) fmt.Printf("%s: ", prompt)
// read the answer // read one byte at a time until newline to avoid buffering past the
ans, err := reader.ReadString('\n') // end of the current line, which would consume input intended for
if err != nil { // subsequent Prompt calls on the same stdin
return false, fmt.Errorf("failed to parse input: %v", err) 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 // normalize the answer
ans = strings.ToLower(strings.TrimSpace(ans)) ans := strings.ToLower(strings.TrimSpace(string(line)))
// return the appropriate response // return the appropriate response
switch ans { switch ans {

View File

@@ -2,7 +2,6 @@ package installer
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"os" "os"
"strings" "strings"
@@ -154,27 +153,7 @@ func TestPromptError(t *testing.T) {
if err == nil { if err == nil {
t.Error("expected error when reading from closed stdin, got nil") t.Error("expected error when reading from closed stdin, got nil")
} }
if !strings.Contains(err.Error(), "failed to parse input") { if !strings.Contains(err.Error(), "failed to prompt") {
t.Errorf("expected 'failed to parse input' error, got: %v", err) 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

@@ -3,8 +3,6 @@ package installer
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo" "github.com/cheat/cheat/internal/repo"
@@ -13,25 +11,11 @@ import (
// Run runs the installer // Run runs the installer
func Run(configs string, confpath string) error { func Run(configs string, confpath string) error {
// determine the appropriate paths for config data and (optional) community // expand template placeholders with platform-appropriate paths
// cheatsheets based on the user's platform configs = ExpandTemplate(configs, confpath)
confdir := filepath.Dir(confpath)
// create paths for community and personal cheatsheets // determine cheatsheet directory paths
community := filepath.Join(confdir, "cheatsheets", "community") community, personal, work := cheatsheetDirs(confpath)
personal := filepath.Join(confdir, "cheatsheets", "personal")
// set default cheatpaths
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -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)
}
// prompt the user to download the community cheatsheets // prompt the user to download the community cheatsheets
yes, err := Prompt( yes, err := Prompt(
@@ -44,15 +28,17 @@ func Run(configs string, confpath string) error {
// clone the community cheatsheets if so instructed // clone the community cheatsheets if so instructed
if yes { if yes {
// clone the community cheatsheets
fmt.Printf("Cloning community cheatsheets to %s.\n", community) fmt.Printf("Cloning community cheatsheets to %s.\n", community)
if err := repo.Clone(community); err != nil { if err := repo.Clone(community); err != nil {
return fmt.Errorf("failed to clone cheatsheets: %v", err) return fmt.Errorf("failed to clone cheatsheets: %v", err)
} }
} else {
configs = CommentCommunity(configs, confpath)
}
// also create a directory for personal cheatsheets // always create personal and work directories
fmt.Printf("Cloning personal cheatsheets to %s.\n", personal) for _, dir := range []string{personal, work} {
if err := os.MkdirAll(personal, os.ModePerm); err != nil { if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create directory: %v", err) return fmt.Errorf("failed to create directory: %v", err)
} }
} }

View File

@@ -1,10 +1,10 @@
package installer package installer
import ( import (
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
@@ -53,8 +53,8 @@ cheatpaths:
confpath: filepath.Join(tempDir, "conf1", "conf.yml"), confpath: filepath.Join(tempDir, "conf1", "conf.yml"),
userInput: "n\n", userInput: "n\n",
wantErr: false, wantErr: false,
checkFiles: []string{"conf1/conf.yml"}, checkFiles: []string{"conf1/conf.yml", "conf1/cheatsheets/personal", "conf1/cheatsheets/work"},
dontWantFiles: []string{"conf1/cheatsheets/community", "conf1/cheatsheets/personal"}, dontWantFiles: []string{"conf1/cheatsheets/community"},
}, },
{ {
name: "user accepts but clone fails", name: "user accepts but clone fails",
@@ -69,15 +69,33 @@ cheatpaths:
wantInErr: "failed to clone cheatsheets", wantInErr: "failed to clone cheatsheets",
}, },
{ {
name: "invalid config path", name: "invalid config path",
configs: "test", configs: "test",
confpath: "/nonexistent/path/conf.yml", // /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", userInput: "n\n",
wantErr: true, wantErr: true,
wantInErr: "failed to create config file", 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Create stdin pipe // Create stdin pipe
@@ -158,10 +176,18 @@ func TestRunStringReplacements(t *testing.T) {
editor: EDITOR_PATH editor: EDITOR_PATH
pager: PAGER_PATH pager: PAGER_PATH
cheatpaths: cheatpaths:
- name: community
path: COMMUNITY_PATH
- name: personal - name: personal
path: PERSONAL_PATH 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 // Create temp directory
@@ -175,7 +201,6 @@ cheatpaths:
confdir := filepath.Dir(confpath) confdir := filepath.Dir(confpath)
// Expected paths // Expected paths
expectedCommunity := filepath.Join(confdir, "cheatsheets", "community")
expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal") expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal")
// Save original stdin/stdout // Save original stdin/stdout
@@ -219,16 +244,22 @@ cheatpaths:
if strings.Contains(contentStr, "PERSONAL_PATH") { if strings.Contains(contentStr, "PERSONAL_PATH") {
t.Error("PERSONAL_PATH was not replaced") t.Error("PERSONAL_PATH was not replaced")
} }
if strings.Contains(contentStr, "EDITOR_PATH") && !strings.Contains(contentStr, fmt.Sprintf("editor: %s", "")) { if strings.Contains(contentStr, "EDITOR_PATH") {
t.Error("EDITOR_PATH was not replaced") t.Error("EDITOR_PATH was not replaced")
} }
if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) { if strings.Contains(contentStr, "PAGER_PATH") {
t.Error("PAGER_PATH was not replaced") t.Error("PAGER_PATH was not replaced")
} }
if strings.Contains(contentStr, "WORK_PATH") {
t.Error("WORK_PATH was not replaced")
}
// Verify correct paths were used // Verify community path is commented out (user declined)
if !strings.Contains(contentStr, expectedCommunity) { if strings.Contains(contentStr, " - name: community") {
t.Errorf("expected community path %q in config", expectedCommunity) 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) { if !strings.Contains(contentStr, expectedPersonal) {
t.Errorf("expected personal path %q in config", expectedPersonal) t.Errorf("expected personal path %q in config", expectedPersonal)

View File

@@ -0,0 +1,58 @@
package installer
import (
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
)
// cheatsheetDirs returns the community, personal, and work cheatsheet
// directory paths derived from a config file path.
func cheatsheetDirs(confpath string) (community, personal, work string) {
confdir := filepath.Dir(confpath)
community = filepath.Join(confdir, "cheatsheets", "community")
personal = filepath.Join(confdir, "cheatsheets", "personal")
work = filepath.Join(confdir, "cheatsheets", "work")
return
}
// ExpandTemplate replaces placeholder tokens in the config template with
// platform-appropriate paths derived from confpath.
func ExpandTemplate(configs string, confpath string) string {
community, personal, work := cheatsheetDirs(confpath)
// substitute paths
configs = strings.ReplaceAll(configs, "COMMUNITY_PATH", community)
configs = strings.ReplaceAll(configs, "PERSONAL_PATH", personal)
configs = strings.ReplaceAll(configs, "WORK_PATH", work)
// locate and set a default pager
configs = strings.ReplaceAll(configs, "PAGER_PATH", config.Pager())
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.ReplaceAll(configs, "EDITOR_PATH", editor)
}
return configs
}
// CommentCommunity comments out the community cheatpath block in the config
// template. This is used when the community cheatsheets directory won't exist
// (either because the user declined to download them, or because the config
// is being output as an example).
func CommentCommunity(configs string, confpath string) string {
community, _, _ := cheatsheetDirs(confpath)
return strings.ReplaceAll(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
)
}

View File

@@ -1,30 +0,0 @@
// Package mock implements mock functions used in unit-tests.
package mock
import (
"fmt"
"path"
"path/filepath"
"runtime"
)
// Path returns the absolute path to the specified mock file.
func Path(filename string) string {
// determine the path of this file during runtime
_, thisfile, _, _ := runtime.Caller(0)
// compute the mock path
file, err := filepath.Abs(
path.Join(
filepath.Dir(thisfile),
"../../mocks",
filename,
),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file
}

View File

@@ -3,6 +3,7 @@ package repo
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
) )
@@ -12,6 +13,9 @@ func TestClone(t *testing.T) {
// that don't require actual cloning // that don't require actual cloning
t.Run("clone to read-only directory", func(t *testing.T) { 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 { if os.Getuid() == 0 {
t.Skip("Cannot test read-only directory as root") t.Skip("Cannot test read-only directory as root")
} }

View File

@@ -6,6 +6,11 @@ import (
"strings" "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 // GitDir returns `true` if we are iterating over a directory contained within
// a repositories `.git` directory. // a repositories `.git` directory.
func GitDir(path string) (bool, error) { func GitDir(path string) (bool, error) {
@@ -50,9 +55,20 @@ func GitDir(path string) (bool, error) {
See: https://github.com/cheat/cheat/issues/699 See: https://github.com/cheat/cheat/issues/699
Accounting for all of the above (hopefully?), the current solution is Accounting for all of the above, the next solution was to search not
not to search for `.git`, but `.git/` (including the directory for `.git`, but `.git/` (including the directory separator), and then
separator), and then only ceasing to walk the directory on a match. 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: 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 3. A cheatpath is a repository, and contains a `.git*` file
4. A cheatpath is a submodule 4. A cheatpath is a submodule
5. A cheatpath is a hidden directory 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 Care must be taken to support the above on both Unix and Windows
systems, which have different directory separators and line-endings. 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 NB: `filepath.Walk` always passes absolute paths to the walk function,
do two things to stop writing bugs here: 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 A reasonable smoke-test for ensuring that skipping is being applied
2. Discard string-matching solutions entirely, and use `go-git` instead
NB: A reasonable smoke-test for ensuring that skipping is being applied
correctly is to run the following command: correctly is to run the following command:
make && strace ./dist/cheat -l | wc -l 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. of syscalls should be significantly lower with the skip check enabled.
*/ */
// determine if the literal string `.git` appears within `path` // determine if `.git` appears as a complete path component
pos := strings.Index(path, fmt.Sprintf(".git%s", string(os.PathSeparator))) pos := strings.Index(path, gitSep)
// if it does not, we know for certain that we are not within a `.git` // if it does not, we know for certain that we are not within a `.git`
// directory. // directory.

View File

@@ -1,137 +1,191 @@
package repo package repo
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
func TestGitDir(t *testing.T) { // setupGitDirTestTree creates a temporary directory structure that exercises
// Create a temporary directory for testing // every case documented in GitDir's comment block. The caller must defer
tempDir, err := os.MkdirTemp("", "cheat-test-*") // os.RemoveAll on the returned root.
if err != nil { //
t.Fatalf("failed to create temp dir: %v", err) // Layout:
} //
defer os.RemoveAll(tempDir) // 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()
// Create test directory structure root := t.TempDir()
testDirs := []string{
filepath.Join(tempDir, ".git"), dirs := []string{
filepath.Join(tempDir, ".git", "objects"), // case 1: not a repository
filepath.Join(tempDir, ".git", "refs"), filepath.Join(root, "plain"),
filepath.Join(tempDir, "regular"),
filepath.Join(tempDir, "regular", ".git"), // case 2: a repository (.git directory with contents)
filepath.Join(tempDir, "submodule"), 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 testDirs { for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("failed to create dir %s: %v", dir, err) t.Fatalf("failed to create dir %s: %v", dir, err)
} }
} }
// Create test files files := map[string]string{
testFiles := map[string]string{ // sheets
filepath.Join(tempDir, ".gitignore"): "*.tmp\n", filepath.Join(root, "plain", "sheet"): "plain sheet",
filepath.Join(tempDir, ".gitattributes"): "* text=auto\n", filepath.Join(root, "repo", "sheet"): "repo sheet",
filepath.Join(tempDir, "submodule", ".git"): "gitdir: ../.git/modules/submodule\n", filepath.Join(root, "submodule", "sheet"): "submod sheet",
filepath.Join(tempDir, "regular", "sheet.txt"): "content\n", 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 file, content := range testFiles { for path, content := range files {
if err := os.WriteFile(file, []byte(content), 0644); err != nil { if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create file %s: %v", file, err) t.Fatalf("failed to write %s: %v", path, err)
} }
} }
tests := []struct { return root
name string
path string
want bool
wantErr bool
}{
{
name: "not in git directory",
path: filepath.Join(tempDir, "regular", "sheet.txt"),
want: false,
},
{
name: "in .git directory",
path: filepath.Join(tempDir, ".git", "objects", "file"),
want: true,
},
{
name: "in .git/refs directory",
path: filepath.Join(tempDir, ".git", "refs", "heads", "main"),
want: true,
},
{
name: ".gitignore file",
path: filepath.Join(tempDir, ".gitignore"),
want: false,
},
{
name: ".gitattributes file",
path: filepath.Join(tempDir, ".gitattributes"),
want: false,
},
{
name: "submodule with .git file",
path: filepath.Join(tempDir, "submodule", "sheet.txt"),
want: false,
},
{
name: "path with .git in middle",
path: filepath.Join(tempDir, "regular", ".git", "sheet.txt"),
want: true,
},
{
name: "nonexistent path without .git",
path: filepath.Join(tempDir, "nonexistent", "file"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GitDir(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("GitDir() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GitDir() = %v, want %v", got, tt.want)
}
})
}
} }
func TestGitDirEdgeCases(t *testing.T) { func TestGitDir(t *testing.T) {
// Test with paths that have .git but not as a directory separator root := setupGitDirTestTree(t)
tests := []struct { tests := []struct {
name string name string
path string path string
want bool want bool
}{ }{
// Case 1: not a repository — no .git anywhere in path
{ {
name: "file ending with .git", name: "plain directory, no repo",
path: "/tmp/myfile.git", 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, want: false,
}, },
{ {
name: "directory ending with .git", name: ".gitattributes file",
path: "/tmp/myrepo.git", 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, want: false,
}, },
{ {
name: ".github directory", name: "sheet under .git-suffixed dir, nested deeper",
path: "/tmp/.github/workflows", path: filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
want: false, want: false,
}, },
// .github directory — must not be confused with .git
{ {
name: "legitimate.git-repo name", name: "file inside .github directory",
path: "/tmp/legitimate.git-repo/file", 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, want: false,
}, },
} }
@@ -140,8 +194,7 @@ func TestGitDirEdgeCases(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := GitDir(tt.path) got, err := GitDir(tt.path)
if err != nil { if err != nil {
// It's ok if the path doesn't exist for these edge case tests t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
return
} }
if got != tt.want { if got != tt.want {
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want) t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
@@ -150,28 +203,153 @@ func TestGitDirEdgeCases(t *testing.T) {
} }
} }
func TestGitDirPathSeparator(t *testing.T) { // TestGitDirWithNestedGitDir tests a repo inside a .git-suffixed parent
// Test that the function correctly uses os.PathSeparator // directory. This is the nastiest combination: a real .git directory that
// This is important for cross-platform compatibility // appears *after* a .git suffix in the path.
func TestGitDirWithNestedGitDir(t *testing.T) {
root := t.TempDir()
// Create a path with the wrong separator for the current OS // Create: root/cheats.git/repo/.git/HEAD
var wrongSep string // root/cheats.git/repo/sheet
if os.PathSeparator == '/' { gitDir := filepath.Join(root, "cheats.git", "repo", ".git")
wrongSep = `\` if err := os.MkdirAll(gitDir, 0755); err != nil {
} else { t.Fatal(err)
wrongSep = `/` }
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)
} }
// Path with wrong separator should not be detected as git dir tests := []struct {
path := fmt.Sprintf("some%spath%s.git%sfile", wrongSep, wrongSep, wrongSep) name string
isGit, err := GitDir(path) path string
want bool
if err != nil { }{
// Path doesn't exist, which is fine {
return 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,
},
} }
if isGit { for _, tt := range tests {
t.Errorf("GitDir() incorrectly detected git dir with wrong path separator") 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

@@ -1 +0,0 @@
package repo

View File

@@ -1,6 +1,7 @@
package sheet package sheet
import ( import (
"strings"
"testing" "testing"
"github.com/cheat/cheat/internal/config" "github.com/cheat/cheat/internal/config"
@@ -16,45 +17,26 @@ func TestColorize(t *testing.T) {
} }
// mock a sheet // mock a sheet
original := "echo 'foo'"
s := Sheet{ s := Sheet{
Text: "echo 'foo'", Text: original,
} }
// colorize the sheet text // colorize the sheet text
s.Colorize(conf) s.Colorize(conf)
// initialize expectations // assert that the text was modified (colorization applied)
want := "echo" if s.Text == original {
want += " 'foo'" t.Error("Colorize did not modify sheet text")
}
// assert // assert that ANSI escape codes are present
if s.Text != want { if !strings.Contains(s.Text, "\x1b[") && !strings.Contains(s.Text, "[0m") {
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text) t.Errorf("colorized text does not contain ANSI escape codes: %q", s.Text)
}
// assert that the original content is still present within the colorized output
if !strings.Contains(s.Text, "echo") || !strings.Contains(s.Text, "foo") {
t.Errorf("colorized text lost original content: %q", 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

@@ -3,21 +3,19 @@ package sheet
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
) )
// TestCopyErrors tests error cases for the Copy method // TestCopyErrors tests error cases for the Copy method
func TestCopyErrors(t *testing.T) { func TestCopyErrors(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
setup func() (*Sheet, string, func()) setup func() (*Sheet, string, func())
wantErr bool
errMsg string
}{ }{
{ {
name: "source file does not exist", name: "source file does not exist",
setup: func() (*Sheet, string, func()) { setup: func() (*Sheet, string, func()) {
// Create a sheet with non-existent path
sheet := &Sheet{ sheet := &Sheet{
Title: "test", Title: "test",
Path: "/non/existent/file.txt", Path: "/non/existent/file.txt",
@@ -29,13 +27,10 @@ func TestCopyErrors(t *testing.T) {
} }
return sheet, dest, cleanup return sheet, dest, cleanup
}, },
wantErr: true,
errMsg: "failed to open cheatsheet",
}, },
{ {
name: "destination directory creation fails", name: "destination directory creation fails",
setup: func() (*Sheet, string, func()) { setup: func() (*Sheet, string, func()) {
// Create a source file
src, err := os.CreateTemp("", "copy-test-src-*") src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil { if err != nil {
t.Fatalf("failed to create temp file: %v", err) t.Fatalf("failed to create temp file: %v", err)
@@ -49,13 +44,11 @@ func TestCopyErrors(t *testing.T) {
CheatPath: "test", CheatPath: "test",
} }
// Create a file where we want a directory
blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file") blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file")
if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil { if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil {
t.Fatalf("failed to create blocker file: %v", err) 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") dest := filepath.Join(blockerFile, "subdir", "dest.txt")
cleanup := func() { cleanup := func() {
@@ -64,13 +57,10 @@ func TestCopyErrors(t *testing.T) {
} }
return sheet, dest, cleanup return sheet, dest, cleanup
}, },
wantErr: true,
errMsg: "failed to create directory",
}, },
{ {
name: "destination file creation fails", name: "destination file creation fails",
setup: func() (*Sheet, string, func()) { setup: func() (*Sheet, string, func()) {
// Create a source file
src, err := os.CreateTemp("", "copy-test-src-*") src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil { if err != nil {
t.Fatalf("failed to create temp file: %v", err) t.Fatalf("failed to create temp file: %v", err)
@@ -84,7 +74,6 @@ func TestCopyErrors(t *testing.T) {
CheatPath: "test", CheatPath: "test",
} }
// Create a directory where we want the file
destDir := filepath.Join(os.TempDir(), "copy-test-dir") destDir := filepath.Join(os.TempDir(), "copy-test-dir")
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) { if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
t.Fatalf("failed to create dest dir: %v", err) t.Fatalf("failed to create dest dir: %v", err)
@@ -96,8 +85,6 @@ func TestCopyErrors(t *testing.T) {
} }
return sheet, destDir, cleanup return sheet, destDir, cleanup
}, },
wantErr: true,
errMsg: "failed to create outfile",
}, },
} }
@@ -107,39 +94,27 @@ func TestCopyErrors(t *testing.T) {
defer cleanup() defer cleanup()
err := sheet.Copy(dest) err := sheet.Copy(dest)
if (err != nil) != tt.wantErr { if err == nil {
t.Errorf("Copy() error = %v, wantErr %v", err, tt.wantErr) t.Error("Copy() expected error, got nil")
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 // TestCopyUnreadableSource verifies that Copy returns an error when the source
func TestCopyIOError(t *testing.T) { // file cannot be opened (e.g., permission denied).
// This is difficult to test without mocking io.Copy func TestCopyUnreadableSource(t *testing.T) {
// The error case would occur if the source file is modified if runtime.GOOS == "windows" {
// or removed after opening but before copying t.Skip("chmod does not restrict reads on Windows")
t.Skip("Skipping io.Copy error test - requires file system race condition") }
}
// TestCopyCleanupOnError verifies that partially written files are cleaned up on error src, err := os.CreateTemp("", "copy-test-unreadable-*")
func TestCopyCleanupOnError(t *testing.T) {
// Create a source file that we'll make unreadable after opening
src, err := os.CreateTemp("", "copy-test-cleanup-*")
if err != nil { if err != nil {
t.Fatalf("failed to create temp file: %v", err) t.Fatalf("failed to create temp file: %v", err)
} }
defer os.Remove(src.Name()) defer os.Remove(src.Name())
// Write some content if _, err := src.WriteString("test content"); err != nil {
content := "test content for cleanup"
if _, err := src.WriteString(content); err != nil {
t.Fatalf("failed to write content: %v", err) t.Fatalf("failed to write content: %v", err)
} }
src.Close() src.Close()
@@ -150,38 +125,21 @@ func TestCopyCleanupOnError(t *testing.T) {
CheatPath: "test", CheatPath: "test",
} }
// Destination path dest := filepath.Join(os.TempDir(), "copy-unreadable-test.txt")
dest := filepath.Join(os.TempDir(), "copy-cleanup-test.txt") defer os.Remove(dest)
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 { if err := os.Chmod(src.Name(), 0000); err != nil {
t.Skip("Cannot change file permissions on this platform") t.Skip("Cannot change file permissions on this platform")
} }
defer os.Chmod(src.Name(), 0644) // Restore permissions for cleanup defer os.Chmod(src.Name(), 0644)
// Attempt to copy - this should fail during io.Copy
err = sheet.Copy(dest) err = sheet.Copy(dest)
if err == nil { if err == nil {
t.Error("Expected Copy to fail with permission error") t.Error("expected Copy to fail with permission error")
} }
// Verify the destination file was cleaned up // Destination should not exist since the error occurs before it is created
if _, err := os.Stat(dest); !os.IsNotExist(err) { if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Error("Destination file should have been removed after copy failure") t.Error("destination file should not exist after open 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
}

View File

@@ -1,65 +0,0 @@
// 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,7 +2,6 @@ package sheet
import ( import (
"fmt" "fmt"
"runtime"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -11,9 +10,9 @@ import (
// Parse parses cheatsheet frontmatter // Parse parses cheatsheet frontmatter
func parse(markdown string) (frontmatter, string, error) { 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" linebreak := "\n"
if runtime.GOOS == "windows" { if strings.Contains(markdown, "\r\n") {
linebreak = "\r\n" linebreak = "\r\n"
} }

View File

@@ -1,17 +1,11 @@
package sheet package sheet
import ( import (
"runtime"
"testing" "testing"
) )
// TestParseWindowsLineEndings tests parsing with Windows line endings // TestParseWindowsLineEndings tests parsing with Windows line endings
func TestParseWindowsLineEndings(t *testing.T) { func TestParseWindowsLineEndings(t *testing.T) {
// Only test Windows line endings on Windows
if runtime.GOOS != "windows" {
t.Skip("Skipping Windows line ending test on non-Windows platform")
}
// stub our cheatsheet content with Windows line endings // stub our cheatsheet content with Windows line endings
markdown := "---\r\nsyntax: go\r\ntags: [ test ]\r\n---\r\nTo foo the bar: baz" markdown := "---\r\nsyntax: go\r\ntags: [ test ]\r\n---\r\nTo foo the bar: baz"
@@ -33,22 +27,3 @@ func TestParseWindowsLineEndings(t *testing.T) {
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax) 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

@@ -38,7 +38,7 @@ To foo the bar: baz`
t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0]) t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0])
} }
if len(fm.Tags) != 1 { if len(fm.Tags) != 1 {
t.Errorf("failed to parse tags: want: len 0, got: len %d", len(fm.Tags)) t.Errorf("failed to parse tags: want: len 1, got: len %d", len(fm.Tags))
} }
} }

View File

@@ -122,69 +122,3 @@ func FuzzSearchRegex(f *testing.F) {
} }
}) })
} }
// 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

@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestSheetSuccess asserts that sheets initialize properly // TestSheetSuccess asserts that sheets initialize properly
@@ -14,7 +14,7 @@ func TestSheetSuccess(t *testing.T) {
sheet, err := New( sheet, err := New(
"foo", "foo",
"community", "community",
mock.Path("sheet/foo"), mocks.Path("sheet/foo"),
[]string{"alpha", "bravo"}, []string{"alpha", "bravo"},
false, false,
) )
@@ -27,10 +27,10 @@ func TestSheetSuccess(t *testing.T) {
t.Errorf("failed to init title: want: foo, got: %s", sheet.Title) t.Errorf("failed to init title: want: foo, got: %s", sheet.Title)
} }
if sheet.Path != mock.Path("sheet/foo") { if sheet.Path != mocks.Path("sheet/foo") {
t.Errorf( t.Errorf(
"failed to init path: want: %s, got: %s", "failed to init path: want: %s, got: %s",
mock.Path("sheet/foo"), mocks.Path("sheet/foo"),
sheet.Path, sheet.Path,
) )
} }
@@ -63,7 +63,7 @@ func TestSheetFailure(t *testing.T) {
_, err := New( _, err := New(
"foo", "foo",
"community", "community",
mock.Path("/does-not-exist"), mocks.Path("/does-not-exist"),
[]string{"alpha", "bravo"}, []string{"alpha", "bravo"},
false, false,
) )
@@ -80,7 +80,7 @@ func TestSheetFrontMatterFailure(t *testing.T) {
_, err := New( _, err := New(
"foo", "foo",
"community", "community",
mock.Path("sheet/bad-fm"), mocks.Path("sheet/bad-fm"),
[]string{"alpha", "bravo"}, []string{"alpha", "bravo"},
false, false,
) )

View File

@@ -1,15 +1,8 @@
package sheet package sheet
import "slices"
// Tagged returns true if a sheet was tagged with `needle` // Tagged returns true if a sheet was tagged with `needle`
func (s *Sheet) Tagged(needle string) bool { func (s *Sheet) Tagged(needle string) bool {
return slices.Contains(s.Tags, needle)
// if any of the tags match `needle`, return `true`
for _, tag := range s.Tags {
if tag == needle {
return true
}
}
// otherwise, return `false`
return false
} }

View File

@@ -0,0 +1,40 @@
package sheet
import (
"fmt"
"path/filepath"
"strings"
)
// Validate ensures that a cheatsheet name does not contain
// directory traversal sequences or other potentially dangerous patterns.
func Validate(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)
// 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

@@ -1,4 +1,4 @@
package cheatpath package sheet
import ( import (
"strings" "strings"
@@ -6,9 +6,9 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// FuzzValidateSheetName tests the ValidateSheetName function with fuzzing // FuzzValidate tests the Validate function with fuzzing
// to ensure it properly prevents path traversal and other security issues // to ensure it properly prevents path traversal and other security issues
func FuzzValidateSheetName(f *testing.F) { func FuzzValidate(f *testing.F) {
// Add seed corpus with various valid and malicious inputs // Add seed corpus with various valid and malicious inputs
// Valid names // Valid names
f.Add("docker") f.Add("docker")
@@ -84,11 +84,11 @@ func FuzzValidateSheetName(f *testing.F) {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with input %q: %v", input, r) t.Errorf("Validate panicked with input %q: %v", input, r)
} }
}() }()
err := ValidateSheetName(input) err := Validate(input)
// Security invariants that must always hold // Security invariants that must always hold
if err == nil { if err == nil {
@@ -129,8 +129,8 @@ func FuzzValidateSheetName(f *testing.F) {
}) })
} }
// FuzzValidateSheetNamePathTraversal specifically targets path traversal bypasses // FuzzValidatePathTraversal specifically targets path traversal bypasses
func FuzzValidateSheetNamePathTraversal(f *testing.F) { func FuzzValidatePathTraversal(f *testing.F) {
// Seed corpus focusing on path traversal variations // Seed corpus focusing on path traversal variations
f.Add("..", "/", "") f.Add("..", "/", "")
f.Add("", "..", "/") f.Add("", "..", "/")
@@ -153,11 +153,11 @@ func FuzzValidateSheetNamePathTraversal(f *testing.F) {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with constructed input %q: %v", input, r) t.Errorf("Validate panicked with constructed input %q: %v", input, r)
} }
}() }()
err := ValidateSheetName(input) err := Validate(input)
// If the input contains literal "..", it must be rejected // If the input contains literal "..", it must be rejected
if strings.Contains(input, "..") && err == nil { if strings.Contains(input, "..") && err == nil {

View File

@@ -1,11 +1,12 @@
package cheatpath package sheet
import ( import (
"runtime"
"strings" "strings"
"testing" "testing"
) )
func TestValidateSheetName(t *testing.T) { func TestValidate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
@@ -53,9 +54,15 @@ func TestValidateSheetName(t *testing.T) {
errMsg: "'..'", errMsg: "'..'",
}, },
{ {
name: "absolute path", name: "absolute path unix",
input: "/etc/passwd", input: "/etc/passwd",
wantErr: true, 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", errMsg: "absolute",
}, },
{ {
@@ -91,14 +98,14 @@ func TestValidateSheetName(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := ValidateSheetName(tt.input) err := Validate(tt.input)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("ValidateName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) t.Errorf("Validate(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return return
} }
if err != nil && tt.errMsg != "" { if err != nil && tt.errMsg != "" {
if !strings.Contains(err.Error(), tt.errMsg) { if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateName(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg) t.Errorf("Validate(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg)
} }
} }
}) })

View File

@@ -1,65 +0,0 @@
// 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

@@ -18,28 +18,26 @@ func TestFilterSingleTag(t *testing.T) {
map[string]sheet.Sheet{ map[string]sheet.Sheet{
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}}, "foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}}, "bar": sheet.Sheet{Title: "bar", Tags: []string{"charlie"}},
}, },
map[string]sheet.Sheet{ map[string]sheet.Sheet{
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}}, "baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}}, "bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
}, },
} }
// filter the cheatsheets // filter the cheatsheets
filtered := Filter(cheatpaths, []string{"bravo"}) filtered := Filter(cheatpaths, []string{"alpha"})
// assert that the expect results were returned // assert that the expect results were returned
want := []map[string]sheet.Sheet{ want := []map[string]sheet.Sheet{
map[string]sheet.Sheet{ map[string]sheet.Sheet{
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}}, "foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
}, },
map[string]sheet.Sheet{ map[string]sheet.Sheet{
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}}, "baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
}, },
} }

View File

@@ -8,12 +8,11 @@ import (
"strings" "strings"
cp "github.com/cheat/cheat/internal/cheatpath" cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/repo"
"github.com/cheat/cheat/internal/sheet" "github.com/cheat/cheat/internal/sheet"
) )
// Load produces a map of cheatsheet titles to filesystem paths // Load produces a map of cheatsheet titles to filesystem paths
func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) { func Load(cheatpaths []cp.Path) ([]map[string]sheet.Sheet, error) {
// create a slice of maps of sheets. This structure will store all sheets // create a slice of maps of sheets. This structure will store all sheets
// that are associated with each cheatpath. // that are associated with each cheatpath.
@@ -27,10 +26,10 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
// recursively iterate over the cheatpath, and load each cheatsheet // recursively iterate over the cheatpath, and load each cheatsheet
// encountered along the way // encountered along the way
err := filepath.Walk( err := filepath.WalkDir(
cheatpath.Path, func( cheatpath.Path, func(
path string, path string,
info os.FileInfo, d fs.DirEntry,
err error) error { err error) error {
// fail if an error occurred while walking the directory // fail if an error occurred while walking the directory
@@ -38,8 +37,12 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
return fmt.Errorf("failed to walk path: %v", err) return fmt.Errorf("failed to walk path: %v", err)
} }
// don't register directories as cheatsheets if d.IsDir() {
if info.IsDir() { // skip .git directories to avoid hundreds/thousands of
// needless syscalls (see repo.GitDir for full history)
if filepath.Base(path) == ".git" {
return fs.SkipDir
}
return nil return nil
} }
@@ -63,17 +66,6 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
string(os.PathSeparator), string(os.PathSeparator),
) )
// Don't walk the `.git` directory. Doing so creates
// hundreds/thousands of needless syscalls and could
// potentially harm performance on machines with slow disks.
skip, err := repo.GitDir(path)
if err != nil {
return fmt.Errorf("failed to identify .git directory: %v", err)
}
if skip {
return fs.SkipDir
}
// parse the cheatsheet file into a `sheet` struct // parse the cheatsheet file into a `sheet` struct
s, err := sheet.New( s, err := sheet.New(
title, title,

View File

@@ -5,22 +5,22 @@ import (
"testing" "testing"
"github.com/cheat/cheat/internal/cheatpath" "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/mock" "github.com/cheat/cheat/mocks"
) )
// TestLoad asserts that sheets on valid cheatpaths can be loaded successfully // TestLoad asserts that sheets on valid cheatpaths can be loaded successfully
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
// mock cheatpaths // mock cheatpaths
cheatpaths := []cheatpath.Cheatpath{ cheatpaths := []cheatpath.Path{
{ {
Name: "community", Name: "community",
Path: path.Join(mock.Path("cheatsheets"), "community"), Path: path.Join(mocks.Path("cheatsheets"), "community"),
ReadOnly: true, ReadOnly: true,
}, },
{ {
Name: "personal", Name: "personal",
Path: path.Join(mock.Path("cheatsheets"), "personal"), Path: path.Join(mocks.Path("cheatsheets"), "personal"),
ReadOnly: false, ReadOnly: false,
}, },
} }
@@ -54,7 +54,7 @@ func TestLoad(t *testing.T) {
func TestLoadBadPath(t *testing.T) { func TestLoadBadPath(t *testing.T) {
// mock a bad cheatpath // mock a bad cheatpath
cheatpaths := []cheatpath.Cheatpath{ cheatpaths := []cheatpath.Path{
{ {
Name: "badpath", Name: "badpath",
Path: "/cheat/test/path/does/not/exist", Path: "/cheat/test/path/does/not/exist",

View File

@@ -32,9 +32,7 @@ func Tags(cheatpaths []map[string]sheet.Sheet) []string {
} }
// sort the slice // sort the slice
sort.Slice(sorted, func(i, j int) bool { sort.Strings(sorted)
return sorted[i] < sorted[j]
})
return sorted return sorted
} }

View File

@@ -127,64 +127,3 @@ func FuzzTags(f *testing.F) {
}() }()
}) })
} }
// 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)
}
}()
})
}

23
mocks/path.go Normal file
View File

@@ -0,0 +1,23 @@
// Package mocks provides test fixture data and helpers for unit tests.
package mocks
import (
"fmt"
"path/filepath"
"runtime"
)
// Path returns the absolute path to the specified mock file within
// the mocks/ directory.
func Path(filename string) string {
_, thisfile, _, _ := runtime.Caller(0)
file, err := filepath.Abs(
filepath.Join(filepath.Dir(thisfile), filename),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file
}

View File

@@ -16,12 +16,12 @@ DURATION="${1:-15s}"
# Define fuzz tests: "TestName:Package:Description" # Define fuzz tests: "TestName:Package:Description"
TESTS=( TESTS=(
"FuzzParse:./internal/sheet:YAML frontmatter parsing" "FuzzParse:./internal/sheet:YAML frontmatter parsing"
"FuzzValidateSheetName:./internal/cheatpath:sheet name validation (path traversal protection)" "FuzzValidate:./internal/sheet:sheet name validation (path traversal protection)"
"FuzzSearchRegex:./internal/sheet:regex search operations" "FuzzSearchRegex:./internal/sheet:regex search operations"
"FuzzSearchCatastrophicBacktracking:./internal/sheet:catastrophic backtracking"
"FuzzTagged:./internal/sheet:tag matching with malicious input" "FuzzTagged:./internal/sheet:tag matching with malicious input"
"FuzzFilter:./internal/sheets:tag filtering operations" "FuzzFilter:./internal/sheets:tag filtering operations"
"FuzzTags:./internal/sheets:tag aggregation and sorting" "FuzzTags:./internal/sheets:tag aggregation and sorting"
"FuzzFindLocalCheatpath:./internal/config:recursive .cheat directory discovery"
) )
echo "Running fuzz tests ($DURATION each)..." echo "Running fuzz tests ($DURATION each)..."

View File

@@ -0,0 +1,129 @@
package integration
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestBriefFlagIntegration exercises the -b/--brief flag end-to-end.
func TestBriefFlagIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env vars")
}
// Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}
// Set up a temp environment with some cheatsheets.
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
os.WriteFile(
filepath.Join(sheetsDir, "tar"),
[]byte("---\nsyntax: bash\ntags: [ compression ]\n---\ntar xf archive.tar\n"),
0644,
)
os.WriteFile(
filepath.Join(sheetsDir, "curl"),
[]byte("---\nsyntax: bash\ntags: [ networking, http ]\n---\ncurl https://example.com\n"),
0644,
)
confPath := filepath.Join(root, "conf.yml")
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: test\n path: %s\n readonly: true\n", sheetsDir)
os.WriteFile(confPath, []byte(conf), 0644)
env := []string{
"CHEAT_CONFIG_PATH=" + confPath,
"HOME=" + root,
"PATH=" + os.Getenv("PATH"),
"EDITOR=vi",
}
run := func(t *testing.T, args ...string) string {
t.Helper()
cmd := exec.Command(binPath, args...)
cmd.Dir = root
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat %v failed: %v\nOutput: %s", args, err, output)
}
return string(output)
}
t.Run("brief output omits file path column", func(t *testing.T) {
output := run(t, "-b")
lines := strings.Split(strings.TrimSpace(output), "\n")
// Header should have title and tags but not file
if !strings.Contains(lines[0], "title:") {
t.Errorf("expected title: in header, got: %s", lines[0])
}
if !strings.Contains(lines[0], "tags:") {
t.Errorf("expected tags: in header, got: %s", lines[0])
}
if strings.Contains(lines[0], "file:") {
t.Errorf("brief output should not contain file: column, got: %s", lines[0])
}
// Data lines should not contain the sheets directory path
for _, line := range lines[1:] {
if strings.Contains(line, sheetsDir) {
t.Errorf("brief output should not contain file paths, got: %s", line)
}
}
})
t.Run("list output still includes file path column", func(t *testing.T) {
output := run(t, "-l")
lines := strings.Split(strings.TrimSpace(output), "\n")
if !strings.Contains(lines[0], "file:") {
t.Errorf("list output should contain file: column, got: %s", lines[0])
}
})
t.Run("brief with filter works", func(t *testing.T) {
output := run(t, "-b", "tar")
if !strings.Contains(output, "tar") {
t.Errorf("expected tar in output, got: %s", output)
}
if strings.Contains(output, "curl") {
t.Errorf("filter should exclude curl, got: %s", output)
}
})
t.Run("combined -lb works identically to -b", func(t *testing.T) {
briefOnly := run(t, "-b", "tar")
combined := run(t, "-lb", "tar")
if briefOnly != combined {
t.Errorf("-b and -lb should produce identical output\n-b:\n%s\n-lb:\n%s", briefOnly, combined)
}
})
t.Run("brief with tag filter works", func(t *testing.T) {
output := run(t, "-b", "-t", "networking")
if !strings.Contains(output, "curl") {
t.Errorf("expected curl in tag-filtered output, got: %s", output)
}
if strings.Contains(output, "tar") {
// tar is tagged "compression", not "networking"
t.Errorf("tag filter should exclude tar, got: %s", output)
}
if strings.Contains(output, "file:") {
t.Errorf("brief output should not contain file: column, got: %s", output)
}
})
}

View File

@@ -0,0 +1,246 @@
package integration
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// hasCwdCheatpath checks whether the --directories output contains a
// cheatpath named "cwd". The output format is "name: path\n" per line
// (tabwriter-aligned), so we look for a line beginning with "cwd".
func hasCwdCheatpath(output string) bool {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "cwd") {
return true
}
}
return false
}
// TestLocalCheatpathIntegration exercises the recursive .cheat directory
// discovery end-to-end: it builds the real cheat binary, sets up filesystem
// layouts, and verifies behaviour from the user's perspective.
func TestLocalCheatpathIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env vars")
}
// Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}
// cheatEnv returns a minimal environment for the cheat binary.
cheatEnv := func(confPath, home string) []string {
return []string{
"CHEAT_CONFIG_PATH=" + confPath,
"HOME=" + home,
"PATH=" + os.Getenv("PATH"),
"EDITOR=vi",
}
}
// writeConfig writes a minimal valid config file referencing sheetsDir.
writeConfig := func(t *testing.T, dir, sheetsDir string) string {
t.Helper()
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: base\n path: %s\n readonly: true\n", sheetsDir)
confPath := filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confPath, []byte(conf), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
return confPath
}
t.Run("parent .cheat is discovered from subdirectory", func(t *testing.T) {
root := t.TempDir()
// Configured cheatpath (empty but must exist for validation)
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
// .cheat at root with a cheatsheet
dotCheat := filepath.Join(root, ".cheat")
os.Mkdir(dotCheat, 0755)
os.WriteFile(
filepath.Join(dotCheat, "localsheet"),
[]byte("---\nsyntax: bash\n---\necho hello from local\n"),
0644,
)
confPath := writeConfig(t, root, sheetsDir)
// Work from a subdirectory
workDir := filepath.Join(root, "src", "pkg")
os.MkdirAll(workDir, 0755)
env := cheatEnv(confPath, root)
// --directories should list "cwd" cheatpath
cmd := exec.Command(binPath, "--directories")
cmd.Dir = workDir
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if !hasCwdCheatpath(string(output)) {
t.Errorf("expected 'cwd' cheatpath in --directories output:\n%s", output)
}
// Viewing the cheatsheet should show its content
cmd2 := exec.Command(binPath, "localsheet")
cmd2.Dir = workDir
cmd2.Env = env
output2, err := cmd2.CombinedOutput()
if err != nil {
t.Fatalf("cheat localsheet failed: %v\nOutput: %s", err, output2)
}
if !strings.Contains(string(output2), "echo hello from local") {
t.Errorf("expected cheatsheet content, got:\n%s", output2)
}
})
t.Run("grandparent .cheat is discovered from deep subdirectory", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
dotCheat := filepath.Join(root, ".cheat")
os.Mkdir(dotCheat, 0755)
os.WriteFile(
filepath.Join(dotCheat, "deepsheet"),
[]byte("---\nsyntax: bash\n---\ndeep discovery works\n"),
0644,
)
confPath := writeConfig(t, root, sheetsDir)
deepDir := filepath.Join(root, "a", "b", "c", "d", "e")
os.MkdirAll(deepDir, 0755)
cmd := exec.Command(binPath, "deepsheet")
cmd.Dir = deepDir
cmd.Env = cheatEnv(confPath, root)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat deepsheet failed: %v\nOutput: %s", err, output)
}
if !strings.Contains(string(output), "deep discovery works") {
t.Errorf("expected cheatsheet content, got:\n%s", output)
}
})
t.Run("nearest .cheat wins over ancestor .cheat", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
// .cheat at root
rootCheat := filepath.Join(root, ".cheat")
os.Mkdir(rootCheat, 0755)
os.WriteFile(
filepath.Join(rootCheat, "shared"),
[]byte("---\nsyntax: bash\n---\nfrom root\n"),
0644,
)
// .cheat at project/ (nearer)
projectDir := filepath.Join(root, "project")
os.MkdirAll(projectDir, 0755)
projectCheat := filepath.Join(projectDir, ".cheat")
os.Mkdir(projectCheat, 0755)
os.WriteFile(
filepath.Join(projectCheat, "shared"),
[]byte("---\nsyntax: bash\n---\nfrom project nearest\n"),
0644,
)
confPath := writeConfig(t, root, sheetsDir)
workDir := filepath.Join(projectDir, "src")
os.MkdirAll(workDir, 0755)
env := cheatEnv(confPath, root)
// --directories should list the nearer cheatpath
cmd := exec.Command(binPath, "--directories")
cmd.Dir = workDir
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if !strings.Contains(string(output), projectCheat) {
t.Errorf("expected project .cheat path in output, got:\n%s", output)
}
// "shared" sheet should come from the nearer .cheat
cmd2 := exec.Command(binPath, "shared")
cmd2.Dir = workDir
cmd2.Env = env
output2, err := cmd2.CombinedOutput()
if err != nil {
t.Fatalf("cheat shared failed: %v\nOutput: %s", err, output2)
}
if !strings.Contains(string(output2), "from project nearest") {
t.Errorf("expected nearest .cheat content, got:\n%s", output2)
}
})
t.Run("no .cheat directory means no cwd cheatpath", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
// Need at least one sheet for --directories to work without error
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
confPath := writeConfig(t, root, sheetsDir)
// No .cheat anywhere under root
cmd := exec.Command(binPath, "--directories")
cmd.Dir = root
cmd.Env = cheatEnv(confPath, root)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if hasCwdCheatpath(string(output)) {
t.Errorf("'cwd' cheatpath should not appear when no .cheat exists:\n%s", output)
}
})
t.Run(".cheat file (not directory) is ignored", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
// Create .cheat as a regular file
os.WriteFile(filepath.Join(root, ".cheat"), []byte("not a dir"), 0644)
confPath := writeConfig(t, root, sheetsDir)
cmd := exec.Command(binPath, "--directories")
cmd.Dir = root
cmd.Env = cheatEnv(confPath, root)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if hasCwdCheatpath(string(output)) {
t.Errorf("'cwd' should not appear for a .cheat file:\n%s", output)
}
})
}

View File

@@ -0,0 +1,305 @@
package integration
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, "./cmd/cheat")
build.Dir = repoRoot(t)
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

@@ -0,0 +1,30 @@
package integration
import (
"path/filepath"
"runtime"
"testing"
)
// repoRoot returns the absolute path to the repository root.
// It derives this from the known location of this source file
// (test/integration/) relative to the repo root.
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to determine repo root via runtime.Caller")
}
// file is <repo>/test/integration/helpers_test.go → go up two dirs
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}
// repoRootBench is the same as repoRoot but for use in benchmarks.
func repoRootBench(b *testing.B) string {
b.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
b.Fatal("failed to determine repo root via runtime.Caller")
}
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}

View File

@@ -1,10 +1,11 @@
package main package integration
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
@@ -12,9 +13,15 @@ import (
// TestPathTraversalIntegration tests that the cheat binary properly blocks // TestPathTraversalIntegration tests that the cheat binary properly blocks
// path traversal attempts when invoked as a subprocess. // path traversal attempts when invoked as a subprocess.
func TestPathTraversalIntegration(t *testing.T) { func TestPathTraversalIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env and tools")
}
// Build the cheat binary // Build the cheat binary
binPath := filepath.Join(t.TempDir(), "cheat_test") binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil { build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output) t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
} }
@@ -146,11 +153,17 @@ cheatpaths:
// TestPathTraversalRealWorld tests with more realistic scenarios // TestPathTraversalRealWorld tests with more realistic scenarios
func TestPathTraversalRealWorld(t *testing.T) { 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 // This test ensures our protection works with actual file operations
// Build cheat // Build cheat
binPath := filepath.Join(t.TempDir(), "cheat_test") binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil { build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("Failed to build: %v\n%s", err, output) t.Fatalf("Failed to build: %v\n%s", err, output)
} }

View File

@@ -1,6 +1,6 @@
//go:build integration //go:build integration
package main package integration
import ( import (
"bytes" "bytes"
@@ -9,7 +9,6 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
@@ -17,12 +16,10 @@ import (
// BenchmarkSearchCommand benchmarks the actual cheat search command // BenchmarkSearchCommand benchmarks the actual cheat search command
func BenchmarkSearchCommand(b *testing.B) { func BenchmarkSearchCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path) // Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", "..")) tmpDir := filepath.Join(root, ".tmp", "bench-test")
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 { if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err) b.Fatalf("Failed to create temp dir: %v", err)
} }
@@ -35,7 +32,7 @@ func BenchmarkSearchCommand(b *testing.B) {
}) })
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat") cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil { if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output) b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
} }
@@ -108,23 +105,15 @@ cheatpaths:
cmd := exec.Command(cheatBin, tc.args...) cmd := exec.Command(cheatBin, tc.args...)
cmd.Env = env cmd.Env = env
// Capture output to prevent spamming
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
start := time.Now()
err := cmd.Run() err := cmd.Run()
elapsed := time.Since(start)
if err != nil { if err != nil {
b.Fatalf("Command failed: %v\nStderr: %s", err, stderr.String()) 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 { if stdout.Len() == 0 {
b.Fatal("No output from search") b.Fatal("No output from search")
} }
@@ -135,12 +124,10 @@ cheatpaths:
// BenchmarkListCommand benchmarks the list command for comparison // BenchmarkListCommand benchmarks the list command for comparison
func BenchmarkListCommand(b *testing.B) { func BenchmarkListCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path) // Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", "..")) tmpDir := filepath.Join(root, ".tmp", "bench-test")
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 { if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err) b.Fatalf("Failed to create temp dir: %v", err)
} }
@@ -153,7 +140,7 @@ func BenchmarkListCommand(b *testing.B) {
}) })
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat") cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil { if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output) b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
} }

View File

@@ -13,6 +13,9 @@
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
# Golang/Intellij
.idea
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/ .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 ## 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 ### Important notes
#### 1.0.0 #### 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 #### 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: 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://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> <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 ### Mergo in the wild
- [moby/moby](https://github.com/moby/moby) 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:
- [kubernetes/kubernetes](https://github.com/kubernetes/kubernetes)
- [vmware/dispatch](https://github.com/vmware/dispatch) * [containerd/containerd](https://github.com/containerd/containerd)
- [Shopify/themekit](https://github.com/Shopify/themekit) * [datadog/datadog-agent](https://github.com/datadog/datadog-agent)
- [imdario/zas](https://github.com/imdario/zas) * [docker/cli/](https://github.com/docker/cli/)
- [matcornic/hermes](https://github.com/matcornic/hermes) * [goreleaser/goreleaser](https://github.com/goreleaser/goreleaser)
- [OpenBazaar/openbazaar-go](https://github.com/OpenBazaar/openbazaar-go) * [go-micro/go-micro](https://github.com/go-micro/go-micro)
- [kataras/iris](https://github.com/kataras/iris) * [grafana/loki](https://github.com/grafana/loki)
- [michaelsauter/crane](https://github.com/michaelsauter/crane) * [masterminds/sprig](github.com/Masterminds/sprig)
- [go-task/task](https://github.com/go-task/task) * [moby/moby](https://github.com/moby/moby)
- [sensu/uchiwa](https://github.com/sensu/uchiwa) * [slackhq/nebula](https://github.com/slackhq/nebula)
- [ory/hydra](https://github.com/ory/hydra) * [volcano-sh/volcano](https://github.com/volcano-sh/volcano)
- [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)
## Install ## 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. 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 ```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
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`? 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 | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 0.3.x | :white_check_mark: | | 1.x.x | :white_check_mark: |
| < 0.3 | :x: | | < 1.0 | :x: |
## Security contact information ## 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 := field.Name
fieldName = changeInitialCase(fieldName, unicode.ToLower) 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() 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 { if err = deepMerge(dst.Elem(), src.Elem(), visited, depth+1, config); err != nil {
return return
} }
} else { } else if src.Elem().Kind() != reflect.Struct {
if overwriteWithEmptySrc || (overwrite && !src.IsNil()) || dst.IsNil() { if overwriteWithEmptySrc || (overwrite && !src.IsNil()) || dst.IsNil() {
dst.Set(src) dst.Set(src)
} }

View File

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

View File

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

View File

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

View File

@@ -18,9 +18,18 @@ type FileBasicInfo struct {
_ uint32 // padding _ 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. // GetFileBasicInfo retrieves times and attributes for a file.
func GetFileBasicInfo(f *os.File) (*FileBasicInfo, error) { func GetFileBasicInfo(f *os.File) (*FileBasicInfo, error) {
bi := &FileBasicInfo{} bi := &alignedFileBasicInfo{}
if err := windows.GetFileInformationByHandleEx( if err := windows.GetFileInformationByHandleEx(
windows.Handle(f.Fd()), windows.Handle(f.Fd()),
windows.FileBasicInfo, windows.FileBasicInfo,
@@ -30,16 +39,21 @@ func GetFileBasicInfo(f *os.File) (*FileBasicInfo, error) {
return nil, &os.PathError{Op: "GetFileInformationByHandleEx", Path: f.Name(), Err: err} return nil, &os.PathError{Op: "GetFileInformationByHandleEx", Path: f.Name(), Err: err}
} }
runtime.KeepAlive(f) 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. // SetFileBasicInfo sets times and attributes for a file.
func SetFileBasicInfo(f *os.File, bi *FileBasicInfo) error { 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( if err := windows.SetFileInformationByHandle(
windows.Handle(f.Fd()), windows.Handle(f.Fd()),
windows.FileBasicInfo, windows.FileBasicInfo,
(*byte)(unsafe.Pointer(bi)), (*byte)(unsafe.Pointer(&biAligned)),
uint32(unsafe.Sizeof(*bi)), uint32(unsafe.Sizeof(biAligned)),
); err != nil { ); err != nil {
return &os.PathError{Op: "SetFileInformationByHandle", Path: f.Name(), Err: err} return &os.PathError{Op: "SetFileInformationByHandle", Path: f.Name(), Err: err}
} }

View File

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

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