diff --git a/go/internal/database/datastore/listed_vulnerability.go b/go/internal/database/datastore/listed_vulnerability.go index 5398798db1e..cd07a0ce056 100644 --- a/go/internal/database/datastore/listed_vulnerability.go +++ b/go/internal/database/datastore/listed_vulnerability.go @@ -18,6 +18,8 @@ import ( "regexp" "slices" "strings" + "unicode" + "unicode/utf8" "github.com/ossf/osv-schema/bindings/go/osvschema" ) @@ -72,9 +74,54 @@ func removeVariants(ecosystem string) string { return "" } +// truncate ensures that the given string is shorter than 80 characters. +// +// If the string is longer than that limit, it's trimmed and suffixed with an ellipsis. +// Ideally the string will be trimmed at the space that's closest to the limit to +// preserve whole words; if a string has no spaces before the limit, it'll be forcefully truncated. +func truncate(str string) string { + count := 0 + truncateAt := -1 + + for i, c := range str { + if unicode.IsSpace(c) { + truncateAt = i + } + + count++ + + if count >= 80 { + // ideally we want to keep words whole when truncating, + // but if we can't find a space just truncate at the limit + if truncateAt == -1 { + _, size := utf8.DecodeRuneInString(str[i:]) + truncateAt = i + size + } + + return str[:truncateAt] + "..." + } + } + + return str +} + +func describe(vuln *osvschema.Vulnerability) string { + summary := vuln.GetSummary() + + if summary == "" { + summary = vuln.GetDetails() + + if summary != "" { + summary = truncate(summary) + } + } + + return summary +} + func NewListedVulnerabilityFromProto(vuln *osvschema.Vulnerability) *ListedVulnerability { published := vuln.GetPublished().AsTime() - summary := vuln.GetSummary() + summary := describe(vuln) allEcosystems := make(map[string]struct{}) allPackages := make(map[string]struct{}) diff --git a/go/internal/database/datastore/listed_vulnerability_test.go b/go/internal/database/datastore/listed_vulnerability_test.go index 578cf4dada9..fefb5654de3 100644 --- a/go/internal/database/datastore/listed_vulnerability_test.go +++ b/go/internal/database/datastore/listed_vulnerability_test.go @@ -1,8 +1,10 @@ package datastore import ( + "strings" "testing" "time" + "unicode/utf8" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -106,3 +108,76 @@ func TestNewListedVulnerabilityFromProto(t *testing.T) { t.Errorf("NewListedVulnerabilityFromProto mismatch (-want +got):\n%s", diff) } } + +func TestNewListedVulnerabilityFromProto_Summary(t *testing.T) { + tests := []struct { + name string + summary string + details string + want string + }{ + {want: ""}, + { + summary: "hello world", + details: "hello sunshine", + want: "hello world", + }, + { + summary: "", + details: "hello sunshine", + want: "hello sunshine", + }, + { + summary: " ", + details: "hello sunshine", + want: " ", + }, + { + summary: " ", + details: "hello sunshine", + want: " ", + }, + { + summary: strings.Repeat("!", 100), + details: "hello sunshine", + want: strings.Repeat("!", 100), + }, + { + summary: "", + details: strings.Repeat("!", 100), + want: strings.Repeat("!", 80) + "...", + }, + { + summary: "", + details: strings.Repeat("\u754c", 100), + want: strings.Repeat("\u754c", 80) + "...", + }, + { + summary: "", + details: strings.Repeat("\u754c", 30) + " " + strings.Repeat("\u8a9e", 60), + want: strings.Repeat("\u754c", 30) + "...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + vuln := &osvschema.Vulnerability{ + Id: "TEST-123", + Published: timestamppb.New(time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC)), + Summary: tt.summary, + Details: tt.details, + } + + got := NewListedVulnerabilityFromProto(vuln).Summary + + if got != tt.want { + t.Fatalf("Summary = %q, want %q", got, tt.want) + } + if !utf8.ValidString(got) { + t.Fatalf("Summary returned invalid UTF-8: %q", got) + } + }) + } +}