diff --git a/packages/protons-runtime/package.json b/packages/protons-runtime/package.json index 1e3d8a2..6e23cae 100644 --- a/packages/protons-runtime/package.json +++ b/packages/protons-runtime/package.json @@ -14,6 +14,7 @@ "type": "module", "types": "./dist/src/index.d.ts", "files": [ + "protons.proto", "src", "dist", "!dist/test", diff --git a/packages/protons-runtime/protons.proto b/packages/protons-runtime/protons.proto new file mode 100644 index 0000000..6e16410 --- /dev/null +++ b/packages/protons-runtime/protons.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; + +import "google/protobuf/descriptor.proto"; + +package protons; + +message ProtonsOptions { + // limit the number of repeated fields or map entries that will be decoded + optional int32 limit = 1; +} + +// custom options available for use by protons +extend google.protobuf.FieldOptions { + optional ProtonsOptions options = 1186; +} diff --git a/packages/protons-runtime/src/index.ts b/packages/protons-runtime/src/index.ts index d22fa35..e0e53d5 100644 --- a/packages/protons-runtime/src/index.ts +++ b/packages/protons-runtime/src/index.ts @@ -326,3 +326,13 @@ export interface Reader { */ sfixed64String(): string } + +export class CodeError extends Error { + public code: string + + constructor (message: string, code: string, options?: ErrorOptions) { + super(message, options) + + this.code = code + } +} diff --git a/packages/protons/bin/protons.ts b/packages/protons/bin/protons.ts index ebb215b..4565ffa 100644 --- a/packages/protons/bin/protons.ts +++ b/packages/protons/bin/protons.ts @@ -11,6 +11,7 @@ async function main (): Promise { Options --output, -o Path to a directory to write transpiled typescript files into --strict, -s Causes parsing warnings to become errors + --path, -p Adds a directory to the include path Examples $ protons ./path/to/file.proto ./path/to/other/file.proto @@ -25,6 +26,11 @@ async function main (): Promise { strict: { type: 'boolean', shortFlag: 's' + }, + path: { + type: 'string', + shortFlag: 'p', + isMultiple: true } } }) diff --git a/packages/protons/src/index.ts b/packages/protons/src/index.ts index 6476832..fa640e7 100644 --- a/packages/protons/src/index.ts +++ b/packages/protons/src/index.ts @@ -430,6 +430,7 @@ interface FieldDef { rule: string optional: boolean repeated: boolean + lengthLimit?: number message: boolean enum: boolean map: boolean @@ -685,13 +686,37 @@ export interface ${messageDef.name} { const parseValue = `${decoderGenerators[type] == null ? `${codec}.decode(reader${type === 'message' ? ', reader.uint32()' : ''})` : decoderGenerators[type](jsTypeOverride)}` if (fieldDef.map) { - return `case ${fieldDef.id}: { + let limit = '' + + if (fieldDef.lengthLimit != null) { + moduleDef.imports.add('CodeError') + + limit = ` + if (obj.${fieldName}.size === ${fieldDef.lengthLimit}) { + throw new CodeError('decode error - map field "${fieldName}" had too many elements', 'ERR_MAX_SIZE') + } +` + } + + return `case ${fieldDef.id}: {${limit} const entry = ${parseValue} obj.${fieldName}.set(entry.key, entry.value) break }` } else if (fieldDef.repeated) { - return `case ${fieldDef.id}: { + let limit = '' + + if (fieldDef.lengthLimit != null) { + moduleDef.imports.add('CodeError') + + limit = ` + if (obj.${fieldName}.length === ${fieldDef.lengthLimit}) { + throw new CodeError('decode error - repeated field "${fieldName}" had too many elements', 'ERR_MAX_LENGTH') + } +` + } + + return `case ${fieldDef.id}: {${limit} obj.${fieldName}.push(${parseValue}) break }` @@ -801,6 +826,7 @@ function defineModule (def: ClassDef, flags: Flags): ModuleDef { fieldDef.repeated = fieldDef.rule === 'repeated' fieldDef.optional = !fieldDef.repeated && fieldDef.options?.proto3_optional === true fieldDef.map = fieldDef.keyType != null + fieldDef.lengthLimit = fieldDef.options?.['(protons.options).limit'] fieldDef.proto2Required = false if (fieldDef.rule === 'required') { @@ -876,11 +902,20 @@ interface Flags { * If true, warnings will be thrown as errors */ strict?: boolean + + /** + * A list of directories to add to the include path + */ + path?: string[] } export async function generate (source: string, flags: Flags): Promise { // convert .protobuf to .json - const json = await promisify(pbjs)(['-t', 'json', source]) + const json = await promisify(pbjs)([ + '-t', 'json', + ...(flags.path ?? []).map(p => ['--path', path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)]).flat(), + source + ]) if (json == null) { throw new Error(`Could not convert ${source} to intermediate JSON format`) diff --git a/packages/protons/test/fixtures/protons-options.proto b/packages/protons/test/fixtures/protons-options.proto new file mode 100644 index 0000000..21bfbe8 --- /dev/null +++ b/packages/protons/test/fixtures/protons-options.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +import "protons.proto"; + +message MessageWithSizeLimitedRepeatedField { + repeated string repeatedField = 1 [(protons.options).limit = 1]; +} + +message MessageWithSizeLimitedMap { + map mapField = 1 [(protons.options).limit = 1]; +} diff --git a/packages/protons/test/fixtures/protons-options.ts b/packages/protons/test/fixtures/protons-options.ts new file mode 100644 index 0000000..0ad6bcc --- /dev/null +++ b/packages/protons/test/fixtures/protons-options.ts @@ -0,0 +1,213 @@ +/* eslint-disable import/export */ +/* eslint-disable complexity */ +/* eslint-disable @typescript-eslint/no-namespace */ +/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */ +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import { encodeMessage, decodeMessage, message, CodeError } from 'protons-runtime' +import type { Codec } from 'protons-runtime' +import type { Uint8ArrayList } from 'uint8arraylist' + +export interface MessageWithSizeLimitedRepeatedField { + repeatedField: string[] +} + +export namespace MessageWithSizeLimitedRepeatedField { + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.repeatedField != null) { + for (const value of obj.repeatedField) { + w.uint32(10) + w.string(value) + } + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length) => { + const obj: any = { + repeatedField: [] + } + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + if (obj.repeatedField.length === 1) { + throw new CodeError('decode error - repeated field "repeatedField" had too many elements', 'ERR_MAX_LENGTH') + } + + obj.repeatedField.push(reader.string()) + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }) + } + + return _codec + } + + export const encode = (obj: Partial): Uint8Array => { + return encodeMessage(obj, MessageWithSizeLimitedRepeatedField.codec()) + } + + export const decode = (buf: Uint8Array | Uint8ArrayList): MessageWithSizeLimitedRepeatedField => { + return decodeMessage(buf, MessageWithSizeLimitedRepeatedField.codec()) + } +} + +export interface MessageWithSizeLimitedMap { + mapField: Map +} + +export namespace MessageWithSizeLimitedMap { + export interface MessageWithSizeLimitedMap$mapFieldEntry { + key: string + value: string + } + + export namespace MessageWithSizeLimitedMap$mapFieldEntry { + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if ((obj.key != null && obj.key !== '')) { + w.uint32(10) + w.string(obj.key) + } + + if ((obj.value != null && obj.value !== '')) { + w.uint32(18) + w.string(obj.value) + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length) => { + const obj: any = { + key: '', + value: '' + } + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + obj.key = reader.string() + break + } + case 2: { + obj.value = reader.string() + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }) + } + + return _codec + } + + export const encode = (obj: Partial): Uint8Array => { + return encodeMessage(obj, MessageWithSizeLimitedMap$mapFieldEntry.codec()) + } + + export const decode = (buf: Uint8Array | Uint8ArrayList): MessageWithSizeLimitedMap$mapFieldEntry => { + return decodeMessage(buf, MessageWithSizeLimitedMap$mapFieldEntry.codec()) + } + } + + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.mapField != null && obj.mapField.size !== 0) { + for (const [key, value] of obj.mapField.entries()) { + w.uint32(10) + MessageWithSizeLimitedMap.MessageWithSizeLimitedMap$mapFieldEntry.codec().encode({ key, value }, w) + } + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length) => { + const obj: any = { + mapField: new Map() + } + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + if (obj.mapField.size === 1) { + throw new CodeError('decode error - map field "mapField" had too many elements', 'ERR_MAX_SIZE') + } + + const entry = MessageWithSizeLimitedMap.MessageWithSizeLimitedMap$mapFieldEntry.codec().decode(reader, reader.uint32()) + obj.mapField.set(entry.key, entry.value) + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }) + } + + return _codec + } + + export const encode = (obj: Partial): Uint8Array => { + return encodeMessage(obj, MessageWithSizeLimitedMap.codec()) + } + + export const decode = (buf: Uint8Array | Uint8ArrayList): MessageWithSizeLimitedMap => { + return decodeMessage(buf, MessageWithSizeLimitedMap.codec()) + } +} diff --git a/packages/protons/test/protons-options.spec.ts b/packages/protons/test/protons-options.spec.ts new file mode 100644 index 0000000..d760bdc --- /dev/null +++ b/packages/protons/test/protons-options.spec.ts @@ -0,0 +1,28 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/chai' +import { MessageWithSizeLimitedMap, MessageWithSizeLimitedRepeatedField } from './fixtures/protons-options.js' + +describe('protons options', () => { + it('should not decode message with map that is too big', () => { + const obj: MessageWithSizeLimitedMap = { + mapField: new Map([['one', 'two'], ['three', 'four']]) + } + + const buf = MessageWithSizeLimitedMap.encode(obj) + + expect(() => MessageWithSizeLimitedMap.decode(buf)) + .to.throw().with.property('code', 'ERR_MAX_SIZE') + }) + + it('should not decode message with list that is too big', () => { + const obj: MessageWithSizeLimitedRepeatedField = { + repeatedField: ['0', '1'] + } + + const buf = MessageWithSizeLimitedRepeatedField.encode(obj) + + expect(() => MessageWithSizeLimitedRepeatedField.decode(buf)) + .to.throw().with.property('code', 'ERR_MAX_LENGTH') + }) +})