Skip to content

Commit 882f88c

Browse files
committed
Add support for escaping values
This is helpful when you have untrusted input being used for tag values and you want to explicitly escape them.
1 parent d08bac9 commit 882f88c

4 files changed

Lines changed: 216 additions & 8 deletions

File tree

flake.lock

Lines changed: 29 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

speedups.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ package metrics
22

33
import "unicode/utf8"
44

5-
var utf8ValidString = utf8.ValidString
5+
var (
6+
utf8ValidString = utf8.ValidString
7+
decodeRuneInString = utf8.DecodeRuneInString
8+
)
9+
10+
const runeError = utf8.RuneError
611

712
// replaceUTF8ValidStringHook is meant to be hijacked by a go:linkname
813
// directive to replace the utf8 validation implementation.
@@ -11,3 +16,11 @@ var utf8ValidString = utf8.ValidString
1116
func replaceUTF8ValidStringHook(fn func(string) bool) {
1217
utf8ValidString = fn
1318
}
19+
20+
// replaceDecodeRuneInStringHook is meant to be hijacked by a go:linkname
21+
// directive to replace the utf8 rune decoding implementation.
22+
//
23+
//nolint:unused
24+
func replaceDecodeRuneInStringHook(fn func(string) (rune, int)) {
25+
decodeRuneInString = fn
26+
}

validator.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ func MustTag(label, value string) Tag {
2828
}
2929
}
3030

31+
// NewTag creates a Tag from an already-validated Label and Value.
32+
func NewTag(label Label, value Value) Tag {
33+
return Tag{label: label, value: value}
34+
}
35+
3136
// UnsafeValue creates a Value, but does not validate it.
3237
func UnsafeValue(s string) Value {
3338
return Value{s}
@@ -45,6 +50,107 @@ func MustValue(s string) Value {
4550
return Value{s}
4651
}
4752

53+
// SanitizeValue escapes a string to be a valid tag value.
54+
// Backslashes, double-quotes, and line feeds are escaped.
55+
// Invalid UTF-8 sequences are replaced with the Unicode replacement character.
56+
//
57+
// If the input is already valid, it is returned without allocation.
58+
func SanitizeValue(s string) Value {
59+
// Fast path for valid UTF-8: just check for escape characters
60+
if utf8ValidString(s) {
61+
n := 0
62+
for i := range len(s) {
63+
switch s[i] {
64+
case '\\', '"', '\n':
65+
n++
66+
}
67+
}
68+
if n == 0 {
69+
return Value{s}
70+
}
71+
// Valid UTF-8 but needs escaping
72+
b := make([]byte, 0, len(s)+n)
73+
last := 0
74+
for i := range len(s) {
75+
var esc byte
76+
switch s[i] {
77+
case '\\':
78+
esc = '\\'
79+
case '"':
80+
esc = '"'
81+
case '\n':
82+
esc = 'n'
83+
default:
84+
continue
85+
}
86+
b = append(b, s[last:i]...)
87+
b = append(b, '\\', esc)
88+
last = i + 1
89+
}
90+
b = append(b, s[last:]...)
91+
return Value{string(b)}
92+
}
93+
94+
// Slow path: invalid UTF-8, need to scan and replace
95+
extra := 0
96+
for i := 0; i < len(s); {
97+
c := s[i]
98+
if c < 0x80 {
99+
switch c {
100+
case '\\', '"', '\n':
101+
extra++
102+
}
103+
i++
104+
continue
105+
}
106+
r, size := decodeRuneInString(s[i:])
107+
if r == runeError && size == 1 {
108+
extra += 2 // replacement char is 3 bytes, invalid is 1
109+
i++
110+
} else {
111+
i += size
112+
}
113+
}
114+
115+
b := make([]byte, 0, len(s)+extra)
116+
last := 0
117+
for i := 0; i < len(s); {
118+
c := s[i]
119+
if c < 0x80 {
120+
var esc byte
121+
switch c {
122+
case '\\':
123+
esc = '\\'
124+
case '"':
125+
esc = '"'
126+
case '\n':
127+
esc = 'n'
128+
default:
129+
i++
130+
continue
131+
}
132+
b = append(b, s[last:i]...)
133+
b = append(b, '\\', esc)
134+
i++
135+
last = i
136+
continue
137+
}
138+
139+
r, size := decodeRuneInString(s[i:])
140+
if r == runeError && size == 1 {
141+
b = append(b, s[last:i]...)
142+
b = append(b, "\uFFFD"...)
143+
i++
144+
last = i
145+
} else {
146+
i += size
147+
}
148+
}
149+
b = append(b, s[last:]...)
150+
151+
return Value{string(b)}
152+
}
153+
48154
// MustTags converts label value pairs into Tags.
49155
//
50156
// This will panic if s is an invalid tag value.

validator_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,59 @@ func TestValidateValueError(t *testing.T) {
6565
}
6666
}
6767

