feat(context): centralize RepoContext API for git operations (#1102)

Centralizes repository context resolution via RepoContext API, fixing bugs where git commands run in the wrong repo when BEADS_DIR points elsewhere or in worktree scenarios.
This commit is contained in:
Peter Chanthamynavong
2026-01-15 07:55:08 -08:00
committed by GitHub
parent 159114563b
commit 0a48519561
33 changed files with 3211 additions and 327 deletions
+344
View File
@@ -0,0 +1,344 @@
// Package beads context.go provides centralized repository context resolution.
//
// Problem: 50+ git commands across the codebase assume CWD is the repository root.
// When BEADS_DIR points to a different repo, or when running from a worktree,
// these commands execute in the wrong directory.
//
// Solution: RepoContext provides a single source of truth for repository paths,
// with methods that ensure git commands run in the correct repository.
//
// Usage:
//
// rc, err := beads.GetRepoContext()
// if err != nil {
// return err
// }
// cmd := rc.GitCmd(ctx, "status") // Runs in beads repo, not CWD
//
// See docs/REPO_CONTEXT.md for detailed documentation.
package beads
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/steveyegge/beads/internal/git"
)
// RepoContext holds resolved repository paths for beads operations.
//
// The struct distinguishes between:
// - RepoRoot: where .beads/ lives (for git operations on beads data)
// - CWDRepoRoot: where user is working (for status display, etc.)
//
// These may differ when BEADS_DIR points to a different repository,
// or when running from a git worktree.
type RepoContext struct {
// BeadsDir is the actual .beads directory path (after following redirects).
BeadsDir string
// RepoRoot is the repository root containing BeadsDir.
// Git commands for beads operations should run here.
RepoRoot string
// CWDRepoRoot is the repository root containing the current working directory.
// May differ from RepoRoot when BEADS_DIR points elsewhere.
CWDRepoRoot string
// IsRedirected is true if BeadsDir was resolved via BEADS_DIR env var
// pointing to a different repository than CWD.
IsRedirected bool
// IsWorktree is true if CWD is in a git worktree.
IsWorktree bool
}
var (
repoCtx *RepoContext
repoCtxOnce sync.Once
repoCtxErr error
)
// GetRepoContext returns the cached repository context, initializing it on first call.
//
// The context is cached because:
// 1. CWD doesn't change during command execution
// 2. BEADS_DIR doesn't change during command execution
// 3. Repeated filesystem access would be wasteful
//
// Returns an error if no .beads directory can be found.
func GetRepoContext() (*RepoContext, error) {
repoCtxOnce.Do(func() {
repoCtx, repoCtxErr = buildRepoContext()
})
return repoCtx, repoCtxErr
}
// buildRepoContext constructs the RepoContext by resolving all paths.
// This is called once per process via sync.Once.
func buildRepoContext() (*RepoContext, error) {
// 1. Find .beads directory (respects BEADS_DIR env var)
beadsDir := FindBeadsDir()
if beadsDir == "" {
return nil, fmt.Errorf("no .beads directory found")
}
// 2. Security: Validate path boundary (SEC-003)
if !isPathInSafeBoundary(beadsDir) {
return nil, fmt.Errorf("BEADS_DIR points to unsafe location: %s", beadsDir)
}
// 3. Check for redirect
redirectInfo := GetRedirectInfo()
// 3. Determine RepoRoot based on redirect status
var repoRoot string
if redirectInfo.IsRedirected {
// BEADS_DIR points to different repo - use that repo's root
repoRoot = filepath.Dir(beadsDir)
} else {
// Normal case - find repo root via git
var err error
repoRoot, err = git.GetMainRepoRoot()
if err != nil {
return nil, fmt.Errorf("cannot determine repository root: %w", err)
}
}
// 4. Get CWD's repo root (may differ from RepoRoot)
cwdRepoRoot := git.GetRepoRoot() // Returns "" if not in git repo
// 5. Check worktree status
isWorktree := git.IsWorktree()
return &RepoContext{
BeadsDir: beadsDir,
RepoRoot: repoRoot,
CWDRepoRoot: cwdRepoRoot,
IsRedirected: redirectInfo.IsRedirected,
IsWorktree: isWorktree,
}, nil
}
// GitCmd creates an exec.Cmd configured to run git in the beads repository.
//
// This method sets cmd.Dir to RepoRoot, ensuring git commands operate on
// the correct repository regardless of CWD.
//
// Security: Git hooks and templates are disabled to prevent code execution
// in potentially malicious repositories (SEC-001, SEC-002).
//
// Pattern:
//
// cmd := rc.GitCmd(ctx, "add", ".beads/")
// output, err := cmd.Output()
//
// Equivalent to running: cd $RepoRoot && git add .beads/
func (rc *RepoContext) GitCmd(ctx context.Context, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = rc.RepoRoot
// Security: Disable git hooks and templates to prevent code execution
// in potentially malicious repositories (SEC-001, SEC-002)
cmd.Env = append(os.Environ(),
"GIT_HOOKS_PATH=", // Disable hooks
"GIT_TEMPLATE_DIR=", // Disable templates
)
return cmd
}
// GitCmdCWD creates an exec.Cmd configured to run git in the user's working repository.
//
// Use this for git commands that should reflect the user's current context,
// such as showing status or checking for uncommitted changes in their working repo.
//
// If CWD is not in a git repository, cmd.Dir is left unset (uses process CWD).
func (rc *RepoContext) GitCmdCWD(ctx context.Context, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
if rc.CWDRepoRoot != "" {
cmd.Dir = rc.CWDRepoRoot
}
return cmd
}
// RelPath returns the given absolute path relative to the beads repository root.
//
// Useful for displaying paths to users in a consistent, repo-relative format.
// Returns an error if the path is not within the repository.
func (rc *RepoContext) RelPath(absPath string) (string, error) {
return filepath.Rel(rc.RepoRoot, absPath)
}
// ResetCaches clears the cached RepoContext, forcing re-resolution on next call.
//
// This is intended for tests that need to change directory or BEADS_DIR
// between test cases. In production, the cache is safe because these
// values don't change during command execution.
//
// WARNING: Not thread-safe. Only call from single-threaded test contexts.
//
// Usage in tests:
//
// t.Cleanup(func() {
// beads.ResetCaches()
// git.ResetCaches()
// })
func ResetCaches() {
repoCtxOnce = sync.Once{}
repoCtx = nil
repoCtxErr = nil
}
// unsafePrefixes lists system directories that BEADS_DIR should never point to.
// This prevents path traversal attacks (SEC-003).
var unsafePrefixes = []string{
"/etc", "/usr", "/var", "/root", "/System", "/Library",
"/bin", "/sbin", "/opt", "/private",
}
// isPathInSafeBoundary validates that a path is not in sensitive system directories.
// Returns false if the path is in an unsafe location (SEC-003).
func isPathInSafeBoundary(path string) bool {
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
// Allow OS-designated temp directories (e.g., /var/folders on macOS)
// On macOS, TempDir() returns paths under /var/folders which symlinks to /private/var/folders
tempDir := os.TempDir()
resolvedTemp, _ := filepath.EvalSymlinks(tempDir)
resolvedPath, _ := filepath.EvalSymlinks(absPath)
if resolvedTemp != "" && strings.HasPrefix(resolvedPath, resolvedTemp) {
return true
}
// Also check unresolved paths (in case symlink resolution fails)
if strings.HasPrefix(absPath, tempDir) {
return true
}
for _, prefix := range unsafePrefixes {
if strings.HasPrefix(absPath, prefix+"/") || absPath == prefix {
return false
}
}
// Also reject other users' home directories
homeDir, _ := os.UserHomeDir()
if strings.HasPrefix(absPath, "/Users/") || strings.HasPrefix(absPath, "/home/") {
if homeDir != "" && !strings.HasPrefix(absPath, homeDir) {
return false
}
}
return true
}
// GetRepoContextForWorkspace returns a fresh RepoContext for a specific workspace.
//
// Unlike GetRepoContext(), this function:
// - Does NOT cache results (daemon handles multiple workspaces)
// - Does NOT respect BEADS_DIR (workspace path is explicit)
// - Resolves worktree relationships correctly
//
// This is designed for long-running processes like the daemon that need to handle
// multiple workspaces or detect context changes (DMN-001).
//
// The function temporarily changes to the workspace directory to resolve paths,
// then restores the original directory.
func GetRepoContextForWorkspace(workspacePath string) (*RepoContext, error) {
// Normalize workspace path
absWorkspace, err := filepath.Abs(workspacePath)
if err != nil {
return nil, fmt.Errorf("cannot resolve workspace path %s: %w", workspacePath, err)
}
// Change to workspace directory temporarily
originalDir, err := os.Getwd()
if err != nil {
return nil, err
}
defer func() { _ = os.Chdir(originalDir) }()
if err := os.Chdir(absWorkspace); err != nil {
return nil, fmt.Errorf("cannot access workspace %s: %w", absWorkspace, err)
}
// Clear git caches for fresh resolution
git.ResetCaches()
// Build context fresh, specifically for this workspace (ignores BEADS_DIR)
return buildRepoContextForWorkspace(absWorkspace)
}
// buildRepoContextForWorkspace constructs RepoContext for a specific workspace.
// Unlike buildRepoContext(), this ignores BEADS_DIR env var since the workspace
// path is explicitly provided (used by daemon).
func buildRepoContextForWorkspace(workspacePath string) (*RepoContext, error) {
// 1. Determine if we're in a worktree and find the main repo root
var repoRoot string
var isWorktree bool
if git.IsWorktree() {
isWorktree = true
var err error
repoRoot, err = git.GetMainRepoRoot()
if err != nil {
return nil, fmt.Errorf("cannot determine main repository root: %w", err)
}
} else {
isWorktree = false
repoRoot = git.GetRepoRoot()
if repoRoot == "" {
return nil, fmt.Errorf("workspace %s is not in a git repository", workspacePath)
}
}
// 2. Find .beads directory in the appropriate location
beadsDir := filepath.Join(repoRoot, ".beads")
// Check if .beads exists
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return nil, fmt.Errorf("no .beads directory found at %s", beadsDir)
}
// 3. Follow redirect if present
beadsDir = FollowRedirect(beadsDir)
// 4. Security: Validate path boundary (SEC-003)
if !isPathInSafeBoundary(beadsDir) {
return nil, fmt.Errorf("beads directory in unsafe location: %s", beadsDir)
}
// 5. Validate directory contains actual project files
if !hasBeadsProjectFiles(beadsDir) {
return nil, fmt.Errorf("beads directory missing required files: %s", beadsDir)
}
// 6. Get CWD's repo root (same as workspace in this case)
cwdRepoRoot := git.GetRepoRoot()
return &RepoContext{
BeadsDir: beadsDir,
RepoRoot: repoRoot,
CWDRepoRoot: cwdRepoRoot,
IsRedirected: false, // Workspace-specific context is never "redirected"
IsWorktree: isWorktree,
}, nil
}
// Validate checks if the cached context is still valid.
//
// Returns an error if BeadsDir or RepoRoot no longer exist. This is useful
// for long-running processes that need to detect when context becomes stale (DMN-002).
func (rc *RepoContext) Validate() error {
if _, err := os.Stat(rc.BeadsDir); os.IsNotExist(err) {
return fmt.Errorf("BeadsDir no longer exists: %s", rc.BeadsDir)
}
if _, err := os.Stat(rc.RepoRoot); os.IsNotExist(err) {
return fmt.Errorf("RepoRoot no longer exists: %s", rc.RepoRoot)
}
return nil
}
+519
View File
@@ -0,0 +1,519 @@
package beads
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/git"
)
// TestGetRepoContextForWorkspace_NormalRepo tests context resolution for a normal git repository
func TestGetRepoContextForWorkspace_NormalRepo(t *testing.T) {
// Create a temporary git repo
tmpDir := t.TempDir()
if err := initGitRepo(tmpDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Create .beads directory with required files
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create a database file (required for hasBeadsProjectFiles)
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
// Reset caches before test
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// Get context for the workspace
rc, err := GetRepoContextForWorkspace(tmpDir)
if err != nil {
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
}
// Verify context fields
if rc.RepoRoot != resolveSymlinks(tmpDir) {
t.Errorf("RepoRoot mismatch: expected %s, got %s", resolveSymlinks(tmpDir), rc.RepoRoot)
}
if rc.BeadsDir != resolveSymlinks(beadsDir) {
t.Errorf("BeadsDir mismatch: expected %s, got %s", resolveSymlinks(beadsDir), rc.BeadsDir)
}
if rc.IsRedirected {
t.Error("IsRedirected should be false for workspace-specific context")
}
if rc.IsWorktree {
t.Error("IsWorktree should be false for main repo")
}
}
// TestGetRepoContextForWorkspace_IgnoresBEADS_DIR verifies that workspace-specific
// context resolution ignores the BEADS_DIR environment variable (DMN-001)
func TestGetRepoContextForWorkspace_IgnoresBEADS_DIR(t *testing.T) {
// Save original env var
originalBeadsDir := os.Getenv("BEADS_DIR")
t.Cleanup(func() {
if originalBeadsDir != "" {
os.Setenv("BEADS_DIR", originalBeadsDir)
} else {
os.Unsetenv("BEADS_DIR")
}
ResetCaches()
git.ResetCaches()
})
// Create two separate repos: repo1 and repo2
tmpDir := t.TempDir()
repo1 := filepath.Join(tmpDir, "repo1")
repo2 := filepath.Join(tmpDir, "repo2")
for _, repo := range []string{repo1, repo2} {
if err := os.MkdirAll(repo, 0750); err != nil {
t.Fatalf("failed to create repo dir: %v", err)
}
if err := initGitRepo(repo); err != nil {
t.Fatalf("failed to init git repo in %s: %v", repo, err)
}
beadsDir := filepath.Join(repo, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads in %s: %v", repo, err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
}
// Set BEADS_DIR to repo2's .beads
os.Setenv("BEADS_DIR", filepath.Join(repo2, ".beads"))
// Get context for repo1 - should find repo1's .beads, NOT repo2's
rc, err := GetRepoContextForWorkspace(repo1)
if err != nil {
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
}
// Verify we got repo1, not repo2
expectedBeadsDir := resolveSymlinks(filepath.Join(repo1, ".beads"))
if rc.BeadsDir != expectedBeadsDir {
t.Errorf("BEADS_DIR was not ignored: expected %s, got %s", expectedBeadsDir, rc.BeadsDir)
}
expectedRepoRoot := resolveSymlinks(repo1)
if rc.RepoRoot != expectedRepoRoot {
t.Errorf("RepoRoot mismatch: expected %s, got %s", expectedRepoRoot, rc.RepoRoot)
}
}
// TestGetRepoContextForWorkspace_NonexistentPath tests handling of invalid workspace paths
func TestGetRepoContextForWorkspace_NonexistentPath(t *testing.T) {
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
_, err := GetRepoContextForWorkspace("/nonexistent/path/that/does/not/exist")
if err == nil {
t.Error("expected error for nonexistent workspace path")
}
}
// TestGetRepoContextForWorkspace_NonGitDirectory tests handling of non-git directories
func TestGetRepoContextForWorkspace_NonGitDirectory(t *testing.T) {
tmpDir := t.TempDir()
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// Don't initialize git - just a plain directory
_, err := GetRepoContextForWorkspace(tmpDir)
if err == nil {
t.Error("expected error for non-git directory")
}
}
// TestGetRepoContextForWorkspace_MissingBeadsDir tests error when .beads doesn't exist
func TestGetRepoContextForWorkspace_MissingBeadsDir(t *testing.T) {
tmpDir := t.TempDir()
if err := initGitRepo(tmpDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// No .beads directory created
_, err := GetRepoContextForWorkspace(tmpDir)
if err == nil {
t.Error("expected error when .beads directory is missing")
}
}
// TestRepoContext_Validate tests the Validate method for detecting stale contexts
func TestRepoContext_Validate(t *testing.T) {
tmpDir := t.TempDir()
if err := initGitRepo(tmpDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// Get initial context
rc, err := GetRepoContextForWorkspace(tmpDir)
if err != nil {
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
}
// Validate should pass initially
if err := rc.Validate(); err != nil {
t.Errorf("Validate should pass for fresh context: %v", err)
}
// Remove the .beads directory to make context stale
if err := os.RemoveAll(beadsDir); err != nil {
t.Fatalf("failed to remove .beads: %v", err)
}
// Validate should now fail (stale context)
if err := rc.Validate(); err == nil {
t.Error("Validate should fail when BeadsDir no longer exists")
}
}
// TestRepoContext_Validate_RepoRootRemoved tests Validate when repo root is removed
func TestRepoContext_Validate_RepoRootRemoved(t *testing.T) {
// Create repo inside a removable parent
parentDir := t.TempDir()
repoDir := filepath.Join(parentDir, "removable-repo")
if err := os.MkdirAll(repoDir, 0750); err != nil {
t.Fatalf("failed to create repo dir: %v", err)
}
if err := initGitRepo(repoDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
beadsDir := filepath.Join(repoDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// Get context
rc, err := GetRepoContextForWorkspace(repoDir)
if err != nil {
t.Fatalf("GetRepoContextForWorkspace failed: %v", err)
}
// Validate should pass
if err := rc.Validate(); err != nil {
t.Errorf("Validate should pass for fresh context: %v", err)
}
// Remove the entire repo
if err := os.RemoveAll(repoDir); err != nil {
t.Fatalf("failed to remove repo: %v", err)
}
// Validate should now fail (both BeadsDir and RepoRoot are gone)
if err := rc.Validate(); err == nil {
t.Error("Validate should fail when RepoRoot no longer exists")
}
}
// TestGetRepoContextForWorkspace_CacheReset verifies that multiple calls return fresh contexts
func TestGetRepoContextForWorkspace_CacheReset(t *testing.T) {
tmpDir := t.TempDir()
if err := initGitRepo(tmpDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// First call
rc1, err := GetRepoContextForWorkspace(tmpDir)
if err != nil {
t.Fatalf("first GetRepoContextForWorkspace failed: %v", err)
}
// Second call - should still work (fresh resolution)
rc2, err := GetRepoContextForWorkspace(tmpDir)
if err != nil {
t.Fatalf("second GetRepoContextForWorkspace failed: %v", err)
}
// Both should return valid contexts
if rc1.BeadsDir != rc2.BeadsDir {
t.Errorf("BeadsDir mismatch between calls: %s vs %s", rc1.BeadsDir, rc2.BeadsDir)
}
}
// TestGetRepoContextForWorkspace_RelativePath tests handling of relative workspace paths
func TestGetRepoContextForWorkspace_RelativePath(t *testing.T) {
// Get original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
t.Cleanup(func() {
os.Chdir(originalWd)
ResetCaches()
git.ResetCaches()
})
tmpDir := t.TempDir()
if err := initGitRepo(tmpDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
// Change to parent directory
parentDir := filepath.Dir(tmpDir)
os.Chdir(parentDir)
// Use relative path
relPath := filepath.Base(tmpDir)
rc, err := GetRepoContextForWorkspace(relPath)
if err != nil {
t.Fatalf("GetRepoContextForWorkspace with relative path failed: %v", err)
}
// Verify we got the correct absolute path
expectedBeadsDir := resolveSymlinks(beadsDir)
if rc.BeadsDir != expectedBeadsDir {
t.Errorf("BeadsDir mismatch: expected %s, got %s", expectedBeadsDir, rc.BeadsDir)
}
}
// initGitRepo initializes a git repository in the given directory
func initGitRepo(dir string) error {
cmd := exec.Command("git", "init")
cmd.Dir = dir
// Suppress git output
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run()
}
// TestIsPathInSafeBoundary tests security boundary validation (TS-SEC-003)
// This ensures redirect paths cannot escape to sensitive system directories.
func TestIsPathInSafeBoundary(t *testing.T) {
t.Cleanup(func() {
ResetCaches()
git.ResetCaches()
})
// Get user home directory for test comparisons
homeDir, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get home directory: %v", err)
}
tests := []struct {
name string
path string
expected bool
}{
// Unsafe system directories - should be rejected
{"system /etc", "/etc/beads", false},
{"system /usr", "/usr/local/beads", false},
{"system /var", "/var/lib/beads", false},
{"system /root", "/root/.beads", false},
{"system /bin", "/bin/.beads", false},
{"system /sbin", "/sbin/.beads", false},
{"system /opt", "/opt/beads", false},
{"macOS /System", "/System/Library/.beads", false},
{"macOS /Library", "/Library/Application Support/.beads", false},
{"macOS /private", "/private/etc/.beads", false},
// Safe paths - should be accepted
{"user home directory", filepath.Join(homeDir, "projects/.beads"), true},
{"temp directory", os.TempDir(), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isPathInSafeBoundary(tt.path)
if result != tt.expected {
t.Errorf("isPathInSafeBoundary(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
}
// TestGetRepoContextForWorkspace_RedirectToUnsafeLocation tests that redirects
// to unsafe locations are rejected (TS-SEC-003 integration test).
func TestGetRepoContextForWorkspace_RedirectToUnsafeLocation(t *testing.T) {
// Save original env
originalBeadsDir := os.Getenv("BEADS_DIR")
t.Cleanup(func() {
if originalBeadsDir != "" {
os.Setenv("BEADS_DIR", originalBeadsDir)
} else {
os.Unsetenv("BEADS_DIR")
}
ResetCaches()
git.ResetCaches()
})
// Create a temporary git repo
tmpDir := t.TempDir()
if err := initGitRepo(tmpDir); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Create .beads directory with a redirect file pointing outside safe boundary
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Write redirect pointing to /etc (unsafe location)
// Note: FollowRedirect won't follow non-existent paths, but if /etc/.beads existed
// and contained beads files, this security check would catch it
redirectFile := filepath.Join(beadsDir, "redirect")
if err := os.WriteFile(redirectFile, []byte("/etc/.beads\n"), 0644); err != nil {
t.Fatalf("failed to write redirect file: %v", err)
}
// Since /etc/.beads doesn't exist, FollowRedirect returns original path
// So create a valid beads.db in the local .beads to get past initial validation,
// then test the boundary check directly via the isPathInSafeBoundary function
if err := os.WriteFile(filepath.Join(beadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db: %v", err)
}
// GetRepoContextForWorkspace should succeed because FollowRedirect
// returns the original safe path when target doesn't exist
rc, err := GetRepoContextForWorkspace(tmpDir)
if err != nil {
// This is expected if the implementation catches the redirect attempt
// Even though target doesn't exist, the test verifies the security boundary
t.Logf("GetRepoContextForWorkspace correctly rejected unsafe redirect: %v", err)
return
}
// If we get here, the context was created with the safe local path
// (because /etc/.beads doesn't exist and FollowRedirect fell back)
// Verify it's using the local beads dir, not the unsafe redirect target
expectedBeadsDir := resolveSymlinks(beadsDir)
if rc.BeadsDir != expectedBeadsDir {
t.Errorf("BeadsDir = %q, want safe local path %q", rc.BeadsDir, expectedBeadsDir)
}
}
// TestGetRepoContextForWorkspace_RedirectWithinRepo tests that redirects
// staying within the same repo or user's directories are allowed.
func TestGetRepoContextForWorkspace_RedirectWithinRepo(t *testing.T) {
originalBeadsDir := os.Getenv("BEADS_DIR")
t.Cleanup(func() {
if originalBeadsDir != "" {
os.Setenv("BEADS_DIR", originalBeadsDir)
} else {
os.Unsetenv("BEADS_DIR")
}
ResetCaches()
git.ResetCaches()
})
// Create two git repos in temp directory
tmpDir := t.TempDir()
repo1 := filepath.Join(tmpDir, "repo1")
repo2 := filepath.Join(tmpDir, "repo2")
for _, repo := range []string{repo1, repo2} {
if err := os.MkdirAll(repo, 0750); err != nil {
t.Fatalf("failed to create repo dir: %v", err)
}
if err := initGitRepo(repo); err != nil {
t.Fatalf("failed to init git repo in %s: %v", repo, err)
}
}
// Create .beads with actual files in repo2
beadsDir2 := filepath.Join(repo2, ".beads")
if err := os.MkdirAll(beadsDir2, 0750); err != nil {
t.Fatalf("failed to create .beads in repo2: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir2, "beads.db"), []byte{}, 0644); err != nil {
t.Fatalf("failed to create beads.db in repo2: %v", err)
}
// Create .beads in repo1 with redirect to repo2
beadsDir1 := filepath.Join(repo1, ".beads")
if err := os.MkdirAll(beadsDir1, 0750); err != nil {
t.Fatalf("failed to create .beads in repo1: %v", err)
}
redirectFile := filepath.Join(beadsDir1, "redirect")
if err := os.WriteFile(redirectFile, []byte(beadsDir2+"\n"), 0644); err != nil {
t.Fatalf("failed to write redirect file: %v", err)
}
// GetRepoContextForWorkspace for repo1 should work
// Note: GetRepoContextForWorkspace ignores BEADS_DIR and looks for .beads in workspace
// But it does follow the redirect file in the local .beads
rc, err := GetRepoContextForWorkspace(repo1)
if err != nil {
t.Fatalf("GetRepoContextForWorkspace failed for safe redirect: %v", err)
}
// Verify the redirect was followed to repo2's .beads
expectedBeadsDir := resolveSymlinks(beadsDir2)
if rc.BeadsDir != expectedBeadsDir {
t.Errorf("BeadsDir = %q, want redirected path %q", rc.BeadsDir, expectedBeadsDir)
}
}
// resolveSymlinks resolves symlinks and returns the canonical path
// This handles macOS temp directory symlinks (/var -> /private/var)
func resolveSymlinks(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return path
}
return resolved
}