Merge main into fix-ci
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getBdBinary returns the path to the bd binary to use for fix operations.
|
||||
@@ -47,3 +48,43 @@ func validateBeadsWorkspace(path string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeWorkspacePath resolves relPath within the workspace root and ensures it
|
||||
// cannot escape the workspace via path traversal.
|
||||
func safeWorkspacePath(root, relPath string) (string, error) {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid workspace path: %w", err)
|
||||
}
|
||||
|
||||
cleanRel := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(cleanRel) {
|
||||
return "", fmt.Errorf("expected relative path, got absolute: %s", relPath)
|
||||
}
|
||||
|
||||
joined := filepath.Join(absRoot, cleanRel)
|
||||
rel, err := filepath.Rel(absRoot, joined)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
||||
}
|
||||
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("path escapes workspace: %s", relPath)
|
||||
}
|
||||
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// isWithinWorkspace reports whether candidate resides within the workspace root.
|
||||
func isWithinWorkspace(root, candidate string) bool {
|
||||
cleanRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cleanCandidate := filepath.Clean(candidate)
|
||||
rel, err := filepath.Rel(cleanRoot, cleanCandidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || !(rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
|
||||
}
|
||||
|
||||
55
cmd/bd/doctor/fix/common_test.go
Normal file
55
cmd/bd/doctor/fix/common_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeWorkspacePath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
absEscape, _ := filepath.Abs(filepath.Join(root, "..", "escape"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
relPath string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal relative path",
|
||||
relPath: ".beads/issues.jsonl",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested relative path",
|
||||
relPath: filepath.Join(".beads", "nested", "file.txt"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "absolute path rejected",
|
||||
relPath: absEscape,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "path traversal rejected",
|
||||
relPath: filepath.Join("..", "escape"),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := safeWorkspacePath(root, tt.relPath)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("safeWorkspacePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err == nil {
|
||||
if !isWithinWorkspace(root, got) {
|
||||
t.Fatalf("resolved path %q not within workspace %q", got, root)
|
||||
}
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("resolved path is not absolute: %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user