68+
func TestNewTag(t *testing.T) {
69+
tag := NewTag(MustLabel("foo"), SanitizeValue("bar"))
70+
assert.Equal(t, tag.String(), `foo="bar"`)
71+
}
72+
73+
func TestSanitizeValue(t *testing.T) {
74+
tests := []struct {
75+
input string
76+
want string
77+
}{
78+
// Already valid - no escaping needed
79+
{"", ""},
80+
{"hello", "hello"},
81+
{"foo_bar", "foo_bar"},
82+
{"ü", "ü"},
83+
{"🍖", "🍖"},
84+
85+
// Needs escaping
86+
{`\`, `\\`},
87+
{`"`, `\"`},
88+
{"\n", `\n`},
89+
{`foo\bar`, `foo\\bar`},
90+
{`foo"bar`, `foo\"bar`},
91+
{"foo\nbar", `foo\nbar`},
92+
{`a\b"c` + "\n" + `d`, `a\\b\"c\nd`},
93+
94+
// Invalid UTF-8 replaced with replacement character
95+
{"\xff", "\uFFFD"},
96+
{"hello\xffworld", "hello\uFFFDworld"},
97+
// Invalid UTF-8 combined with escaping
98+
{"\xff\n\xfe", "\uFFFD\\n\uFFFD"},
99+
{"\xff\\\"\xfe", "\uFFFD\\\\\\\"\uFFFD"},
100+
// Invalid UTF-8 mixed with valid multi-byte UTF-8
101+
{"\xffü\xfe", "\uFFFDü\uFFFD"},
102+
}
103+
104+
for _, tt := range tests {
105+
got := SanitizeValue(tt.input)
106+
assert.Equal(t, got.String(), tt.want)
107+
// Verify the result passes validation
108+
assert.Equal(t, MustValue(got.String()).String(), tt.want)
109+
}
110+
}
111+
112+
func TestSanitizeValueNoAlloc(t *testing.T) {
113+
// Valid strings should not allocate
114+
valid := "hello_world_123"
115+
allocs := testing.AllocsPerRun(100, func() {
116+
_ = SanitizeValue(valid)
117+
})
118+
assert.Equal(t, allocs, float64(0))
119+
}
120+
68121
func BenchmarkValidate(b *testing.B) {
69122
b.Run("MustIdent", func(b *testing.B) {
70123
b.ReportAllocs()
@@ -86,4 +139,18 @@ func BenchmarkValidate(b *testing.B) {
86139
MustValue(`some.other.value`)
87140
}
88141
})
142+
143+
b.Run("SanitizeValue/clean", func(b *testing.B) {
144+
b.ReportAllocs()
145+
for b.Loop() {
146+
SanitizeValue(`some.other.value`)
147+
}
148+
})
149+
150+
b.Run("SanitizeValue/escape", func(b *testing.B) {
151+
b.ReportAllocs()
152+
for b.Loop() {
153+
SanitizeValue("foo\nbar\"baz\\qux")
154+
}
155+
})
89156
}

0 commit comments

Comments
 (0)