chore: bump version to 4.5.0

Bug fixes:
- Fix inverted pager detection logic (returned error instead of path)
- Fix repo.Clone ignoring destination directory parameter
- Fix sheet loading using append on pre-sized slices
- Clean up partial files on copy failure
- Trim whitespace from editor config

Security:
- Add path traversal protection for cheatsheet names

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Allen Lane
2026-02-14 19:56:19 -05:00
parent 7908a678df
commit cc85a4bdb1
69 changed files with 4802 additions and 577 deletions

65
internal/sheets/doc.go Normal file
View File

@@ -0,0 +1,65 @@
// Package sheets manages collections of cheat sheets across multiple cheatpaths.
//
// The sheets package provides functionality to:
// - Load sheets from multiple cheatpaths
// - Consolidate duplicate sheets (with precedence rules)
// - Filter sheets by tags
// - Sort sheets alphabetically
// - Extract unique tags across all sheets
//
// # Loading Sheets
//
// Sheets are loaded recursively from cheatpath directories, excluding:
// - Hidden files (starting with .)
// - Files in .git directories
// - Files with extensions (sheets have no extension)
//
// # Consolidation
//
// When multiple cheatpaths contain sheets with the same name, consolidation
// rules apply based on the order of cheatpaths. Sheets from earlier paths
// override those from later paths, allowing personal sheets to override
// community sheets.
//
// Example:
//
// cheatpaths:
// 1. personal: ~/cheat
// 2. community: ~/cheat/community
//
// If both contain "git", the version from "personal" is used.
//
// # Filtering
//
// Sheets can be filtered by:
// - Tags: Include only sheets with specific tags
// - Cheatpath: Include only sheets from specific paths
//
// Key Functions
//
// - Load: Loads all sheets from the given cheatpaths
// - Filter: Filters sheets by tag
// - Consolidate: Merges sheets from multiple paths with precedence
// - Sort: Sorts sheets alphabetically by title
// - Tags: Extracts all unique tags from sheets
//
// Example Usage
//
// // Load sheets from all cheatpaths
// allSheets, err := sheets.Load(cheatpaths)
// if err != nil {
// log.Fatal(err)
// }
//
// // Consolidate to handle duplicates
// consolidated := sheets.Consolidate(allSheets)
//
// // Filter by tag
// filtered := sheets.Filter(consolidated, "networking")
//
// // Sort alphabetically
// sheets.Sort(filtered)
//
// // Get all unique tags
// tags := sheets.Tags(consolidated)
package sheets

View File

