Skip to content

Commit a5cbeef

Browse files
committed
Add protoscope language, assembler, disassembler
Adds `protoscope`, a human-readable text format for raw Protobuf binary wire payloads, to `protocompile`. It enables writing, parsing, compiling (assembly), and decompiling (disassembly) without requiring a schema. It supports multi-frame documents (separated by `---`) and transport framing (gRPC, ConnectRPC, and Varint delimited). - **Parser & AST (`internal/protoscope/parser`, `internal/protoscope/ast`)**: Parses tokens into an AST of fields, blocks (groups/length-delimited), values, and options. - **Assembler (`internal/protoscope/assembler`)**: Compiles AST nodes into raw Protobuf wire format. - **Disassembler (`internal/protoscope/disassembler`)**: Decompiles binary Protobuf payloads back to protoscope text format using heuristics. - **Public API (`protoscope`)**: Exposes public endpoints (`Assemble`, `Disassemble`, `Diagnostics`, `Hover`, and `Possibilities`) for integrations (e.g. Buf LSP). Handles frame splitting/joining, flags parsing, and context-aware hover info. - `Fuzz tests` and a few related fixes as a result of the fuzz testing. Changes in `experimental/internal/lexer/number.go` is an example of changes made to avoid a DoS vector that was revealed with this testing.
1 parent 90bc23d commit a5cbeef

47 files changed

Lines changed: 3914 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
version: "2"
22
linters:
33
default: all
4+
enable:
5+
- gomodguard_v2
46
disable:
57
# TODO: TCN-350 - initial exclusions for failing linters.
68
# Should enable all of these?
@@ -20,6 +22,7 @@ linters:
2022
- goconst
2123
- gocyclo
2224
- godoclint
25+
- gomodguard
2326
- interfacebloat
2427
- modernize
2528
- nestif

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ test: $(PROTOC) ## Run unit tests
6060
$(GO) test $(if $(filter 386,$(GOARCH)),,-race) -cover ./...
6161
$(GO) test -tags protolegacy ./...
6262

63+
.PHONY: fuzz
64+
fuzz: $(PROTOC) ## Run fuzz tests
65+
$(GO) test -v -fuzz=FuzzRoundTrip -fuzztime=30s ./experimental/internal/protoscope/assembler
66+
$(GO) test -v -fuzz=FuzzParse -fuzztime=30s ./experimental/internal/protoscope/parser
67+
$(GO) test -v -fuzz=FuzzDisassemble -fuzztime=30s ./experimental/internal/protoscope/disassembler
68+
$(GO) test -v -fuzz=FuzzAssemble -fuzztime=30s ./experimental/protoscope
69+
$(GO) test -v -fuzz=FuzzDisassemble -fuzztime=30s ./experimental/protoscope
70+
6371
.PHONY: benchmarks
6472
benchmarks: $(PROTOC) ## Run benchmarks
6573
$(GO) test -bench=. -benchmem -v ./experimental/benchmark

experimental/internal/lexer/lexer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ type Lexer struct {
9292
EscapePartialX bool // Partial \xN escapes.
9393
EscapeUppercaseX bool // The unusual \XNN escape.
9494
EscapeOldStyleUnicode bool // Old-style Unicode escapes \uXXXX and \UXXXXXXXX.
95+
96+
AllowBacktickStrings bool // If true, backticks ` can enclose string/hex literals.
9597
}
9698

9799
// Lex runs lexical analysis on file and returns a new token stream as a result.

