Skip to content

Commit 791e4e0

Browse files
committed
feat: Include location in the result output
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 58ace24 commit 791e4e0

File tree

5 files changed

+181
-16
lines changed

5 files changed

+181
-16
lines changed

output/result.go

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,97 @@
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+
const (
25+
msgField = "msg"
26+
locField = "_loc"
27+
)
28+
29+
var reservedFields = []string{
30+
msgField,
31+
locField,
32+
}
33+
1534
// NewResult creates a new result. An error is returned if the
1635
// metadata could not be successfully parsed.
1736
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)
37+
if metadata == nil {
38+
return Result{}, fmt.Errorf("metadata must be supplied")
2039
}
21-
if _, ok := metadata["msg"].(string); !ok {
22-
return Result{}, fmt.Errorf("msg field must be string: %v", metadata)
40+
msg, ok := lookup[string](metadata, msgField)
41+
if !ok {
42+
return Result{}, fmt.Errorf("%q field must be present and a string", msgField)
2343
}
2444

2545
result := Result{
26-
Message: metadata["msg"].(string),
46+
Message: msg,
2747
Metadata: make(map[string]any),
2848
}
2949

50+
if loc, ok := metadata[locField]; ok {
51+
if l := parseLocation(loc); l != nil {
52+
result.Location = l
53+
}
54+
}
55+
3056
for k, v := range metadata {
31-
if k != "msg" {
57+
if !slices.Contains(reservedFields, k) {
3258
result.Metadata[k] = v
3359
}
3460
}
3561

3662
return result, nil
3763
}
3864

65+
func parseLocation(location any) *Location {
66+
loc, ok := location.(map[string]any)
67+
if !ok {
68+
return nil
69+
}
70+
71+
l := &Location{}
72+
if file, ok := lookup[string](loc, "file"); ok {
73+
l.File = file
74+
}
75+
if line, ok := lookup[json.Number](loc, "line"); ok {
76+
l.Line = line
77+
}
78+
79+
if l.File == "" && l.Line.String() == "" {
80+
return nil
81+
}
82+
83+
return l
84+
}
85+
86+
func lookup[T any](m map[string]any, k string) (value T, ok bool) {
87+
x, ok := m[k]
88+
if !ok {
89+
return
90+
}
91+
value, ok = x.(T)
92+
return
93+
}
94+
3995
// Passed returns true if the result did not fail a policy.
4096
func (r Result) Passed() bool {
4197
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
}

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)