diff --git a/pkg/gotenberg/attrs.go b/pkg/gotenberg/attrs.go new file mode 100644 index 00000000..e5e1de5a --- /dev/null +++ b/pkg/gotenberg/attrs.go @@ -0,0 +1,54 @@ +package gotenberg + +import ( + "net/url" +) + +// maxAttrRunes bounds the length of a string span attribute to keep payload +// size and backend cardinality in check. +const maxAttrRunes = 256 + +// CapAttr truncates s to at most [maxAttrRunes] runes, appending an ellipsis +// when it shortens the value. It is multibyte-safe. +func CapAttr(s string) string { + runes := []rune(s) + if len(runes) <= maxAttrRunes { + return s + } + return string(runes[:maxAttrRunes-1]) + "…" +} + +// RedactURL parses raw and returns a redacted, length-capped form safe to use +// as a span attribute or event value. Userinfo, query, and fragment are +// dropped because they may carry credentials or other sensitive data. It +// returns an empty string when raw is empty or cannot be parsed. +func RedactURL(raw string) string { + if raw == "" { + return "" + } + + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + parsed.RawFragment = "" + + return CapAttr(parsed.String()) +} + +// MapEnum returns value when it belongs to allowed, otherwise "other". It keeps +// a span attribute or metric dimension bounded even when an upstream tool +// introduces a new enum value. +func MapEnum(value string, allowed ...string) string { + for _, candidate := range allowed { + if value == candidate { + return value + } + } + return "other" +} diff --git a/pkg/gotenberg/attrs_test.go b/pkg/gotenberg/attrs_test.go new file mode 100644 index 00000000..9b591289 --- /dev/null +++ b/pkg/gotenberg/attrs_test.go @@ -0,0 +1,62 @@ +package gotenberg + +import ( + "strings" + "testing" +) + +func TestRedactURL(t *testing.T) { + for _, tc := range []struct { + name string + raw string + want string + }{ + {"empty", "", ""}, + {"strips userinfo query fragment", "https://user:pass@example.com/path?token=secret#frag", "https://example.com/path"}, + {"keeps host and path", "http://example.com/a/b", "http://example.com/a/b"}, + {"parse error", "http://example.com/%zz", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := RedactURL(tc.raw); got != tc.want { + t.Errorf("RedactURL(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestRedactURL_Caps(t *testing.T) { + raw := "https://example.com/" + strings.Repeat("a", 400) + got := RedactURL(raw) + if n := len([]rune(got)); n != maxAttrRunes { + t.Errorf("expected capped length %d, got %d", maxAttrRunes, n) + } +} + +func TestCapAttr(t *testing.T) { + t.Run("short unchanged", func(t *testing.T) { + if got := CapAttr("short"); got != "short" { + t.Errorf("expected unchanged, got %q", got) + } + }) + + t.Run("multibyte truncated to rune cap", func(t *testing.T) { + got := CapAttr(strings.Repeat("é", 400)) + if n := len([]rune(got)); n != maxAttrRunes { + t.Errorf("expected %d runes, got %d", maxAttrRunes, n) + } + if !strings.HasSuffix(got, "…") { + t.Error("expected an ellipsis suffix on a truncated value") + } + }) +} + +func TestMapEnum(t *testing.T) { + allowed := []string{"document", "stylesheet", "script"} + + if got := MapEnum("script", allowed...); got != "script" { + t.Errorf("expected member passthrough, got %q", got) + } + if got := MapEnum("websocket", allowed...); got != "other" { + t.Errorf("expected non-member to map to other, got %q", got) + } +}