experimental/internal/lexer/loop.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,14 @@ func loop(l *lexer) {
187187
r := l.pop()
188188

189189
switch {
190-
case r == '"', r == '\'':
190+
case r == '"', r == '\'', (r == '`' && l.AllowBacktickStrings):
191191
l.cursor-- // Back up to behind the quote before resuming.
192192
lexString(l, "")
193193

194194
case l.NumberCanStartWithDot && r == '.', unicode.IsDigit(r):
195195
// Back up behind the rune we just popped.
196196
l.cursor -= utf8.RuneLen(r)
197-
lexNumber(l)
197+
_ = lexNumber(l)
198198

199199
case unicodex.IsXIDStart(r):
200200
// Back up behind the rune we just popped.

experimental/internal/lexer/number.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func lexNumber(l *lexer) token.Token {
8181
token.MutateMeta[tokenmeta.Number](tok).Base = base
8282
}
8383

84-
isFloat := taxa.IsFloatText(digits)
84+
isFloat := taxa.IsFloatText(tok.Text())
8585
expBase := 1
8686
expIdx := -1
8787
if isFloat {
@@ -190,6 +190,12 @@ func lexNumber(l *lexer) token.Token {
190190
goto fail
191191
}
192192

193+
// Cap the exponent to prevent extremely large values from causing hangs in v.Int()
194+
// or other operations. 1,000,000 is still quite large but manageable.
195+
if v.IsInf() || v.IsNaN() || v.Exp() > 1000000 || v.Exp() < -1000000 {
196+
goto fail
197+
}
198+
193199
// We want this to overflow to Infinity as needed, which Float64
194200
// will do for us. Otherwise it will ties-to-even as the
195201
// protobuf.com spec requires.
@@ -219,7 +225,7 @@ func lexNumber(l *lexer) token.Token {
219225
case result.big != nil:
220226
token.MutateMeta[tokenmeta.Number](tok).Big = new(decimal.Decimal).ReuseInt(result.big)
221227

222-
case base == 10 && !result.hasThousands:
228+
case base == 10 && !result.hasThousands && suffix == "" && prefix == "":
223229
// We explicitly do not call SetValue for the most common case of base
224230
// 10 integers, because that is handled for us on-demand in AsInt. This
225231
// is a memory consumption optimization.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright 2020-2026 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package assembler
16+
17+
import (
18+
"encoding/binary"
19+
"encoding/hex"
20+
"math"
21+
"strings"
22+
"unicode"
23+
24+
"github.com/bufbuild/protocompile/experimental/id"
25+
"github.com/bufbuild/protocompile/experimental/internal/protoscope/ast"
26+
"github.com/bufbuild/protocompile/experimental/seq"
27+
"github.com/bufbuild/protocompile/experimental/token"
28+
"github.com/bufbuild/protocompile/experimental/token/keyword"
29+
)
30+
31+
// Assemble translates a protoscope AST into Protobuf wire format.
32+
func Assemble(file *ast.File) []byte {
33+
a := &assembler{}
34+
for decl := range seq.Values(file.Decls()) {
35+
a.assembleDecl(decl, false)
36+
}
37+
return a.buf
38+
}
39+
40+
type assembler struct {
41+
buf []byte
42+
}
43+
44+
func (a *assembler) assembleDecl(decl ast.DeclAny, inBlock bool) {
45+
switch decl.Kind() {
46+
case ast.DeclKindField:
47+
a.assembleField(id.Wrap(decl.Context(), id.ID[ast.Field](decl.ID().Value())))
48+
case ast.DeclKindLiteral:
49+
a.assembleLiteral(id.Wrap(decl.Context(), id.ID[ast.Literal](decl.ID().Value())), inBlock)
50+
case ast.DeclKindBlock:
51+
a.assembleBlock(id.Wrap(decl.Context(), id.ID[ast.Block](decl.ID().Value())), inBlock)
52+
}
53+
}
54+
55+
func (a *assembler) assembleField(f ast.Field) {
56+
tag, _ := f.Tag().AsNumber().Int()
57+
wireType := uint64(0)
58+
val := f.Value()
59+
if !val.IsZero() {
60+
switch val.Kind() {
61+
case ast.DeclKindBlock:
62+
block := id.Wrap(val.Context(), id.ID[ast.Block](val.ID().Value()))
63+
if block.Token().Keyword() == keyword.Bang {
64+
wireType = 3 // SGROUP
65+
} else {
66+
wireType = 2 // LEN
67+
}
68+
case ast.DeclKindLiteral:
69+
lit := id.Wrap(val.Context(), id.ID[ast.Literal](val.ID().Value()))
70+
if lit.Token().Kind() == token.String {
71+
wireType = 2 // LEN
72+
} else if lit.Token().Kind() == token.Number {
73+
num := lit.Token().AsNumber()
74+
suffix := num.Suffix().Text()
75+
switch suffix {
76+
case "i64", "f64":
77+
wireType = 1 // I64
78+
case "i32", "f32":
79+
wireType = 5 // I32
80+
default:
81+
if num.IsFloat() {
82+
wireType = 1 // I64 (double)
83+
}
84+
}
85+
}
86+
}
87+
}
88+
89+
// Override with explicit wire type hint.
90+
switch f.WireType().Text() {
91+
case "VARINT":
92+
wireType = 0
93+
case "I64":
94+
wireType = 1
95+
case "LEN":
96+
wireType = 2
97+
case "SGROUP":
98+
wireType = 3
99+
case "EGROUP":
100+
wireType = 4
101+
case "I32":
102+
wireType = 5
103+
}
104+
105+
a.writeVarint(tag<<3 | wireType)
106+
107+
if wireType == 4 { // EGROUP
108+
return
109+
}
110+
111+
if wireType == 3 { // SGROUP
112+
a.assembleDecl(val, false)
113+
a.writeVarint(tag<<3 | 4) // Emit matching EGROUP
114+
return
115+
}
116+
117+
if wireType == 2 { // LEN
118+
// Length-delimited fields must be prefixed by their length.
119+
// Some values (blocks and strings) are self-length-delimited.
120+
isSelfDelimited := false
121+
if !val.IsZero() && val.Kind() == ast.DeclKindBlock {
122+
isSelfDelimited = true
123+
}
124+
if !val.IsZero() && val.Kind() == ast.DeclKindLiteral {
125+
lit := id.Wrap(val.Context(), id.ID[ast.Literal](val.ID().Value()))
126+
if lit.Token().Kind() == token.String {
127+
isSelfDelimited = true
128+
}
129+
}
130+
131+
if isSelfDelimited {
132+
a.assembleDecl(val, false)
133+
} else {
134+
sub := &assembler{}
135+
sub.assembleDecl(val, false)
136+
a.writeVarint(uint64(len(sub.buf)))
137+
a.buf = append(a.buf, sub.buf...)
138+
}
139+
return
140+
}
141+
142+
a.assembleDecl(val, false)
143+
}
144+
145+
func (a *assembler) assembleLiteral(l ast.Literal, inBlock bool) {
146+
tok := l.Token()
147+
switch tok.Keyword() {
148+
case keyword.True:
149+
a.writeVarint(1)
150+
return
151+
case keyword.False:
152+
a.writeVarint(0)
153+
return
154+
}
155+
156+
switch tok.Kind() {
157+
case token.Number:
158+
// Check for suffix hints
159+
num := tok.AsNumber()
160+
suffix := num.Suffix().Text()
161+
switch suffix {
162+
case "i32":
163+
var buf [4]byte
164+
if num.IsFloat() {
165+
f, _ := num.Float()
166+
binary.LittleEndian.PutUint32(buf[:], math.Float32bits(float32(f)))
167+
} else {
168+
v, _ := num.Int()
169+
binary.LittleEndian.PutUint32(buf[:], uint32(v))
170+
}
171+
a.buf = append(a.buf, buf[:]...)
172+
case "f32":
173+
f, _ := num.Float()
174+
var buf [4]byte
175+
binary.LittleEndian.PutUint32(buf[:], math.Float32bits(float32(f)))
176+
a.buf = append(a.buf, buf[:]...)
177+
case "i64":
178+
var buf [8]byte
179+
if num.IsFloat() {
180+
f, _ := num.Float()
181+
binary.LittleEndian.PutUint64(buf[:], math.Float64bits(f))
182+
} else {
183+
v, _ := num.Int()
184+
binary.LittleEndian.PutUint64(buf[:], v)
185+
}
186+
a.buf = append(a.buf, buf[:]...)
187+
case "f64":
188+
f, _ := num.Float()
189+
var buf [8]byte
190+
binary.LittleEndian.PutUint64(buf[:], math.Float64bits(f))
191+
a.buf = append(a.buf, buf[:]...)
192+
case "z":
193+
v := num.Value().Int(nil).Int64()
194+
// Zigzag encoding: (n << 1) ^ (n >> 63)
195+
zigzag := uint64((v << 1) ^ (v >> 63))
196+
a.writeVarint(zigzag)
197+
default:
198+
if num.IsFloat() {
199+
f, _ := num.Float()
200+
var buf [8]byte
201+
binary.LittleEndian.PutUint64(buf[:], math.Float64bits(f))
202+
a.buf = append(a.buf, buf[:]...)
203+
} else {
204+
v, _ := num.Int()
205+
a.writeVarint(v)
206+
}
207+
}
208+
case token.String:
209+
open, _ := tok.AsString().Quotes()
210+
isHex := open.Text() == "`"
211+
var contentBytes []byte
212+
if isHex {
213+
// Decode hex string
214+
var sb strings.Builder
215+
for _, r := range tok.AsString().Text() {
216+
if !unicode.IsSpace(r) {
217+
sb.WriteRune(r)
218+
}
219+
}
220+
var err error
221+
contentBytes, err = hex.DecodeString(sb.String())
222+
if err != nil {
223+
// Fallback to raw string text if decoding fails
224+
contentBytes = []byte(tok.AsString().Text())
225+
}
226+
} else {
227+
contentBytes = []byte(tok.AsString().Text())
228+
}
229+
230+
if inBlock {
231+
a.buf = append(a.buf, contentBytes...)
232+
} else {
233+
a.writeVarint(uint64(len(contentBytes)))
234+
a.buf = append(a.buf, contentBytes...)
235+
}
236+
}
237+
}
238+
239+
func (a *assembler) assembleBlock(b ast.Block, _ bool) {
240+
tok := b.Token()
241+
switch tok.Keyword() {
242+
case keyword.LBracket, keyword.Brackets, keyword.LBrace, keyword.Braces:
243+
// Length-prefixed block
244+
sub := &assembler{}
245+
for decl := range seq.Values(b.Decls()) {
246+
sub.assembleDecl(decl, true)
247+
}
248+
a.writeVarint(uint64(len(sub.buf)))
249+
a.buf = append(a.buf, sub.buf...)
250+
case keyword.Bang:
251+
// Group content (no length prefix)
252+
for decl := range seq.Values(b.Decls()) {
253+
a.assembleDecl(decl, false)
254+
}
255+
}
256+
}
257+
258+
func (a *assembler) writeVarint(v uint64) {
259+
var buf [10]byte
260+
n := binary.PutUvarint(buf[:], v)
261+
a.buf = append(a.buf, buf[:n]...)
262+
}

0 commit comments

Comments
 (0)