Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pkg/parser/github_urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func parseActionsRunURL(parsedURL *url.URL, host, owner, repo string, parts []st
func parseNumberedURL(host, owner, repo string, urlType GitHubURLType, label, numberText string) (*GitHubURLComponents, error) {
number, err := strconv.ParseInt(numberText, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid %s number: %s", label, numberText)
return nil, fmt.Errorf("invalid %s number %s: %w", label, numberText, err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c9a688e. Added TestParseGitHubURL_NumErrorWrapping in pkg/parser/github_urls_test.go with four table-driven cases covering numbered URLs (PR, issue), run URL, and job URL, each asserting errors.As(err, &(*strconv.NumError)(nil)) is reachable.

}
return &GitHubURLComponents{
Host: host,
Expand Down Expand Up @@ -185,7 +185,7 @@ func parseRunURL(host, owner, repo string, parts []string) (*GitHubURLComponents

runID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid run ID: %s", parts[0])
return nil, fmt.Errorf("invalid run ID %s: %w", parts[0], err)
}

components := &GitHubURLComponents{
Expand All @@ -200,7 +200,7 @@ func parseRunURL(host, owner, repo string, parts []string) (*GitHubURLComponents
if len(parts) >= 3 && parts[1] == "job" {
jobID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid job ID: %s", parts[2])
return nil, fmt.Errorf("invalid job ID %s: %w", parts[2], err)
}
components.JobID = jobID
}
Expand Down
40 changes: 40 additions & 0 deletions pkg/parser/github_urls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package parser

import (
"errors"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -966,3 +967,42 @@ func TestParseGitHubURL_AdditionalEdgeCases(t *testing.T) {
})
}
}

// TestParseGitHubURL_NumErrorWrapping verifies that malformed numeric IDs in URLs
// wrap the underlying *strconv.NumError so callers can inspect it via errors.As.
func TestParseGitHubURL_NumErrorWrapping(t *testing.T) {
tests := []struct {
name string
url string
}{
{
name: "numbered URL - invalid PR number",
url: "https://github.com/owner/repo/pull/abc",
},
{
name: "numbered URL - invalid issue number",
url: "https://github.com/owner/repo/issues/abc",
},
{
name: "run URL - invalid run ID",
url: "https://github.com/owner/repo/actions/runs/notanumber",
},
{
name: "run URL - invalid job ID",
url: "https://github.com/owner/repo/actions/runs/12345/job/notanumber",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseGitHubURL(tt.url)
if err == nil {
t.Fatal("expected error, got nil")
}
var numErr *strconv.NumError
if !errors.As(err, &numErr) {
t.Errorf("errors.As(*strconv.NumError) = false; want true so NumError is reachable via %v", err)
}
})
}
}
13 changes: 13 additions & 0 deletions pkg/parser/path_rel_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package parser

// pathRelError is returned when filepath.Rel fails during a security path check.
// Its Error() method omits the internal absolute paths from the filepath.Rel
// diagnostic so they do not leak into user-facing error messages, while Unwrap()
// preserves the upstream error for errors.Is / errors.As traversal.
type pathRelError struct {
msg string
err error
}

func (e *pathRelError) Error() string { return e.msg }
func (e *pathRelError) Unwrap() error { return e.err }
11 changes: 9 additions & 2 deletions pkg/parser/remote_fetch_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,15 @@ func ResolveIncludePath(filePath, baseDir string, cache *ImportCache) (string, e
normalizedSecurityBase := filepath.Clean(securityBase)
normalizedFullPath := filepath.Clean(fullPath)

relativePath, err := filepath.Rel(normalizedSecurityBase, normalizedFullPath)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
relativePath, relErr := filepath.Rel(normalizedSecurityBase, normalizedFullPath)
if relErr != nil {
allowedFolder := filepath.Base(normalizedSecurityBase)
return "", &pathRelError{
msg: fmt.Sprintf("security: path %s must be within %s folder", filePath, allowedFolder),
err: relErr,
}
}
if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
allowedFolder := filepath.Base(normalizedSecurityBase)
return "", fmt.Errorf("security: path %s must be within %s folder (resolves to: %s)", filePath, allowedFolder, relativePath)
}
Expand Down
12 changes: 10 additions & 2 deletions pkg/parser/remote_resolve_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,16 @@ func resolveAndValidateLocalIncludePath(filePath, resolveBase, securityBase stri
fullPath := filepath.Join(resolveBase, filePath)
normalizedSecurityBase := filepath.Clean(securityBase)
normalizedFullPath := filepath.Clean(fullPath)
relativePath, err := filepath.Rel(normalizedSecurityBase, normalizedFullPath)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
relativePath, relErr := filepath.Rel(normalizedSecurityBase, normalizedFullPath)
if relErr != nil {
allowedFolder := filepath.Base(normalizedSecurityBase)
remoteLog.Printf("Security: Path escapes allowed folder: %s (rel error: %v)", filePath, relErr)
return "", &pathRelError{
msg: fmt.Sprintf("security: path %s must be within %s folder", filePath, allowedFolder),
err: relErr,
}
}
if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
allowedFolder := filepath.Base(normalizedSecurityBase)
remoteLog.Printf("Security: Path escapes allowed folder: %s (resolves to: %s)", filePath, relativePath)
return "", fmt.Errorf("security: path %s must be within %s folder (resolves to: %s)", filePath, allowedFolder, relativePath)
Expand Down
5 changes: 4 additions & 1 deletion pkg/parser/schedule_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,10 @@ func (p *ScheduleParser) minimumIntervalTokenCount(hasWeekdaysSuffix bool) int {
func (p *ScheduleParser) parseLongIntervalParts() (int, string, error) {
intervalStr := p.tokens[1]
interval, err := strconv.Atoi(intervalStr)
if err != nil || interval < 1 {
if err != nil {
return 0, "", fmt.Errorf("invalid interval '%s', must be a positive integer: %w", intervalStr, err)
}
if interval < 1 {
return 0, "", fmt.Errorf("invalid interval '%s', must be a positive integer", intervalStr)
Comment on lines +231 to 235

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c9a688e. Added TestParseLongIntervalParts_NumErrorWrapping in pkg/parser/schedule_parser_test.go with three subtests: non-numeric interval asserts errors.As reaches *strconv.NumError; zero and negative intervals (value-range branch, no upstream error) assert it does not. Uses assert.ErrorAs/assert.NotErrorAs per testifylint convention.

}

Expand Down
36 changes: 36 additions & 0 deletions pkg/parser/schedule_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package parser

import (
"strconv"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -1096,6 +1097,41 @@ func TestParseSchedule(t *testing.T) {
}
}

// TestParseLongIntervalParts_NumErrorWrapping verifies the error-wrapping contract
// introduced in parseLongIntervalParts:
// - a non-numeric interval string wraps *strconv.NumError so callers can inspect it
// - zero and negative intervals use a fresh error (no upstream NumError to wrap)
func TestParseLongIntervalParts_NumErrorWrapping(t *testing.T) {
p := &ScheduleParser{}

t.Run("non-numeric interval wraps NumError", func(t *testing.T) {
p.tokens = []string{"every", "abc", "minutes"}
_, _, err := p.parseLongIntervalParts()
require.Error(t, err)
var numErr *strconv.NumError
assert.ErrorAs(t, err, &numErr,
"errors.As(*strconv.NumError) should be true for non-numeric interval")
})

t.Run("zero interval does not wrap NumError", func(t *testing.T) {
p.tokens = []string{"every", "0", "minutes"}
_, _, err := p.parseLongIntervalParts()
require.Error(t, err)
var numErr *strconv.NumError
assert.NotErrorAs(t, err, &numErr,
"errors.As(*strconv.NumError) should be false for out-of-range interval")
})

t.Run("negative interval does not wrap NumError", func(t *testing.T) {
p.tokens = []string{"every", "-5", "minutes"}
_, _, err := p.parseLongIntervalParts()
require.Error(t, err)
var numErr *strconv.NumError
assert.NotErrorAs(t, err, &numErr,
"errors.As(*strconv.NumError) should be false for negative interval")
})
}

// TestErrUnsupportedSyntaxIs verifies that all four "explicitly unsupported
// syntax" branches wrap ErrUnsupportedSyntax so that errors.Is works, and
// that an ordinary parse error (unrecognised keyword) does not.
Expand Down