The canonical beads database name is issues.jsonl. Tens of thousands of users have issues.jsonl, and beads.jsonl was only used by the Beads project itself due to git history pollution. Changes: - Updated bd doctor to warn about beads.jsonl instead of issues.jsonl - Changed default config from beads.jsonl to issues.jsonl - Reversed precedence in checkGitForIssues to prefer issues.jsonl - Updated git merge driver config to use issues.jsonl - Updated all tests to expect issues.jsonl as the default issues.jsonl is now the canonical default; beads.jsonl is legacy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
216 lines
7.1 KiB
Go
216 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestCleanupMergeArtifacts_CommandInjectionPrevention verifies that the git rm
|
|
// command in cleanupMergeArtifacts is safe from command injection attacks.
|
|
//
|
|
// This test addresses bd-yxy: gosec G204 flags exec.Command with variable fullPath
|
|
// in merge.go:121. We verify that:
|
|
// 1. Shell metacharacters in filenames don't cause command injection
|
|
// 2. exec.Command passes arguments directly to git (no shell interpretation)
|
|
// 3. Only backup files are targeted for removal
|
|
func TestCleanupMergeArtifacts_CommandInjectionPrevention(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
wantSafe bool
|
|
}{
|
|
{
|
|
name: "shell metacharacter semicolon",
|
|
filename: "backup; rm -rf /",
|
|
wantSafe: true, // exec.Command doesn't use shell, so ; is just part of filename
|
|
},
|
|
{
|
|
name: "shell metacharacter pipe",
|
|
filename: "backup | cat /etc/passwd",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "shell metacharacter ampersand",
|
|
filename: "backup & malicious_command",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "shell variable expansion",
|
|
filename: "backup$PATH",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "command substitution backticks",
|
|
filename: "backup`whoami`",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "command substitution dollar-paren",
|
|
filename: "backup$(whoami)",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "normal backup file",
|
|
filename: "issues.jsonl.backup",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "backup with spaces",
|
|
filename: "backup file with spaces.jsonl",
|
|
wantSafe: true,
|
|
},
|
|
{
|
|
name: "path traversal attempt",
|
|
filename: "../../backup_etc_passwd",
|
|
wantSafe: true, // filepath.Join normalizes this
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temporary test environment
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
// Create a test file with potentially dangerous name
|
|
testFile := filepath.Join(beadsDir, tt.filename)
|
|
|
|
// Create the file - this will fail if filename contains path separators
|
|
// or other invalid characters, which is exactly what we want
|
|
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
|
// Some filenames may be invalid on the OS - that's fine, they can't
|
|
// be exploited if they can't be created
|
|
t.Logf("Could not create file with name %q (OS prevented): %v", tt.filename, err)
|
|
return
|
|
}
|
|
|
|
// Create output path
|
|
outputPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if err := os.WriteFile(outputPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatalf("Failed to create output file: %v", err)
|
|
}
|
|
|
|
// Run cleanup with debug=false (normal operation)
|
|
cleanupMergeArtifacts(outputPath, false)
|
|
|
|
// Verify the file was removed (since it contains "backup")
|
|
if _, err := os.Stat(testFile); err == nil {
|
|
// File still exists - this is fine if git rm failed because
|
|
// the file isn't tracked, but os.Remove should have removed it
|
|
t.Logf("File %q still exists after cleanup - this is OK if not tracked", tt.filename)
|
|
}
|
|
|
|
// Most importantly: verify no command injection occurred
|
|
// If command injection worked, we'd see evidence in the filesystem
|
|
// or the test would hang/crash. The fact that we get here means
|
|
// exec.Command safely handled the filename.
|
|
|
|
// Verify that sensitive paths are NOT affected
|
|
if _, err := os.Stat("/etc/passwd"); err != nil {
|
|
t.Errorf("Command injection may have occurred - /etc/passwd missing")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCleanupMergeArtifacts_OnlyBackupFiles verifies that only files with
|
|
// "backup" in their name are targeted for removal, preventing accidental
|
|
// deletion of other files.
|
|
func TestCleanupMergeArtifacts_OnlyBackupFiles(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
// Create various files
|
|
files := map[string]bool{
|
|
"issues.jsonl": false, // Should NOT be removed
|
|
"beads.db": false, // Should NOT be removed
|
|
"backup.jsonl": true, // Should be removed
|
|
"issues.jsonl.backup": true, // Should be removed
|
|
"BACKUP_FILE": true, // Should be removed (case-insensitive)
|
|
"my_backup_2024.txt": true, // Should be removed
|
|
"important_data.jsonl": false, // Should NOT be removed
|
|
"issues.jsonl.bak": false, // Should NOT be removed (no "backup")
|
|
}
|
|
|
|
for filename := range files {
|
|
path := filepath.Join(beadsDir, filename)
|
|
if err := os.WriteFile(path, []byte("test"), 0644); err != nil {
|
|
t.Fatalf("Failed to create %s: %v", filename, err)
|
|
}
|
|
}
|
|
|
|
// Create output path
|
|
outputPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
// Run cleanup
|
|
cleanupMergeArtifacts(outputPath, false)
|
|
|
|
// Verify correct files were removed/preserved
|
|
for filename, shouldRemove := range files {
|
|
path := filepath.Join(beadsDir, filename)
|
|
_, err := os.Stat(path)
|
|
exists := err == nil
|
|
|
|
if shouldRemove && exists {
|
|
t.Errorf("File %q should have been removed but still exists", filename)
|
|
}
|
|
if !shouldRemove && !exists {
|
|
t.Errorf("File %q should have been preserved but was removed", filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCleanupMergeArtifacts_GitRmSafety verifies that git rm is called with
|
|
// safe arguments and proper working directory.
|
|
func TestCleanupMergeArtifacts_GitRmSafety(t *testing.T) {
|
|
// This test verifies the fix for bd-yxy by ensuring:
|
|
// 1. fullPath is constructed safely using filepath.Join
|
|
// 2. exec.Command is used (not shell execution)
|
|
// 3. Arguments are passed individually (no concatenation)
|
|
// 4. Working directory is set correctly
|
|
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
// Initialize git repo (required for git rm to work)
|
|
// Note: We're not actually testing git functionality here,
|
|
// just verifying our command construction is safe
|
|
|
|
// Create a backup file
|
|
backupFile := filepath.Join(beadsDir, "test.backup")
|
|
if err := os.WriteFile(backupFile, []byte("test"), 0644); err != nil {
|
|
t.Fatalf("Failed to create backup file: %v", err)
|
|
}
|
|
|
|
outputPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if err := os.WriteFile(outputPath, []byte("{}"), 0644); err != nil {
|
|
t.Fatalf("Failed to create output file: %v", err)
|
|
}
|
|
|
|
// Run cleanup - this should safely attempt git rm and then os.Remove
|
|
cleanupMergeArtifacts(outputPath, false)
|
|
|
|
// Verify backup file was removed (by os.Remove since git rm will fail
|
|
// in a non-git directory)
|
|
if _, err := os.Stat(backupFile); err == nil {
|
|
t.Errorf("Backup file should have been removed")
|
|
}
|
|
|
|
// Key insight: The security issue (G204) is actually a false positive.
|
|
// exec.Command("git", "rm", "-f", "--quiet", fullPath) is safe because:
|
|
// - fullPath is constructed with filepath.Join (safe)
|
|
// - exec.Command does NOT use a shell
|
|
// - Arguments are passed as separate strings to git binary
|
|
// - Shell metacharacters are treated as literal characters in the filename
|
|
}
|