diff --git a/cmd/notation/internal/display/metadata/text/verify_helper.go b/cmd/notation/internal/display/metadata/text/verify_helper.go index 499adcc80..1c52891e5 100644 --- a/cmd/notation/internal/display/metadata/text/verify_helper.go +++ b/cmd/notation/internal/display/metadata/text/verify_helper.go @@ -17,7 +17,9 @@ package text import ( "fmt" + "maps" "reflect" + "slices" "text/tabwriter" "github.com/notaryproject/notation-go" @@ -66,8 +68,8 @@ func printUserMetadataIfPresent(printer *output.Printer, outcome *notation.Verif func printUserMetadataMap(printer *output.Printer, metadata map[string]string) error { tw := tabwriter.NewWriter(printer, 0, 0, 3, ' ', 0) fmt.Fprintln(tw, "\nKEY\tVALUE\t") - for k, v := range metadata { - fmt.Fprintf(tw, "%v\t%v\t\n", k, v) + for _, k := range slices.Sorted(maps.Keys(metadata)) { + fmt.Fprintf(tw, "%v\t%v\t\n", k, metadata[k]) } return tw.Flush() } diff --git a/cmd/notation/internal/display/metadata/text/verify_helper_test.go b/cmd/notation/internal/display/metadata/text/verify_helper_test.go index 3cdbb048a..059df521d 100644 --- a/cmd/notation/internal/display/metadata/text/verify_helper_test.go +++ b/cmd/notation/internal/display/metadata/text/verify_helper_test.go @@ -16,6 +16,9 @@ package text import ( "bytes" "encoding/json" + "maps" + "slices" + "strings" "testing" "github.com/notaryproject/notation-core-go/signature" @@ -55,3 +58,69 @@ func TestPrintMetadataIfPresent(t *testing.T) { } }) } + +func TestUserMetadataIsSortedByKey(t *testing.T) { + annotations := map[string]string{ + "org.example.zone": "us-east", + "org.example.alpha": "1", + "io.wabbit.buildTime": "2024-01-01", + "io.wabbit.buildId": "123", + "com.example.team": "platform", + "a": "first", + "z": "last", + "m": "middle", + } + payloadBytes, err := json.Marshal(&envelope.Payload{ + TargetArtifact: ocispec.Descriptor{Annotations: annotations}, + }) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + outcome := ¬ation.VerificationOutcome{ + EnvelopeContent: &signature.EnvelopeContent{ + Payload: signature.Payload{Content: payloadBytes}, + }, + } + + want := slices.Sorted(maps.Keys(annotations)) + // Go randomizes map iteration order, so a single run can be sorted by + // chance. Render repeatedly to make the check meaningful. + for i := 0; i < 20; i++ { + buf := bytes.Buffer{} + h := NewVerifyHandler(output.NewPrinter(&buf, &buf)) + h.OnVerifySucceeded([]*notation.VerificationOutcome{outcome}, "localhost:5000/net-monitor@sha256:abcd") + if err := h.Render(); err != nil { + t.Fatalf("Render() error: %v", err) + } + got := metadataKeys(t, buf.String()) + if !slices.Equal(got, want) { + t.Fatalf("run %d: user metadata keys are not sorted: got %v, want %v", i, got, want) + } + } +} + +// metadataKeys returns the key column of the user metadata table printed by +// the verify handler. It fails the test when the table is missing so that an +// empty result cannot be mistaken for sorted output. +func metadataKeys(t *testing.T, out string) []string { + t.Helper() + if !strings.Contains(out, "Successfully verified signature for") { + t.Fatalf("verification success message missing from output: %q", out) + } + lines := strings.Split(out, "\n") + header := slices.IndexFunc(lines, func(line string) bool { + return strings.HasPrefix(line, "KEY") + }) + if header == -1 { + t.Fatalf("user metadata table missing from output: %q", out) + } + var keys []string + for _, line := range lines[header+1:] { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + keys = append(keys, fields[0]) + } + return keys +}