@@ -2,6 +2,7 @@ package sheets
import (
"strings"
"unicode/utf8"
"github.com/cheat/cheat/internal/sheet"
)
@@ -31,7 +32,8 @@ func Filter(
// iterate over each tag. If the sheet does not match *all* tags, filter
// it out.
for _, tag := range tags {
if !sheet.Tagged(strings.TrimSpace(tag)) {
trimmed := strings.TrimSpace(tag)
if trimmed == "" || !utf8.ValidString(trimmed) || !sheet.Tagged(trimmed) {
keep = false
}
}

View File

@@ -0,0 +1,177 @@
package sheets
import (
"strings"
"testing"
"github.com/cheat/cheat/internal/sheet"
)
// FuzzFilter tests the Filter function with various tag combinations
func FuzzFilter(f *testing.F) {
// Add seed corpus with various tag scenarios
// Format: "tags to filter by" (comma-separated)
f.Add("linux")
f.Add("linux,bash")
f.Add("linux,bash,ssh")
f.Add("")
f.Add(" ")
f.Add(" linux ")
f.Add("linux,")
f.Add(",linux")
f.Add(",,")
f.Add("linux,,bash")
f.Add("tag-with-dash")
f.Add("tag_with_underscore")
f.Add("UPPERCASE")
f.Add("miXedCase")
f.Add("🎉emoji")
f.Add("tag with spaces")
f.Add("\ttab\ttag")
f.Add("tag\nwith\nnewline")
f.Add("very-long-tag-name-that-might-cause-issues-somewhere")
f.Add(strings.Repeat("a,", 100) + "a")
f.Fuzz(func(t *testing.T, tagString string) {
// Split the tag string into individual tags
var tags []string
if tagString != "" {
tags = strings.Split(tagString, ",")
}
// Create test data - some sheets with various tags
cheatpaths := []map[string]sheet.Sheet{
{
"sheet1": sheet.Sheet{
Title: "sheet1",
Tags: []string{"linux", "bash"},
},
"sheet2": sheet.Sheet{
Title: "sheet2",
Tags: []string{"linux", "ssh", "networking"},
},
"sheet3": sheet.Sheet{
Title: "sheet3",
Tags: []string{"UPPERCASE", "miXedCase"},
},
},
{
"sheet4": sheet.Sheet{
Title: "sheet4",
Tags: []string{"tag with spaces", "🎉emoji"},
},
"sheet5": sheet.Sheet{
Title: "sheet5",
Tags: []string{}, // No tags
},
},
}
// The function should not panic
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Filter panicked with tags %q: %v", tags, r)
}
}()
result := Filter(cheatpaths, tags)
// Verify invariants
// 1. Result should have same number of cheatpaths
if len(result) != len(cheatpaths) {
t.Errorf("Filter changed number of cheatpaths: got %d, want %d",
len(result), len(cheatpaths))
}
// 2. Each filtered sheet should contain all requested tags
for _, filteredPath := range result {
for title, sheet := range filteredPath {
// Verify this sheet has all the tags we filtered for
for _, tag := range tags {
trimmedTag := strings.TrimSpace(tag)
if trimmedTag == "" {
continue // Skip empty tags
}
if !sheet.Tagged(trimmedTag) {
t.Errorf("Sheet %q passed filter but doesn't have tag %q",
title, trimmedTag)
}
}
}
}
// 3. Empty tag list should return all sheets
if len(tags) == 0 || (len(tags) == 1 && tags[0] == "") {
totalOriginal := 0
totalFiltered := 0
for _, path := range cheatpaths {
totalOriginal += len(path)
}
for _, path := range result {
totalFiltered += len(path)
}
if totalFiltered != totalOriginal {
t.Errorf("Empty filter should return all sheets: got %d, want %d",
totalFiltered, totalOriginal)
}
}
}()
})
}
// FuzzFilterEdgeCases tests Filter with extreme inputs
func FuzzFilterEdgeCases(f *testing.F) {
// Seed with number of tags and tag length
f.Add(0, 0)
f.Add(1, 10)
f.Add(10, 10)
f.Add(100, 5)
f.Add(1000, 3)
f.Fuzz(func(t *testing.T, numTags int, tagLen int) {
// Limit to reasonable values to avoid memory issues
if numTags > 1000 || numTags < 0 || tagLen > 100 || tagLen < 0 {
t.Skip("Skipping unreasonable test case")
}
// Generate tags
tags := make([]string, numTags)
for i := 0; i < numTags; i++ {
// Create a tag of specified length
if tagLen > 0 {
tags[i] = strings.Repeat("a", tagLen) + string(rune(i%26+'a'))
}
}
// Create a sheet with no tags (should be filtered out)
cheatpaths := []map[string]sheet.Sheet{
{
"test": sheet.Sheet{
Title: "test",
Tags: []string{},
},
},
}
// Should not panic with many tags
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Filter panicked with %d tags of length %d: %v",
numTags, tagLen, r)
}
}()
result := Filter(cheatpaths, tags)
// With non-matching tags, result should be empty
if numTags > 0 && tagLen > 0 {
if len(result[0]) != 0 {
t.Errorf("Expected empty result with non-matching tags, got %d sheets",
len(result[0]))
}
}
}()
})
}

View File

