Add unknown fields support to Protobuf.js

This commit is contained in:
Josh Perez 2020-07-10 13:48:42 -04:00 committed by Scott Nonnenberg
parent c6d5607b8c
commit 68e432188b
5 changed files with 819 additions and 10081 deletions

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,14 @@
*/ */
/** /**
* @license ProtoBuf.js (c) 2013 Daniel Wirtz <dcode@dcode.io> * @license protobuf.js (c) 2013 Daniel Wirtz <dcode@dcode.io>
* Released under the Apache License, Version 2.0 * Released under the Apache License, Version 2.0
* see: https://github.com/dcodeIO/ProtoBuf.js for details * see: https://github.com/dcodeIO/protobuf.js for details
*/ */
(function(global, factory) { (function(global, factory) {
/* AMD */ if (typeof define === 'function' && define["amd"]) /* AMD */ if (typeof define === 'function' && define["amd"])
define(["ByteBuffer"], factory); define(["bytebuffer"], factory);
/* CommonJS */ else if (typeof require === "function" && typeof module === "object" && module && module["exports"]) /* CommonJS */ else if (typeof require === "function" && typeof module === "object" && module && module["exports"])
module["exports"] = factory(require("bytebuffer"), true); module["exports"] = factory(require("bytebuffer"), true);
/* Global */ else /* Global */ else
@ -57,7 +57,7 @@
* @const * @const
* @expose * @expose
*/ */
ProtoBuf.VERSION = "4.1.2"; ProtoBuf.VERSION = "5.0.1";
/** /**
* Wire types. * Wire types.
@ -725,7 +725,8 @@
// "syntax": undefined // "syntax": undefined
}; };
var token, var token,
head = true; head = true,
weak;
try { try {
while (token = this.tn.next()) { while (token = this.tn.next()) {
switch (token) { switch (token) {
@ -742,11 +743,12 @@
if (!head) if (!head)
throw Error("unexpected 'import'"); throw Error("unexpected 'import'");
token = this.tn.peek(); token = this.tn.peek();
if (token === "public") // ignored if (token === "public" || (weak = token === "weak")) // token ignored
this.tn.next(); this.tn.next();
token = this._readString(); token = this._readString();
this.tn.skip(";"); this.tn.skip(";");
topLevel["imports"].push(token); if (!weak) // import ignored
topLevel["imports"].push(token);
break; break;
case 'syntax': case 'syntax':
if (!head) if (!head)
@ -1071,6 +1073,7 @@
"enums": [], "enums": [],
"messages": [], "messages": [],
"options": {}, "options": {},
"services": [],
"oneofs": {} "oneofs": {}
// "extensions": undefined // "extensions": undefined
}; };
@ -1097,8 +1100,12 @@
this._parseMessage(msg); this._parseMessage(msg);
else if (token === "option") else if (token === "option")
this._parseOption(msg); this._parseOption(msg);
else if (token === "service")
this._parseService(msg);
else if (token === "extensions") else if (token === "extensions")
this._parseExtensions(msg); msg["extensions"] = this._parseExtensionRanges();
else if (token === "reserved")
this._parseIgnored(); // TODO
else if (token === "extend") else if (token === "extend")
this._parseExtend(msg); this._parseExtend(msg);
else if (Lang.TYPEREF.test(token)) { else if (Lang.TYPEREF.test(token)) {
@ -1113,6 +1120,16 @@
return msg; return msg;
}; };
/**
* Parses an ignored statement.
* @private
*/
ParserPrototype._parseIgnored = function() {
while (this.tn.peek() !== ';')
this.tn.next();
this.tn.skip(";");
};
/** /**
* Parses a message field. * Parses a message field.
* @param {!Object} msg Message definition * @param {!Object} msg Message definition
@ -1275,29 +1292,43 @@
}; };
/** /**
* Parses an extensions statement. * Parses extension / reserved ranges.
* @param {!Object} msg Message object * @returns {!Array.<!Array.<number>>}
* @private * @private
*/ */
ParserPrototype._parseExtensions = function(msg) { ParserPrototype._parseExtensionRanges = function() {
var token = this.tn.next(), var ranges = [];
var token,
range,
value;
do {
range = []; range = [];
if (token === "min") while (true) {
range.push(ProtoBuf.ID_MIN); token = this.tn.next();
else if (token === "max") switch (token) {
range.push(ProtoBuf.ID_MAX); case "min":
else value = ProtoBuf.ID_MIN;
range.push(mkNumber(token)); break;
this.tn.skip("to"); case "max":
token = this.tn.next(); value = ProtoBuf.ID_MAX;
if (token === "min") break;
range.push(ProtoBuf.ID_MIN); default:
else if (token === "max") value = mkNumber(token);
range.push(ProtoBuf.ID_MAX); break;
else }
range.push(mkNumber(token)); range.push(value);
if (range.length === 2)
break;
if (this.tn.peek() !== "to") {
range.push(value);
break;
}
this.tn.next();
}
ranges.push(range);
} while (this.tn.omit(","));
this.tn.skip(";"); this.tn.skip(";");
msg["extensions"] = range; return ranges;
}; };
/** /**
@ -1765,9 +1796,10 @@
* @expose * @expose
*/ */
ElementPrototype.verifyValue = function(value) { ElementPrototype.verifyValue = function(value) {
var fail = function(val, msg) { var self = this;
throw Error("Illegal value for "+this.toString(true)+" of type "+this.type.name+": "+val+" ("+msg+")"); function fail(val, msg) {
}.bind(this); throw Error("Illegal value for "+self.toString(true)+" of type "+self.type.name+": "+val+" ("+msg+")");
}
switch (this.type) { switch (this.type) {
// Signed 32bit // Signed 32bit
case ProtoBuf.TYPES["int32"]: case ProtoBuf.TYPES["int32"]:
@ -2257,10 +2289,10 @@
/** /**
* Extensions range. * Extensions range.
* @type {!Array.<number>} * @type {!Array.<number>|undefined}
* @expose * @expose
*/ */
this.extensions = [ProtoBuf.ID_MIN, ProtoBuf.ID_MAX]; this.extensions = undefined;
/** /**
* Runtime message class. * Runtime message class.
@ -2374,6 +2406,14 @@
*/ */
var MessagePrototype = Message.prototype = Object.create(ProtoBuf.Builder.Message.prototype); var MessagePrototype = Message.prototype = Object.create(ProtoBuf.Builder.Message.prototype);
Object.defineProperty(MessagePrototype, '__unknownFields', {
configurable: true,
enumerable: false,
value: null,
writable: true,
});
/** /**
* Adds a value to a repeated field. * Adds a value to a repeated field.
* @name ProtoBuf.Builder.Message#add * @name ProtoBuf.Builder.Message#add
@ -2659,18 +2699,19 @@
* @name ProtoBuf.Builder.Message#encodeDelimited * @name ProtoBuf.Builder.Message#encodeDelimited
* @function * @function
* @param {(!ByteBuffer|boolean)=} buffer ByteBuffer to encode to. Will create a new one and flip it if omitted. * @param {(!ByteBuffer|boolean)=} buffer ByteBuffer to encode to. Will create a new one and flip it if omitted.
* @param {boolean=} noVerify Whether to not verify field values, defaults to `false`
* @return {!ByteBuffer} Encoded message as a ByteBuffer * @return {!ByteBuffer} Encoded message as a ByteBuffer
* @throws {Error} If the message cannot be encoded or if required fields are missing. The later still * @throws {Error} If the message cannot be encoded or if required fields are missing. The later still
* returns the encoded ByteBuffer in the `encoded` property on the error. * returns the encoded ByteBuffer in the `encoded` property on the error.
* @expose * @expose
*/ */
MessagePrototype.encodeDelimited = function(buffer) { MessagePrototype.encodeDelimited = function(buffer, noVerify) {
var isNew = false; var isNew = false;
if (!buffer) if (!buffer)
buffer = new ByteBuffer(), buffer = new ByteBuffer(),
isNew = true; isNew = true;
var enc = new ByteBuffer().LE(); var enc = new ByteBuffer().LE();
T.encode(this, enc).flip(); T.encode(this, enc, noVerify).flip();
buffer.writeVarint32(enc.remaining()); buffer.writeVarint32(enc.remaining());
buffer.append(enc); buffer.append(enc);
return isNew ? buffer.flip() : buffer; return isNew ? buffer.flip() : buffer;
@ -2817,7 +2858,7 @@
return binaryAsBase64 ? obj.toBase64() : obj.toBuffer(); return binaryAsBase64 ? obj.toBase64() : obj.toBuffer();
// Convert Longs to proper objects or strings // Convert Longs to proper objects or strings
if (ProtoBuf.Long.isLong(obj)) if (ProtoBuf.Long.isLong(obj))
return longsAsStrings ? obj.toString() : new ProtoBuf.Long(obj); return longsAsStrings ? obj.toString() : ProtoBuf.Long.fromValue(obj);
var clone; var clone;
// Clone arrays // Clone arrays
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
@ -2879,6 +2920,7 @@
* @name ProtoBuf.Builder.Message.decode * @name ProtoBuf.Builder.Message.decode
* @function * @function
* @param {!ByteBuffer|!ArrayBuffer|!Buffer|string} buffer Buffer to decode from * @param {!ByteBuffer|!ArrayBuffer|!Buffer|string} buffer Buffer to decode from
* @param {(number|string)=} length Message length. Defaults to decode all the remainig data.
* @param {string=} enc Encoding if buffer is a string: hex, utf8 (not recommended), defaults to base64 * @param {string=} enc Encoding if buffer is a string: hex, utf8 (not recommended), defaults to base64
* @return {!ProtoBuf.Builder.Message} Decoded message * @return {!ProtoBuf.Builder.Message} Decoded message
* @throws {Error} If the message cannot be decoded or if required fields are missing. The later still * @throws {Error} If the message cannot be decoded or if required fields are missing. The later still
@ -2887,7 +2929,10 @@
* @see ProtoBuf.Builder.Message.decode64 * @see ProtoBuf.Builder.Message.decode64
* @see ProtoBuf.Builder.Message.decodeHex * @see ProtoBuf.Builder.Message.decodeHex
*/ */
Message.decode = function(buffer, enc) { Message.decode = function(buffer, length, enc) {
if (typeof length === 'string')
enc = length,
length = -1;
if (typeof buffer === 'string') if (typeof buffer === 'string')
buffer = ByteBuffer.wrap(buffer, enc ? enc : "base64"); buffer = ByteBuffer.wrap(buffer, enc ? enc : "base64");
buffer = ByteBuffer.isByteBuffer(buffer) ? buffer : ByteBuffer.wrap(buffer); // May throw buffer = ByteBuffer.isByteBuffer(buffer) ? buffer : ByteBuffer.wrap(buffer); // May throw
@ -3082,6 +3127,9 @@
err["encoded"] = buffer; // Still expose what we got err["encoded"] = buffer; // Still expose what we got
throw(err); throw(err);
} }
if (message.__unknownFields) {
buffer.append(message.__unknownFields);
}
return buffer; return buffer;
}; };
@ -3148,7 +3196,7 @@
/** /**
* Decodes an encoded message and returns the decoded message. * Decodes an encoded message and returns the decoded message.
* @param {ByteBuffer} buffer ByteBuffer to decode from * @param {ByteBuffer} buffer ByteBuffer to decode from
* @param {number=} length Message length. Defaults to decode all the available data. * @param {number=} length Message length. Defaults to decode all remaining data.
* @param {number=} expectedGroupEndId Expected GROUPEND id if this is a legacy group * @param {number=} expectedGroupEndId Expected GROUPEND id if this is a legacy group
* @return {ProtoBuf.Builder.Message} Decoded message * @return {ProtoBuf.Builder.Message} Decoded message
* @throws {Error} If the message cannot be decoded * @throws {Error} If the message cannot be decoded
@ -3168,8 +3216,16 @@
throw Error("Illegal group end indicator for "+this.toString(true)+": "+id+" ("+(expectedGroupEndId ? expectedGroupEndId+" expected" : "not a group")+")"); throw Error("Illegal group end indicator for "+this.toString(true)+": "+id+" ("+(expectedGroupEndId ? expectedGroupEndId+" expected" : "not a group")+")");
break; break;
} }
// "messages created by your new code can be parsed by your old code: old binaries simply append the buffer to unknownFields when parsing.
if (!(field = this._fieldsById[id])) { if (!(field = this._fieldsById[id])) {
// "messages created by your new code can be parsed by your old code: old binaries simply ignore the new field when parsing." // Finds the starting offset to slice
let start = buffer.offset;
do {
--start;
buffer.offset = start;
} while (buffer.readVarint32() !== tag);
// Skip the piece in the buffer
switch (wireType) { switch (wireType) {
case ProtoBuf.WIRE_TYPES.VARINT: case ProtoBuf.WIRE_TYPES.VARINT:
buffer.readVarint32(); buffer.readVarint32();
@ -3190,6 +3246,14 @@
default: default:
throw Error("Illegal wire type for unknown field "+id+" in "+this.toString(true)+"#decode: "+wireType); throw Error("Illegal wire type for unknown field "+id+" in "+this.toString(true)+"#decode: "+wireType);
} }
// Slice the part of the buffer we can't parse and add it to unknownFields
const unknownFields = msg.__unknownFields ? msg.__unknownFields : new ByteBuffer(0);
const slicedBuffer = buffer.slice(start, buffer.offset);
msg.__unknownFields = ByteBuffer.concat([
unknownFields,
slicedBuffer
]);
continue; continue;
} }
if (field.repeated && !field.options["packed"]) { if (field.repeated && !field.options["packed"]) {
@ -3398,9 +3462,10 @@
*/ */
FieldPrototype.verifyValue = function(value, skipRepeated) { FieldPrototype.verifyValue = function(value, skipRepeated) {
skipRepeated = skipRepeated || false; skipRepeated = skipRepeated || false;
var fail = function(val, msg) { var self = this;
throw Error("Illegal value for "+this.toString(true)+" of type "+this.type.name+": "+val+" ("+msg+")"); function fail(val, msg) {
}.bind(this); throw Error("Illegal value for "+self.toString(true)+" of type "+self.type.name+": "+val+" ("+msg+")");
}
if (value === null) { // NULL values for optional fields if (value === null) { // NULL values for optional fields
if (this.required) if (this.required)
fail(typeof value, "required"); fail(typeof value, "required");
@ -4014,6 +4079,9 @@
callback(err); callback(err);
return; return;
} }
// Coalesce to empty string when service response has empty content
if (res === null)
res = ''
try { res = method.resolvedResponseType.clazz.decode(res); } catch (notABuffer) {} try { res = method.resolvedResponseType.clazz.decode(res); } catch (notABuffer) {}
if (!res || !(res instanceof method.resolvedResponseType.clazz)) { if (!res || !(res instanceof method.resolvedResponseType.clazz)) {
callback(Error("Illegal response type received in service method "+ T.name+"#"+method.name)); callback(Error("Illegal response type received in service method "+ T.name+"#"+method.name));
@ -4457,13 +4525,12 @@
subObj.push(svc); subObj.push(svc);
}); });
// Set extension range // Set extension ranges
if (def["extensions"]) { if (def["extensions"]) {
obj.extensions = def["extensions"]; if (typeof def["extensions"][0] === 'number') // pre 5.0.1
if (obj.extensions[0] < ProtoBuf.ID_MIN) obj.extensions = [ def["extensions"] ];
obj.extensions[0] = ProtoBuf.ID_MIN; else
if (obj.extensions[1] > ProtoBuf.ID_MAX) obj.extensions = def["extensions"];
obj.extensions[1] = ProtoBuf.ID_MAX;
} }
// Create on top of current namespace // Create on top of current namespace
@ -4502,8 +4569,16 @@
def["fields"].forEach(function(fld) { def["fields"].forEach(function(fld) {
if (obj.getChild(fld['id']|0) !== null) if (obj.getChild(fld['id']|0) !== null)
throw Error("duplicate extended field id in "+obj.name+": "+fld['id']); throw Error("duplicate extended field id in "+obj.name+": "+fld['id']);
if (fld['id'] < obj.extensions[0] || fld['id'] > obj.extensions[1]) // Check if field id is allowed to be extended
throw Error("illegal extended field id in "+obj.name+": "+fld['id']+" ("+obj.extensions.join(' to ')+" expected)"); if (obj.extensions) {
var valid = false;
obj.extensions.forEach(function(range) {
if (fld["id"] >= range[0] && fld["id"] <= range[1])
valid = true;
});
if (!valid)
throw Error("illegal extended field id in "+obj.name+": "+fld['id']+" (not within valid ranges)");
}
// Convert extension field names to camel case notation if the override is set // Convert extension field names to camel case notation if the override is set
var name = fld["name"]; var name = fld["name"];
if (this.options['convertFieldsToCamelCase']) if (this.options['convertFieldsToCamelCase'])

File diff suppressed because it is too large Load diff

View file

@ -385,6 +385,7 @@
<script type="text/javascript" src="crypto_test.js"></script> <script type="text/javascript" src="crypto_test.js"></script>
<script type="text/javascript" src="database_test.js"></script> <script type="text/javascript" src="database_test.js"></script>
<script type="text/javascript" src="i18n_test.js"></script> <script type="text/javascript" src="i18n_test.js"></script>
<script type="text/javascript" src="protobuf_test.js"></script>
<!-- Comment out to turn off code coverage. Useful for getting real callstacks. --> <!-- Comment out to turn off code coverage. Useful for getting real callstacks. -->
<!-- NOTE: blanket doesn't support modern syntax and will choke until we find a replacement. :0( --> <!-- NOTE: blanket doesn't support modern syntax and will choke until we find a replacement. :0( -->

123
test/protobuf_test.js Normal file
View file

@ -0,0 +1,123 @@
describe('ProtoBuf.js', () => {
const { ProtoBuf } = window.dcodeIO;
const sampleProto = `message Simple_v1 {
optional string knownName = 1;
optional string knownValue = 3;
}
message Simple_v2 {
optional string knownName = 1;
optional int32 unknownFlags = 2;
optional string knownValue = 3;
optional string unknownString = 4;
}`;
it('retains unknown fields', () => {
const builder = ProtoBuf.loadProto(sampleProto);
const protos = builder.build();
const v2 = new protos.Simple_v2();
v2.knownName = 'version2';
v2.unknownFlags = 42;
v2.knownValue = 'known value';
v2.unknownString = 'f';
const v1 = protos.Simple_v1.decode(v2.encode());
const result = protos.Simple_v2.decode(v1.encode());
assert.equal(result.knownName, v2.knownName, 'known fields');
assert.equal(42, result.unknownFlags, 'unknown flag');
assert.equal('f', result.unknownString, 'unknown string');
assert.equal('known value', result.knownValue, 'known value');
});
it('supports nested unknown fields', () => {
const nestedProto = `
${sampleProto}
message Container_v1 {
optional Simple_v1 elem = 1;
}
message Container_v2 {
optional Simple_v2 elem = 1;
}`;
const builder = ProtoBuf.loadProto(nestedProto);
const protos = builder.build();
const v2 = new protos.Container_v2();
v2.elem = {
knownName: 'nested v2',
unknownFlags: 10,
knownValue: 'hello world',
};
const v1 = protos.Container_v1.decode(v2.encode());
const result = protos.Container_v2.decode(v1.encode());
assert.equal(
v2.elem.knownName,
result.elem.knownName,
'nested: known fields'
);
assert.equal(10, result.elem.unknownFlags, 'nested: unknown flags');
assert.equal('hello world', result.elem.knownValue, 'known value');
});
it('allows multi-byte id', () => {
const proto = `message Simple_v1 {
optional string knownName = 1;
optional string knownValue = 3;
}
message Simple_v2 {
optional string knownName = 1;
optional int32 unknownFlags = 296;
optional string knownValue = 3;
}`;
const builder = ProtoBuf.loadProto(proto);
const protos = builder.build();
const v2 = new protos.Simple_v2();
v2.knownName = 'v2 multibyte';
v2.unknownFlags = 16;
v2.knownValue = 'foo bar';
const v1 = protos.Simple_v1.decode(v2.encode());
const result = protos.Simple_v2.decode(v1.encode());
assert.equal(result.knownName, v2.knownName, 'multibyte: known fields');
assert.equal(16, result.unknownFlags, 'multibyte: unknown fields');
assert.equal('foo bar', result.knownValue, 'multibyte: known value');
});
it('retains fields with 64bit type', () => {
const proto = `message Simple_v1 {
optional string knownName = 1;
optional string knownValue = 3;
}
message Simple_v2 {
optional string knownName = 1;
optional double unknownFlags = 2;
optional string knownValue = 3;
}`;
const builder = ProtoBuf.loadProto(proto);
const protos = builder.build();
const v2 = new protos.Simple_v2();
v2.knownName = 'v2 double';
v2.unknownFlags = 0;
v2.knownValue = 'double double';
const v1 = protos.Simple_v1.decode(v2.encode());
const result = protos.Simple_v2.decode(v1.encode());
assert.equal(result.knownName, v2.knownName, 'double: known fields');
assert.equal(0, result.unknownFlags, 'double: unknown fields');
assert.equal('double double', result.knownValue, 'double: known value');
});
});