diff --git a/README.md b/README.md index 6bfa5eb..a38fdb5 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ always asks before it touches your local dashboard. ## Widgets -33 tools across 10 categories, and growing. +36 tools across 10 categories, and growing. | Category | Widgets | | ---------- | --------------------------------------------------------------------------------------------------------- | | Generators | UUID Generator | | Formatting | Cron Expression, JSON Formatter, Timestamp Converter, XML Formatter, YAML ↔ JSON Converter | -| Encoding | Base64, JWT Encoder, LZ-String, URL Encoder | -| Security | Certificate Viewer, Hash Generator, JWK Viewer, Password Generator | +| Encoding | Base64, JWT Encoder, LZ-String, Protobuf Decoder, URL Encoder | +| Security | Certificate Viewer, Hash Generator, JWK Viewer, Password Generator, Unix Permissions | | Text | Content Type Detector, Emoji Picker, Log Viewer, Notes, Regex Tester, Text Case Converter, Text Diff | | Math | Expression Evaluator, Number Base Converter, Percentage Calculator, Statistics Calculator, Unit Converter | | AI / LLM | Invisible Character Cleaner, Token Counter | diff --git a/package-lock.json b/package-lock.json index a205408..db8a349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "^1.30.0", "lz-string": "^1.5.0", "nanoid": "^6.0.1", + "protobufjs": "^8.8.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -7189,6 +7190,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7701,6 +7708,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 3caf393..3a2868b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lucide-react": "^1.30.0", "lz-string": "^1.5.0", "nanoid": "^6.0.1", + "protobufjs": "^8.8.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx new file mode 100644 index 0000000..50b58dd --- /dev/null +++ b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.test.tsx @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { setCodeMirrorValue } from '@/test/codemirror' +import ProtobufDecoderWidget from './ProtobufDecoderWidget' + +/** The sample payload the widget opens on: Person { id: 150, name: + * "testing", tags: ["a", "b"], home: { city: "Paris" } }. */ +const SAMPLE_PAYLOAD = 'CJYBEgd0ZXN0aW5nGgFhGgFiIgcKBVBhcmlz' + +function payloadBox() { + return screen.getByPlaceholderText(/paste a base64 or hex protobuf payload/i) +} + +async function setPayload(user: ReturnType, text: string) { + await user.clear(payloadBox()) + if (text) await user.paste(text) +} + +describe('ProtobufDecoderWidget', () => { + it('decodes the sample payload without a schema, field by field', () => { + render() + + const rows = screen.getAllByRole('listitem') + expect(within(rows[0]).getByText('1')).toBeInTheDocument() + expect(within(rows[0]).getByText('150')).toBeInTheDocument() + expect(within(rows[0]).getByText('varint')).toBeInTheDocument() + expect(screen.getByText('"testing"')).toBeInTheDocument() + expect(screen.getByText('"Paris"')).toBeInTheDocument() + }) + + it('says what the encoding and size turned out to be', () => { + render() + expect(screen.getByText(/base64, 27 bytes/i)).toBeInTheDocument() + }) + + it('reads the same payload pasted as hex', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '08 96 01 12 07 74 65 73 74 69 6e 67') + + expect(screen.getByText(/hex, 12 bytes/i)).toBeInTheDocument() + expect(screen.getByText('"testing"')).toBeInTheDocument() + }) + + it('strips a gRPC frame and says it did', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '0000000003089601') + + expect(screen.getByText(/gRPC frame detected/i)).toBeInTheDocument() + expect(screen.getByText('150')).toBeInTheDocument() + }) + + it('offers the other readings of an ambiguous value', async () => { + const user = userEvent.setup() + render() + + // Field 1, varint 1: as likely a bool as a number. + await setPayload(user, '0801') + + expect(screen.getByText(/or bool true/i)).toBeInTheDocument() + }) + + it('explains what a schema-less decode cannot know', () => { + render() + expect(screen.getByText(/names and declared types are not in the bytes/i)).toBeInTheDocument() + }) + + it('names the fields once the .proto is supplied', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + + expect(screen.getByRole('combobox', { name: /message/i })).toHaveValue('Person') + expect(screen.getByText('name')).toBeInTheDocument() + expect(screen.getByText('"testing"')).toBeInTheDocument() + expect(screen.getByText('city')).toBeInTheDocument() + }) + + it('lets the message type be switched to another one in the definition', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + await user.selectOptions(screen.getByRole('combobox', { name: /message/i }), 'Address') + + // Person's payload is not an Address. protobufjs decodes it to an empty + // object without complaining, so the widget is what has to say why. + expect(screen.getByText(/Field 1 arrived as varint but is declared string city/i)).toBeInTheDocument() + expect(screen.getByText(/Fields 2, 3, 4 on the wire/i)).toBeInTheDocument() + }) + + it('reports a syntax error in the definition with its line', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + setCodeMirrorValue( + screen.getByRole('textbox', { name: 'Proto definition' }), + 'syntax = "proto3";\nmessage B { int32 id = ; }', + ) + + expect(screen.getByText(/line 2/i)).toBeInTheDocument() + }) + + it('warns when the payload carries fields the definition never declared', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + // Field 1 (declared) plus field 9 (not in Person). + await setPayload(user, '0896014a0568656c6c6f') + + expect(screen.getByText(/Field 9 on the wire is not in this definition/i)).toBeInTheDocument() + }) + + it('reports an unusable payload instead of decoding noise', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, 'not a payload!!') + + expect(screen.getByText(/neither valid hex nor valid base64/i)).toBeInTheDocument() + }) + + it('keeps what it read before a truncated payload gave out', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '0896011209746573') + + expect(screen.getByText('150')).toBeInTheDocument() + expect(screen.getByText(/past the end of the payload/i)).toBeInTheDocument() + expect(screen.getByText(/everything above was read before that point/i)).toBeInTheDocument() + }) + + it('keeps payload, mode and definition across a remount of the same instance', async () => { + const user = userEvent.setup() + const { unmount } = render() + + await user.click(screen.getByRole('button', { name: /with \.proto/i })) + await setPayload(user, '089601') + unmount() + + render() + expect(screen.getByRole('button', { name: /with \.proto/i })).toHaveAttribute('aria-pressed', 'true') + expect(payloadBox()).toHaveValue('089601') + expect(screen.getByText('id')).toBeInTheDocument() + }) + + it('still decodes the sample payload after it has been re-pasted', async () => { + const user = userEvent.setup() + render() + + await setPayload(user, '') + expect(screen.getByText(/paste a base64 or hex payload/i)).toBeInTheDocument() + + await user.paste(SAMPLE_PAYLOAD) + expect(screen.getByText('"testing"')).toBeInTheDocument() + }) +}) diff --git a/src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx new file mode 100644 index 0000000..1cf6f7c --- /dev/null +++ b/src/widgets/protobuf-decoder/ProtobufDecoderWidget.tsx @@ -0,0 +1,290 @@ +import { useMemo } from 'react' +import { CodeEditor } from '@/components/CodeEditor' +import { CopyButton } from '@/components/CopyButton' +import { DataTree } from '@/components/data-tree/DataTree' +import { buildJsonTree } from '@/components/data-tree/treeModel' +import { ErrorMessage } from '@/components/ErrorMessage' +import { SegmentedControl } from '@/components/SegmentedControl' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' +import { useWidgetDirty } from '@/widgets/useWidgetDirty' +import { useWidgetState } from '@/widgets/useWidgetState' +import type { WidgetProps } from '@/widgets/types' +import { decodePayload, toHex, type PayloadEncoding } from './payloadBytes' +import { decodeWithSchema, parseSchema } from './protoSchema' +import { decodeWireFormat, formatFields, WIRE_TYPE_NAMES, type WireField } from './wireFormat' + +type Mode = 'raw' | 'schema' + +/** A Person message: an id, a name, two repeated tags, and a nested + * address. Enough shape to show what a schema-less decode can and cannot + * recover, which is the point of the widget. */ +const SAMPLE_PAYLOAD = 'CJYBEgd0ZXN0aW5nGgFhGgFiIgcKBVBhcmlz' + +const SAMPLE_SCHEMA = `syntax = "proto3"; + +message Person { + int32 id = 1; + string name = 2; + repeated string tags = 3; + Address home = 4; +} + +message Address { + string city = 1; +}` + +const SELECT_CLASS = + 'h-6 min-w-0 rounded-md border border-input bg-transparent px-1 text-[11px] outline-none transition-colors focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 dark:bg-input/30' + +const ENCODING_OPTIONS: { label: string; value: PayloadEncoding }[] = [ + { label: 'Auto', value: 'auto' }, + { label: 'Base64', value: 'base64' }, + { label: 'Hex', value: 'hex' }, +] + +export default function ProtobufDecoderWidget({ instanceId }: WidgetProps) { + const [payload, setPayload] = useWidgetState(instanceId, 'payload', SAMPLE_PAYLOAD) + const [encoding, setEncoding] = useWidgetState(instanceId, 'encoding', 'auto') + const [mode, setMode] = useWidgetState(instanceId, 'mode', 'raw') + const [schema, setSchema] = useWidgetState(instanceId, 'schema', SAMPLE_SCHEMA) + const [messageName, setMessageName] = useWidgetState(instanceId, 'messageName', '') + useWidgetDirty(instanceId, payload !== SAMPLE_PAYLOAD || schema !== SAMPLE_SCHEMA || mode !== 'raw') + + const decodedPayload = useMemo(() => decodePayload(payload, encoding), [payload, encoding]) + const bytes = 'error' in decodedPayload ? null : decodedPayload.bytes + + const wire = useMemo(() => (bytes ? decodeWireFormat(bytes) : null), [bytes]) + const parsedSchema = useMemo(() => (mode === 'schema' ? parseSchema(schema) : null), [mode, schema]) + + // Fall back to the first message the definition declares until one is + // picked, so a freshly pasted .proto decodes without another click. + const availableNames = parsedSchema?.ok ? parsedSchema.names : [] + const selectedName = availableNames.includes(messageName) ? messageName : (availableNames[0] ?? '') + + const schemaDecode = useMemo(() => { + if (mode !== 'schema' || !bytes || !parsedSchema?.ok || !selectedName) return null + return decodeWithSchema(parsedSchema.root, selectedName, bytes, wire?.fields ?? []) + }, [mode, bytes, parsedSchema, selectedName, wire]) + + const copyText = useMemo(() => { + // Branching on the mode first matters: with a failed schema decode the + // pane shows an error, and falling through to the wire rendering would + // have the Copy button hand back output nobody can see. + if (mode === 'schema') return schemaDecode?.ok ? JSON.stringify(schemaDecode.value, null, 2) : '' + return wire ? formatFields(wire.fields) : '' + }, [mode, schemaDecode, wire]) + + return ( +
+