Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: runtime size limits for arrays and maps #128

Merged
merged 2 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/protons-runtime/src/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ export interface EncodeFunction<T> {
(value: Partial<T>, writer: Writer, opts?: EncodeOptions): void
}

export interface DecodeOptions<T> {
/**
* Runtime-specified limits for lengths of repeated/map fields
*/
limits?: Partial<Record<keyof T, number>>
}

export interface DecodeFunction<T> {
(reader: Reader, length?: number): T
(reader: Reader, length?: number, opts?: DecodeOptions<T>): T
}

export interface Codec<T> {
Expand Down
5 changes: 2 additions & 3 deletions packages/protons-runtime/src/codecs/message.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { createCodec, CODEC_TYPES, type EncodeOptions, type Codec } from '../codec.js'
import type { Reader, Writer } from '../index.js'
import { createCodec, CODEC_TYPES, type EncodeFunction, type DecodeFunction, type Codec } from '../codec.js'

export interface Factory<A, T> {
new (obj: A): T
}

export function message <T> (encode: (obj: Partial<T>, writer: Writer, opts?: EncodeOptions) => void, decode: (reader: Reader, length?: number) => T): Codec<T> {
export function message <T> (encode: EncodeFunction<T>, decode: DecodeFunction<T>): Codec<T> {
return createCodec('message', CODEC_TYPES.LENGTH_DELIMITED, encode, decode)
}
6 changes: 3 additions & 3 deletions packages/protons-runtime/src/decode.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createReader } from './utils/reader.js'
import type { Codec } from './codec.js'
import type { Codec, DecodeOptions } from './codec.js'
import type { Uint8ArrayList } from 'uint8arraylist'

export function decodeMessage <T> (buf: Uint8Array | Uint8ArrayList, codec: Codec<T>): T {
export function decodeMessage <T> (buf: Uint8Array | Uint8ArrayList, codec: Codec<T>, opts?: DecodeOptions<T>): T {
const reader = createReader(buf)

return codec.decode(reader)
return codec.decode(reader, undefined, opts)
}
2 changes: 1 addition & 1 deletion packages/protons-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export { enumeration } from './codecs/enum.js'
export { message } from './codecs/message.js'
export { createReader as reader } from './utils/reader.js'
export { createWriter as writer } from './utils/writer.js'
export type { Codec, EncodeOptions } from './codec.js'
export type { Codec, EncodeOptions, DecodeOptions } from './codec.js'

export interface Writer {
/**
Expand Down
56 changes: 56 additions & 0 deletions packages/protons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
- [Install](#install)
- [Usage](#usage)
- [Differences from protobuf.js](#differences-from-protobufjs)
- [Extra features](#extra-features)
- [Limiting the size of repeated/map elements](#limiting-the-size-of-repeatedmap-elements)
- [Overriding 64 bit types](#overriding-64-bit-types)
- [Missing features](#missing-features)
- [API Docs](#api-docs)
- [License](#license)
Expand Down Expand Up @@ -73,6 +76,59 @@ It does have one or two differences:
5. `map` fields can have keys of any type - protobufs.js [only supports strings](https://github.com/protobufjs/protobuf.js/issues/1203#issuecomment-488637338)
6. `map` fields are deserialized as ES6 `Map`s - protobuf.js uses `Object`s

## Extra features

### Limiting the size of repeated/map elements

To protect decoders from malicious payloads, it's possible to limit the maximum size of repeated/map elements.

You can either do this at compile time by using the [protons.options](https://github.com/protocolbuffers/protobuf/blob/6f1d88107f268b8ebdad6690d116e74c403e366e/docs/options.md?plain=1#L490-L493) extension:

```protobuf
message MyMessage {
// repeatedField cannot have more than 10 entries
repeated uint32 repeatedField = 1 [(protons.options).limit = 10];

// stringMap cannot have more than 10 keys
map<string, string> stringMap = 2 [(protons.options).limit = 10];
}
```

Or at runtime by passing objects to the `.decode` function of your message:

```TypeScript
const message = MyMessage.decode(buf, {
limits: {
repeatedField: 10,
stringMap: 10
}
})
```

### Overriding 64 bit types

By default 64 bit types are implemented as [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt)s.

Sometimes this is undesirable due to [performance issues](https://betterprogramming.pub/the-downsides-of-bigints-in-javascript-6350fd807d) or code legibility.

It's possible to override the JavaScript type 64 bit fields will deserialize to:

```protobuf
message MyMessage {
repeated int64 bigintField = 1;
repeated int64 numberField = 2 [jstype = JS_NUMBER];
repeated int64 stringField = 3 [jstype = JS_STRING];
}
```

```TypeScript
const message = MyMessage.decode(buf)

console.info(typeof message.bigintField) // bigint
console.info(typeof message.numberField) // number
console.info(typeof message.stringField) // string
```

## Missing features

Some features are missing `OneOf`s, etc due to them not being needed so far in ipfs/libp2p. If these features are important to you, please open PRs implementing them along with tests comparing the generated bytes to `protobuf.js` and `pbjs`.
Expand Down
1 change: 0 additions & 1 deletion packages/protons/bin/protons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ async function main (): Promise<void> {
Examples
$ protons ./path/to/file.proto ./path/to/other/file.proto
`, {
// @ts-expect-error importMeta is missing from the types
importMeta: import.meta,
flags: {
output: {
Expand Down
2 changes: 1 addition & 1 deletion packages/protons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
"release": "aegir release"
},
"dependencies": {
"meow": "^13.0.0",
"meow": "^13.1.0",
"protobufjs-cli": "^1.0.0"
},
"devDependencies": {
Expand Down
31 changes: 20 additions & 11 deletions packages/protons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@
moduleDef.addImport('protons-runtime', 'decodeMessage')
moduleDef.addImport('protons-runtime', 'message')
moduleDef.addTypeImport('protons-runtime', 'Codec')
moduleDef.addTypeImport('protons-runtime', 'DecodeOptions')
moduleDef.addTypeImport('uint8arraylist', 'Uint8ArrayList')

const interfaceFields = defineFields(fields, messageDef, moduleDef)
Expand Down Expand Up @@ -691,12 +692,16 @@
const parseValue = `${decoderGenerators[type] == null ? `${codec}.decode(reader${type === 'message' ? ', reader.uint32()' : ''})` : decoderGenerators[type](jsTypeOverride)}`

if (fieldDef.map) {
let limit = ''
moduleDef.addImport('protons-runtime', 'CodeError')

Check warning on line 695 in packages/protons/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/protons/src/index.ts#L695

Added line #L695 was not covered by tests

if (fieldDef.lengthLimit != null) {
moduleDef.addImport('protons-runtime', 'CodeError')
let limit = `
if (opts.limits?.${fieldName} != null && obj.${fieldName}.size === opts.limits.${fieldName}) {
throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_SIZE')
}
`

Check warning on line 701 in packages/protons/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/protons/src/index.ts#L697-L701

Added lines #L697 - L701 were not covered by tests

limit = `
if (fieldDef.lengthLimit != null) {
limit += `

Check warning on line 704 in packages/protons/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/protons/src/index.ts#L703-L704

Added lines #L703 - L704 were not covered by tests
if (obj.${fieldName}.size === ${fieldDef.lengthLimit}) {
throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_SIZE')
}
Expand All @@ -709,12 +714,16 @@
break
}`
} else if (fieldDef.repeated) {
let limit = ''
moduleDef.addImport('protons-runtime', 'CodeError')

Check warning on line 717 in packages/protons/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/protons/src/index.ts#L717

Added line #L717 was not covered by tests

if (fieldDef.lengthLimit != null) {
moduleDef.addImport('protons-runtime', 'CodeError')
let limit = `
if (opts.limits?.${fieldName} != null && obj.${fieldName}.length === opts.limits.${fieldName}) {
throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_LENGTH')
}
`

Check warning on line 723 in packages/protons/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/protons/src/index.ts#L719-L723

Added lines #L719 - L723 were not covered by tests

limit = `
if (fieldDef.lengthLimit != null) {
limit += `

Check warning on line 726 in packages/protons/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/protons/src/index.ts#L725-L726

Added lines #L725 - L726 were not covered by tests
if (obj.${fieldName}.length === ${fieldDef.lengthLimit}) {
throw new CodeError('decode error - repeated field "${fieldName}" had too many elements', 'ERR_MAX_LENGTH')
}
Expand Down Expand Up @@ -750,7 +759,7 @@
if (opts.lengthDelimited !== false) {
w.ldelim()
}
}, (reader, length) => {
}, (reader, length, opts = {}) => {
const obj: any = {${createDefaultObject(fields, messageDef, moduleDef)}}

const end = length == null ? reader.len : reader.pos + length
Expand All @@ -777,8 +786,8 @@
return encodeMessage(obj, ${messageDef.name}.codec())
}

export const decode = (buf: Uint8Array | Uint8ArrayList): ${messageDef.name} => {
return decodeMessage(buf, ${messageDef.name}.codec())
export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<${messageDef.name}>): ${messageDef.name} => {
return decodeMessage(buf, ${messageDef.name}.codec(), opts)
}`

return `
Expand Down
14 changes: 7 additions & 7 deletions packages/protons/test/fixtures/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
/* eslint-disable @typescript-eslint/no-empty-interface */

import { type Codec, decodeMessage, encodeMessage, message } from 'protons-runtime'
import { type Codec, decodeMessage, type DecodeOptions, encodeMessage, message } from 'protons-runtime'
import type { Uint8ArrayList } from 'uint8arraylist'

export interface Basic {
Expand Down Expand Up @@ -35,7 +35,7 @@ export namespace Basic {
if (opts.lengthDelimited !== false) {
w.ldelim()
}
}, (reader, length) => {
}, (reader, length, opts = {}) => {
const obj: any = {
num: 0
}
Expand Down Expand Up @@ -72,8 +72,8 @@ export namespace Basic {
return encodeMessage(obj, Basic.codec())
}

export const decode = (buf: Uint8Array | Uint8ArrayList): Basic => {
return decodeMessage(buf, Basic.codec())
export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<Basic>): Basic => {
return decodeMessage(buf, Basic.codec(), opts)
}
}

Expand All @@ -92,7 +92,7 @@ export namespace Empty {
if (opts.lengthDelimited !== false) {
w.ldelim()
}
}, (reader, length) => {
}, (reader, length, opts = {}) => {
const obj: any = {}

const end = length == null ? reader.len : reader.pos + length
Expand All @@ -119,7 +119,7 @@ export namespace Empty {
return encodeMessage(obj, Empty.codec())
}

export const decode = (buf: Uint8Array | Uint8ArrayList): Empty => {
return decodeMessage(buf, Empty.codec())
export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<Empty>): Empty => {
return decodeMessage(buf, Empty.codec(), opts)
}
}
Loading
Loading