@@ -20,7 +20,7 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
sheets := make([]map[string]sheet.Sheet, len(cheatpaths))
// iterate over each cheatpath
for _, cheatpath := range cheatpaths {
for i, cheatpath := range cheatpaths {
// vivify the map of cheatsheets on this specific cheatpath
pathsheets := make(map[string]sheet.Sheet)
@@ -43,6 +43,19 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
return nil
}
// get the base filename
filename := filepath.Base(path)
// skip hidden files (files that start with a dot)
if strings.HasPrefix(filename, ".") {
return nil
}
// skip files with extensions (cheatsheets have no extension)
if filepath.Ext(filename) != "" {
return nil
}
// calculate the cheatsheet's "title" (the phrase with which it may be
// accessed. Eg: `cheat tar` - `tar` is the title)
title := strings.TrimPrefix(
@@ -88,7 +101,7 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
// store the sheets on this cheatpath alongside the other cheatsheets on
// other cheatpaths
sheets = append(sheets, pathsheets)
sheets[i] = pathsheets
}
// return the cheatsheets, grouped by cheatpath

View File

@@ -26,19 +26,26 @@ func TestLoad(t *testing.T) {
}
// load cheatsheets
sheets, err := Load(cheatpaths)
cheatpathSheets, err := Load(cheatpaths)
if err != nil {
t.Errorf("failed to load cheatsheets: %v", err)
}
// assert that the correct number of sheets loaded
// (sheet load details are tested in `sheet_test.go`)
totalSheets := 0
for _, sheets := range cheatpathSheets {
totalSheets += len(sheets)
}
// we expect 4 total sheets (2 from community, 2 from personal)
// hidden files and files with extensions are excluded
want := 4
if len(sheets) != want {
if totalSheets != want {
t.Errorf(
"failed to load correct number of cheatsheets: want: %d, got: %d",
want,
len(sheets),
totalSheets,
)
}
}

View File

@@ -2,6 +2,7 @@ package sheets
import (
"sort"
"unicode/utf8"
"github.com/cheat/cheat/internal/sheet"
)
@@ -16,7 +17,10 @@ func Tags(cheatpaths []map[string]sheet.Sheet) []string {
for _, path := range cheatpaths {
for _, sheet := range path {
for _, tag := range sheet.Tags {
tags[tag] = true
// Skip invalid UTF-8 tags to prevent downstream issues
if utf8.ValidString(tag) {
tags[tag] = true
}
}
}
}

View File

@@ -0,0 +1,190 @@
package sheets
import (
"strings"
"testing"
"unicode/utf8"
"github.com/cheat/cheat/internal/sheet"
)
// FuzzTags tests the Tags function with various tag combinations
func FuzzTags(f *testing.F) {
// Add seed corpus
// Format: comma-separated tags that will be distributed across sheets
f.Add("linux,bash,ssh")
f.Add("")
f.Add("single")
f.Add("duplicate,duplicate,duplicate")
f.Add(" spaces , around , tags ")
f.Add("MiXeD,UPPER,lower")
f.Add("special-chars,under_score,dot.ted")
f.Add("emoji🎉,unicode中文,symbols@#$")
f.Add("\ttab,\nnewline,\rcarriage")
f.Add(",,,,") // Multiple empty tags
f.Add(strings.Repeat("tag,", 100)) // Many tags
f.Add("a," + strings.Repeat("very-long-tag-name", 10)) // Long tag names
f.Fuzz(func(t *testing.T, tagString string) {
// Split tags and distribute them across multiple sheets
var allTags []string
if tagString != "" {
allTags = strings.Split(tagString, ",")
}
// Create test cheatpaths with various tag distributions
cheatpaths := []map[string]sheet.Sheet{}
// Distribute tags across 3 paths with overlapping tags
for i := 0; i < 3; i++ {
path := make(map[string]sheet.Sheet)
// Each path gets some subset of tags
for j, tag := range allTags {
if j%3 == i || j%(i+2) == 0 { // Create some overlap
sheetName := string(rune('a' + j%26))
path[sheetName] = sheet.Sheet{
Title: sheetName,
Tags: []string{tag},
}
}
}
// Add a sheet with multiple tags
if len(allTags) > 1 {
path["multi"] = sheet.Sheet{
Title: "multi",
Tags: allTags[:len(allTags)/2+1], // First half of tags
}
}
cheatpaths = append(cheatpaths, path)
}
// The function should not panic
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Tags panicked with input %q: %v", tagString, r)
}
}()
result := Tags(cheatpaths)
// Verify invariants
// 1. Result should be sorted
for i := 1; i < len(result); i++ {
if result[i-1] >= result[i] {
t.Errorf("Tags not sorted: %q >= %q at positions %d, %d",
result[i-1], result[i], i-1, i)
}
}
// 2. No duplicates in result
seen := make(map[string]bool)
for _, tag := range result {
if seen[tag] {
t.Errorf("Duplicate tag in result: %q", tag)
}
seen[tag] = true
}
// 3. All non-empty tags from input should be in result
// (This is approximate since we distributed tags in a complex way)
inputTags := make(map[string]bool)
for _, tag := range allTags {
if tag != "" {
inputTags[tag] = true
}
}
resultTags := make(map[string]bool)
for _, tag := range result {
resultTags[tag] = true
}
// Result might have fewer tags due to distribution logic,
// but shouldn't have tags not in the input
for tag := range resultTags {
found := false
for inputTag := range inputTags {
if tag == inputTag {
found = true
break
}
}
if !found && tag != "" {
t.Errorf("Result contains tag %q not derived from input", tag)
}
}
// 4. Valid UTF-8 (Tags function should filter out invalid UTF-8)
for _, tag := range result {
if !utf8.ValidString(tag) {
t.Errorf("Invalid UTF-8 in tag: %q", tag)
}
}
}()
})
}
// FuzzTagsStress tests Tags function with large numbers of tags
func FuzzTagsStress(f *testing.F) {
// Seed: number of unique tags, number of sheets, tags per sheet
f.Add(10, 10, 5)
f.Add(100, 50, 10)
f.Add(1000, 100, 20)
f.Fuzz(func(t *testing.T, numUniqueTags int, numSheets int, tagsPerSheet int) {
// Limit to reasonable values
if numUniqueTags > 1000 || numUniqueTags < 0 ||
numSheets > 1000 || numSheets < 0 ||
tagsPerSheet > 100 || tagsPerSheet < 0 {
t.Skip("Skipping unreasonable test case")
}
// Generate unique tags
uniqueTags := make([]string, numUniqueTags)
for i := 0; i < numUniqueTags; i++ {
uniqueTags[i] = "tag" + string(rune(i))
}
// Create sheets with random tags
cheatpaths := []map[string]sheet.Sheet{
make(map[string]sheet.Sheet),
}
for i := 0; i < numSheets; i++ {
// Select random tags for this sheet
sheetTags := make([]string, 0, tagsPerSheet)
for j := 0; j < tagsPerSheet && j < numUniqueTags; j++ {
// Distribute tags across sheets
tagIndex := (i*tagsPerSheet + j) % numUniqueTags
sheetTags = append(sheetTags, uniqueTags[tagIndex])
}
cheatpaths[0]["sheet"+string(rune(i))] = sheet.Sheet{
Title: "sheet" + string(rune(i)),
Tags: sheetTags,
}
}
// Should handle large numbers efficiently
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Tags panicked with %d unique tags, %d sheets, %d tags/sheet: %v",
numUniqueTags, numSheets, tagsPerSheet, r)
}
}()
result := Tags(cheatpaths)
// Should have at most numUniqueTags in result
if len(result) > numUniqueTags {
t.Errorf("More tags in result (%d) than unique tags created (%d)",
len(result), numUniqueTags)
}
}()
})
}

View File

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

View File

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