Skip to content

Commit 00b02b2

Browse files
authored
feat: Include location in the result output (#1196)
This will allow outputters to point to the specific location that caused the result to be created. Signed-off-by: James Alseth <[email protected]>
1 parent 5242185 commit 00b02b2

File tree

6 files changed

+185
-17
lines changed

6 files changed

+185
-17
lines changed

output/result.go

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,101 @@
11
package output
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"slices"
67
)
78

89
// Result describes the result of a single rule evaluation.
910
type Result struct {
1011
Message string `json:"msg"`
12+
Location *Location `json:"loc,omitempty"`
1113
Metadata map[string]any `json:"metadata,omitempty"`
1214
Outputs []string `json:"outputs,omitempty"`
1315
}
1416

17+
// Location describes the origin location in the configuration file that
18+
// caused the result to be produced.
19+
type Location struct {
20+
File string `json:"file,omitempty"`
21+
Line json.Number `json:"line,omitempty"`
22+
}
23+
24+
func (l Location) String() string {
25+
return fmt.Sprintf("%s L%s", l.File, l.Line.String())
26+
}
27+
28+
const (
29+
msgField = "msg"
30+
locField = "_loc"
31+
)
32+
33+
var reservedFields = []string{
34+
msgField,
35+
locField,
36+
}
37+
1538
// NewResult creates a new result. An error is returned if the
1639
// metadata could not be successfully parsed.
1740
func NewResult(metadata map[string]any) (Result, error) {
18-
if _, ok := metadata["msg"]; !ok {
19-
return Result{}, fmt.Errorf("rule missing msg field: %v", metadata)
41+
if metadata == nil {
42+
return Result{}, fmt.Errorf("metadata must be supplied")
2043
}
21-
if _, ok := metadata["msg"].(string); !ok {
22-
return Result{}, fmt.Errorf("msg field must be string: %v", metadata)
44+
msg, ok := lookup[string](metadata, msgField)
45+
if !ok {
46+
return Result{}, fmt.Errorf("%q field must be present and a string", msgField)
2347
}
2448

2549
result := Result{
26-
Message: metadata["msg"].(string),
50+
Message: msg,
2751
Metadata: make(map[string]any),
2852
}
2953

54+
if loc, ok := metadata[locField]; ok {
55+
if l := parseLocation(loc); l != nil {
56+
result.Location = l
57+
}
58+
}
59+
3060
for k, v := range metadata {
31-
if k != "msg" {
61+
if !slices.Contains(reservedFields, k) {
3262
result.Metadata[k] = v
3363
}
3464
}
3565

3666
return result, nil
3767
}
3868

69+
func parseLocation(location any) *Location {
70+
loc, ok := location.(map[string]any)
71+
if !ok {
72+
return nil
73+
}
74+
75+
l := &Location{}
76+
if file, ok := lookup[string](loc, "file"); ok {
77+
l.File = file
78+
}
79+
if line, ok := lookup[json.Number](loc, "line"); ok {
80+
l.Line = line
81+
}
82+
83+
if l.File == "" && l.Line.String() == "" {
84+
return nil
85+
}
86+
87+
return l
88+
}
89+
90+
func lookup[T any](m map[string]any, k string) (value T, ok bool) {
91+
x, ok := m[k]
92+
if !ok {
93+
return
94+
}
95+
value, ok = x.(T)
96+
return
97+
}
98+
3999
// Passed returns true if the result did not fail a policy.
40100
func (r Result) Passed() bool {
41101
return r.Message == ""

output/result_test.go

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,86 @@
11
package output
22

33
import (
4+
"encoding/json"
5+
"strconv"
46
"testing"
7+
8+
"github.com/google/go-cmp/cmp"
59
)
610

11+
func TestNewResult(t *testing.T) {
12+
t.Parallel()
13+
14+
tests := []struct {
15+
desc string
16+
input map[string]any
17+
want Result
18+
wantErr bool
19+
}{
20+
{
21+
desc: "no metadata is an error",
22+
wantErr: true,
23+
},
24+
{
25+
desc: "missing msg is an error",
26+
input: map[string]any{},
27+
wantErr: true,
28+
},
29+
{
30+
desc: "non-string msg is an error",
31+
input: map[string]any{"msg": 123},
32+
wantErr: true,
33+
},
34+
{
35+
desc: "msg only",
36+
input: map[string]any{"msg": "message"},
37+
want: Result{
38+
Message: "message",
39+
Metadata: make(map[string]any),
40+
},
41+
},
42+
{
43+
desc: "msg with location and metadata",
44+
input: map[string]any{
45+
"msg": "message",
46+
"_loc": map[string]any{
47+
"file": "some_file.json",
48+
"line": json.Number("123"),
49+
},
50+
"other": "metadata",
51+
},
52+
want: Result{
53+
Message: "message",
54+
Location: &Location{
55+
File: "some_file.json",
56+
Line: json.Number("123"),
57+
},
58+
Metadata: map[string]any{"other": "metadata"},
59+
},
60+
},
61+
}
62+
63+
for _, tc := range tests {
64+
t.Run(tc.desc, func(t *testing.T) {
65+
t.Parallel()
66+
67+
got, err := NewResult(tc.input)
68+
if gotErr := err != nil; gotErr != tc.wantErr {
69+
t.Fatalf("NewResult() error = %v, want %v", err, tc.wantErr)
70+
}
71+
if tc.wantErr {
72+
return
73+
}
74+
if diff := cmp.Diff(got, tc.want); diff != "" {
75+
t.Errorf("NewResult() produced an unexpected diff:\n%s", diff)
76+
}
77+
})
78+
}
79+
}
80+
781
func TestCheckResultsHelpers(t *testing.T) {
82+
t.Parallel()
83+
884
tests := []struct {
985
desc string
1086
results CheckResults
@@ -70,6 +146,8 @@ func TestCheckResultsHelpers(t *testing.T) {
70146

71147
for _, tc := range tests {
72148
t.Run(tc.desc, func(t *testing.T) {
149+
t.Parallel()
150+
73151
if gotFailure := tc.results.HasFailure(); gotFailure != tc.wantHasFailure {
74152
t.Errorf("HasFailure() = %v, want %v", gotFailure, tc.wantHasFailure)
75153
}
@@ -87,6 +165,8 @@ func TestCheckResultsHelpers(t *testing.T) {
87165
}
88166

89167
func TestExitCode(t *testing.T) {
168+
t.Parallel()
169+
90170
warning := CheckResult{
91171
Warnings: []Result{{}},
92172
}
@@ -110,15 +190,21 @@ func TestExitCode(t *testing.T) {
110190
{results: CheckResults{warning, failure}, expected: 1},
111191
}
112192

113-
for _, testCase := range testCases {
114-
actual := testCase.results.ExitCode()
115-
if actual != testCase.expected {
116-
t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
117-
}
193+
for i, testCase := range testCases {
194+
t.Run(strconv.Itoa(i), func(t *testing.T) {
195+
t.Parallel()
196+
197+
actual := testCase.results.ExitCode()
198+
if actual != testCase.expected {
199+
t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
200+
}
201+
})
118202
}
119203
}
120204

121205
func TestExitCodeFailOnWarn(t *testing.T) {
206+
t.Parallel()
207+
122208
warning := CheckResult{
123209
Warnings: []Result{{}},
124210
}
@@ -137,10 +223,14 @@ func TestExitCodeFailOnWarn(t *testing.T) {
137223
{results: CheckResults{warning, failure}, expected: 2},
138224
}
139225

140-
for _, testCase := range testCases {
141-
actual := testCase.results.ExitCodeFailOnWarn()
142-
if actual != testCase.expected {
143-
t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
144-
}
226+
for i, testCase := range testCases {
227+
t.Run(strconv.Itoa(i), func(t *testing.T) {
228+
t.Parallel()
229+
230+
actual := testCase.results.ExitCodeFailOnWarn()
231+
if actual != testCase.expected {
232+
t.Errorf("Unexpected error code. expected %v, actual %v", testCase.expected, actual)
233+
}
234+
})
145235
}
146236
}

policy/engine.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,6 @@ func (e *Engine) check(ctx context.Context, path string, config any, namespace s
391391
checkResult.Failures = append(checkResult.Failures, failures...)
392392
checkResult.Warnings = append(checkResult.Warnings, warnings...)
393393
checkResult.Exceptions = append(checkResult.Exceptions, exceptions...)
394-
395394
checkResult.Queries = append(checkResult.Queries, exceptionQueryResult, ruleQueryResult)
396395
}
397396

tests/source-location/data.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
loc:
2+
file: test.txt
3+
line: 123
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package main
2+
3+
deny contains {
4+
"msg": "test",
5+
"other": "metadata",
6+
"_loc": input.loc
7+
}

tests/source-location/test.bats

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bats
2+
3+
@test "Location is included in results" {
4+
run $CONFTEST test -o json data.yaml
5+
6+
echo $output
7+
[[ "$output" =~ "\"file\": \"test.txt\"" ]]
8+
[[ "$output" =~ "\"line\": 123" ]]
9+
}

0 commit comments

Comments
 (0)