@@ 55,11 55,13 @@ enum {
PB_KIND_MAP,
};
-/* Wire types per proto3 spec. */
+/* Wire types per proto3 spec. SGROUP/EGROUP are proto2-only legacy. */
enum {
PB_WIRE_VARINT = 0,
PB_WIRE_I64 = 1,
PB_WIRE_LEN = 2,
+ PB_WIRE_SGROUP = 3,
+ PB_WIRE_EGROUP = 4,
PB_WIRE_I32 = 5,
};
@@ 121,14 123,19 @@ typedef struct pb_plan_field {
uint8_t packed;
uint8_t repeated;
uint8_t optional;
+ uint8_t required; /* proto2 required — missing-on-encode errors, no zero suppression */
+ uint8_t is_group; /* proto2 group — SGROUP/EGROUP framing instead of LEN */
uint8_t tag_len;
uint8_t tag_bytes[5];
+ uint8_t egroup_tag_len; /* groups only: pre-encoded EGROUP tag */
+ uint8_t egroup_tag_bytes[5];
int sub_plan_idx; /* 1-based into sub_plans table; 0 if none */
uint8_t map_key_kind;
uint8_t map_value_kind;
int map_value_sub_plan_idx; /* 1-based; 0 if value is scalar */
int oneof_idx; /* 0-based into plan->oneofs; -1 if none */
int enum_ref; /* LUA_REGISTRYINDEX ref for enum desc; LUA_NOREF if none */
+ char *full_name; /* extensions: "<package>.<ext_name>" key in data._extensions; NULL for regular fields */
} pb_plan_field;
typedef struct pb_plan_oneof {
@@ 145,6 152,8 @@ typedef struct pb_plan {
pb_plan_oneof *oneofs;
int extension_range_start;
int extension_range_end;
+ int n_extensions; /* proto2 extensions registered on this message */
+ pb_plan_field *extensions; /* extension field shapes; keyed by full_name */
uint8_t has_override;
int override_encode_ref; /* LUA_NOREF if absent */
int override_decode_ref;
@@ 187,9 196,21 @@ plan_free(lua_State *L, pb_plan *p)
for (int i = 0; i < p->n_fields; i++) {
if (p->fields[i].enum_ref != LUA_NOREF)
luaL_unref(L, LUA_REGISTRYINDEX, p->fields[i].enum_ref);
+ if (p->fields[i].full_name != NULL)
+ free(p->fields[i].full_name);
}
free(p->fields);
}
+ if (p->extensions != NULL) {
+ for (int i = 0; i < p->n_extensions; i++) {
+ if (p->extensions[i].enum_ref != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX,
+ p->extensions[i].enum_ref);
+ if (p->extensions[i].full_name != NULL)
+ free(p->extensions[i].full_name);
+ }
+ free(p->extensions);
+ }
if (p->oneofs != NULL) {
for (int i = 0; i < p->n_oneofs; i++) {
free(p->oneofs[i].name);
@@ 304,7 325,7 @@ compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
lua_rawseti(L, field_names_idx, field_idx_1based);
lua_pop(L, 1);
- /* repeated / packed / optional */
+ /* repeated / packed / optional / required (proto2) */
lua_getfield(L, f_desc_idx, "repeated");
field->repeated = lua_toboolean(L, -1) ? 1 : 0;
lua_pop(L, 1);
@@ 314,6 335,9 @@ compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
lua_getfield(L, f_desc_idx, "optional");
field->optional = lua_toboolean(L, -1) ? 1 : 0;
lua_pop(L, 1);
+ lua_getfield(L, f_desc_idx, "required");
+ field->required = lua_toboolean(L, -1) ? 1 : 0;
+ lua_pop(L, 1);
/* kind dispatch on desc.kind */
lua_getfield(L, f_desc_idx, "kind");
@@ 344,14 368,19 @@ compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
} else {
lua_pop(L, 1);
}
- } else if (strcmp(kind_str, "message") == 0) {
+ } else if (strcmp(kind_str, "message") == 0 ||
+ strcmp(kind_str, "group") == 0) {
+ int is_group = (strcmp(kind_str, "group") == 0);
lua_pop(L, 1);
field->kind = PB_KIND_MESSAGE;
- element_wire_type = PB_WIRE_LEN;
+ field->is_group = is_group ? 1 : 0;
+ /* Group wire-type is SGROUP at the field's tag; EGROUP is the
+ * closing bracket, pre-encoded separately for the encoder. */
+ element_wire_type = is_group ? PB_WIRE_SGROUP : PB_WIRE_LEN;
lua_getfield(L, f_desc_idx, "message");
if (!lua_istable(L, -1))
- luaL_error(L, "message field '%s' missing 'message' descriptor",
- "?");
+ luaL_error(L, "%s field missing 'message' descriptor",
+ is_group ? "group" : "message");
int sub_desc = lua_gettop(L);
field->sub_plan_idx = resolve_sub_plan(L, sub_desc, sub_plans_idx);
lua_pop(L, 1); /* sub-desc table */
@@ 407,7 436,8 @@ compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
}
/* Wire type: repeated+packed → LEN regardless of element type;
- * repeated unpacked → element type per tag; singular → element. */
+ * repeated unpacked → element type per tag; singular → element.
+ * Groups can't be packed (LEN form), so this branch never fires for them. */
if (field->repeated && field->packed) {
field->wire_type = PB_WIRE_LEN;
} else {
@@ 416,6 446,10 @@ compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field,
encode_tag(field->field_number, field->wire_type,
field->tag_bytes, &field->tag_len);
+ if (field->is_group) {
+ encode_tag(field->field_number, PB_WIRE_EGROUP,
+ field->egroup_tag_bytes, &field->egroup_tag_len);
+ }
}
/* ---------------------------------------------------------------- *
@@ 616,6 650,41 @@ compile_plan_impl(lua_State *L, int desc_idx)
/* Compile oneofs after fields so oneof_idx back-pointers can be set. */
compile_oneofs(L, p, desc_idx, field_names_idx);
+ /* Compile proto2 extensions (registered on this descriptor's
+ * extensions_list array). Each extension shares the field shape; we
+ * additionally cache its full_name string so encode can find the
+ * value in data._extensions[full_name] and decode can stash it back. */
+ lua_getfield(L, desc_idx, "extensions_list");
+ if (lua_istable(L, -1)) {
+ int elist_idx = lua_gettop(L);
+ int n_ext = (int)lua_objlen(L, elist_idx);
+ if (n_ext > 0) {
+ p->n_extensions = n_ext;
+ p->extensions = (pb_plan_field *)calloc(
+ n_ext, sizeof(pb_plan_field));
+ for (int i = 0; i < n_ext; i++) {
+ lua_rawgeti(L, elist_idx, i + 1);
+ int e_desc_idx = lua_gettop(L);
+ /* Reuse compile_field. Pass a throwaway 1-based
+ * index into field_names_idx — extension name
+ * caching there is harmless; we never look it
+ * up since we cache full_name separately. */
+ compile_field(L, e_desc_idx,
+ &p->extensions[i],
+ field_names_idx, sub_plans_idx,
+ n_fields + i + 1);
+ /* Cache full_name for _extensions[key] lookup. */
+ lua_getfield(L, e_desc_idx, "full_name");
+ const char *fn = lua_tostring(L, -1);
+ p->extensions[i].full_name =
+ strdup(fn != NULL ? fn : "");
+ lua_pop(L, 1);
+ lua_pop(L, 1); /* extension desc */
+ }
+ }
+ }
+ lua_pop(L, 1); /* extensions_list (table or nil) */
+
/* Stash the field-names + sub-plans tables in the registry. */
lua_pushvalue(L, sub_plans_idx);
p->sub_plans_ref = luaL_ref(L, LUA_REGISTRYINDEX);
@@ 1371,8 1440,12 @@ push_default_for_kind(lua_State *L, uint8_t kind)
static void encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx);
static void encode_submessage_field(lua_State *L, enc_buf *b, pb_plan *plan,
pb_plan_field *f, int val_idx);
+static void encode_group_field(lua_State *L, enc_buf *b, pb_plan *plan,
+ pb_plan_field *f, int val_idx);
static void encode_map_field(lua_State *L, enc_buf *b, pb_plan *plan,
pb_plan_field *f, int val_idx);
+static void encode_extension(lua_State *L, enc_buf *b, pb_plan *plan,
+ pb_plan_field *ext, int val_idx);
/* Encode a repeated field's elements into `b`. Dispatches on element
* kind and the `packed` plan flag:
@@ 1401,6 1474,15 @@ encode_repeated_field(lua_State *L, enc_buf *b, pb_plan *plan,
return;
if (f->kind == PB_KIND_MESSAGE) {
+ if (f->is_group) {
+ for (int i = 1; i <= n; i++) {
+ lua_rawgeti(L, val_idx, i);
+ int elem = lua_gettop(L);
+ encode_group_field(L, b, plan, f, elem);
+ lua_pop(L, 1);
+ }
+ return;
+ }
for (int i = 1; i <= n; i++) {
lua_rawgeti(L, val_idx, i);
int elem = lua_gettop(L);
@@ 1519,6 1601,77 @@ encode_submessage_field(lua_State *L, enc_buf *b, pb_plan *plan,
lua_settop(L, saved_top);
}
+/* Encode one singular proto2 group field: SGROUP tag, nested body bytes
+ * verbatim (no length prefix), EGROUP tag. The body is built into a fresh
+ * sub-buffer so the SGROUP/EGROUP bracket lands on the parent in one shot;
+ * the lifecycle contract is identical to encode_submessage_field. */
+static void
+encode_group_field(lua_State *L, enc_buf *b, pb_plan *plan,
+ pb_plan_field *f, int val_idx)
+{
+ if (plan->sub_plans_ref == LUA_NOREF)
+ luaL_error(L, "plan '%s' has no sub-plans table",
+ plan->name != NULL ? plan->name : "?");
+
+ if (b->heap_idx == 0)
+ ebuf_grow(L, b, 1);
+
+ val_idx = abs_idx(L, val_idx);
+ int saved_top = lua_gettop(L);
+
+ lua_rawgeti(L, LUA_REGISTRYINDEX, plan->sub_plans_ref);
+ lua_rawgeti(L, -1, f->sub_plan_idx);
+ pb_plan *subplan = (pb_plan *)lua_touserdata(L, -1);
+ if (subplan == NULL)
+ luaL_error(L, "group sub-plan at index %d is not a userdata",
+ f->sub_plan_idx);
+
+ if (lua_type(L, val_idx) != LUA_TTABLE)
+ luaL_error(L, "group field requires a table value");
+
+ enc_buf sub;
+ ebuf_init(&sub);
+ encode_body(L, &sub, subplan, val_idx);
+
+ /* SGROUP tag + body + EGROUP tag (no length prefix). */
+ ebuf_reserve(L, b, f->tag_len + sub.used + f->egroup_tag_len);
+ ebuf_put_tag(b, f);
+ if (sub.used > 0)
+ ebuf_put_bytes(b, ebuf_base(&sub), sub.used);
+ memcpy(ebuf_base(b) + b->used, f->egroup_tag_bytes, f->egroup_tag_len);
+ b->used += f->egroup_tag_len;
+
+ lua_settop(L, saved_top);
+}
+
+/* Encode one proto2 extension value into the parent buffer `b`. The
+ * extension field shape mirrors a regular field; for the encode dispatch
+ * we route through the same singular/repeated/message/group paths used
+ * by the field-walk. Always force-emit (proto2 extensions are
+ * presence-tracked: a user-set zero must reach the wire). */
+static void
+encode_extension(lua_State *L, enc_buf *b, pb_plan *plan,
+ pb_plan_field *ext, int val_idx)
+{
+ val_idx = abs_idx(L, val_idx);
+ if (ext->repeated) {
+ if (lua_type(L, val_idx) != LUA_TTABLE)
+ luaL_error(L,
+ "repeated extension '%s' requires a table value",
+ ext->full_name != NULL ? ext->full_name : "?");
+ encode_repeated_field(L, b, plan, ext, val_idx);
+ return;
+ }
+ if (ext->kind == PB_KIND_MESSAGE) {
+ if (ext->is_group)
+ encode_group_field(L, b, plan, ext, val_idx);
+ else
+ encode_submessage_field(L, b, plan, ext, val_idx);
+ return;
+ }
+ encode_one_field(L, b, ext, val_idx, /* force_emit */ 1);
+}
+
/* Encode a map<K,V> field into `b`.
*
* Wire shape: each (k, v) pair becomes a length-delimited entry sub-
@@ 1704,6 1857,16 @@ encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx)
int val_idx = lua_gettop(L);
if (lua_isnil(L, val_idx)) {
+ /* Proto2 required: missing → hard error with full path,
+ * matching codec.lua's build_required_writer. */
+ if (f->required) {
+ lua_rawgeti(L, names_idx, i + 1);
+ const char *fname = lua_tostring(L, -1);
+ luaL_error(L,
+ "required field missing on encode: %s.%s",
+ plan->name != NULL ? plan->name : "?",
+ fname != NULL ? fname : "?");
+ }
lua_pop(L, 1);
continue;
}
@@ 1716,14 1879,42 @@ encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx)
"repeated field requires a table value");
encode_repeated_field(L, b, plan, f, val_idx);
} else if (f->kind == PB_KIND_MESSAGE) {
- encode_submessage_field(L, b, plan, f, val_idx);
+ if (f->is_group)
+ encode_group_field(L, b, plan, f, val_idx);
+ else
+ encode_submessage_field(L, b, plan, f, val_idx);
} else {
- int force = f->oneof_idx >= 0 ? 1 : 0;
+ /* Force emit when the field has presence: oneof member,
+ * proto2 required, or proto2 explicit-optional. The
+ * non-forced path proto3-elides zeros. */
+ int force = (f->oneof_idx >= 0 || f->required) ? 1 : 0;
encode_one_field(L, b, f, val_idx, force);
}
lua_pop(L, 1);
}
+ /* Proto2 extensions: walk plan->extensions and emit each present
+ * entry from data._extensions[ext.full_name]. Registration order
+ * == iteration order (matches codec.lua's extensions_list walk). */
+ if (plan->n_extensions > 0) {
+ lua_getfield(L, msg_idx, "_extensions");
+ if (lua_type(L, -1) == LUA_TTABLE) {
+ int exts_idx = lua_gettop(L);
+ for (int i = 0; i < plan->n_extensions; i++) {
+ pb_plan_field *ext = &plan->extensions[i];
+ lua_getfield(L, exts_idx,
+ ext->full_name != NULL ?
+ ext->full_name : "");
+ if (!lua_isnil(L, -1)) {
+ encode_extension(L, b, plan, ext,
+ lua_gettop(L));
+ }
+ lua_pop(L, 1);
+ }
+ }
+ lua_pop(L, 1); /* _extensions (table or nil) */
+ }
+
/* Re-emit captured unknown bytes at the tail (bd-wyp / ra6 3j).
* Mirrors codec.lua's `encode_message`: nil or "" are no-ops; any
* non-empty string is appended verbatim. */
@@ 1845,9 2036,20 @@ dec_fixed64(dec_ctx *c)
return v;
}
+/* For SGROUP recursion we need to thread the opening field id so the closing
+ * EGROUP can be id-matched per proto2 spec. Mirrors runtime/pb/wire.lua's
+ * skip_field(buf, pos, wt, field_id). */
+static void dec_skip_with_id(dec_ctx *c, uint8_t wt, uint32_t field_id);
+
static void
dec_skip(dec_ctx *c, uint8_t wt)
{
+ dec_skip_with_id(c, wt, 0);
+}
+
+static void
+dec_skip_with_id(dec_ctx *c, uint8_t wt, uint32_t field_id)
+{
switch (wt) {
case PB_WIRE_VARINT:
(void)dec_varint(c);
@@ 1865,6 2067,31 @@ dec_skip(dec_ctx *c, uint8_t wt)
c->pos += (size_t)plen;
break;
}
+ case PB_WIRE_SGROUP: {
+ if (field_id == 0)
+ luaL_error(c->L,
+ "skip SGROUP requires field id for EGROUP match");
+ while (c->pos < c->len) {
+ uint64_t itag = dec_varint(c);
+ uint32_t iid = (uint32_t)(itag >> 3);
+ uint8_t iwt = (uint8_t)(itag & 0x07);
+ if (iwt == PB_WIRE_EGROUP) {
+ if (iid != field_id)
+ luaL_error(c->L,
+ "EGROUP id %d does not match SGROUP id %d",
+ (int)iid, (int)field_id);
+ return;
+ }
+ dec_skip_with_id(c, iwt, iid);
+ }
+ luaL_error(c->L,
+ "unterminated SGROUP for field id %d", (int)field_id);
+ break;
+ }
+ case PB_WIRE_EGROUP:
+ luaL_error(c->L, "unexpected EGROUP for field id %d",
+ (int)field_id);
+ break;
default:
luaL_error(c->L, "unsupported wire type %d for skip", (int)wt);
}
@@ 1992,12 2219,22 @@ dec_push_one(dec_ctx *c, pb_plan_field *f)
dec_push_kind(c, f->kind);
}
-/* Forward decl for the recursive decode. */
-static void decode_body(dec_ctx *c, pb_plan *plan, int result_idx);
+/* Forward decl for the recursive decode.
+ *
+ * `stop_group_id`: 0 means decode to end of c->len (normal message); non-zero
+ * means we're inside a proto2 group body and the loop terminates on the
+ * matching EGROUP tag. After EGROUP, c->pos sits just past the closing tag.
+ * Mirrors codec.lua's decode_group. */
+static void decode_body(dec_ctx *c, pb_plan *plan, int result_idx,
+ uint32_t stop_group_id);
static void decode_submessage_field(dec_ctx *c, pb_plan_field *f,
int sub_plans_idx);
+static void decode_group_field(dec_ctx *c, pb_plan_field *f, int sub_plans_idx);
+static void decode_extension_into(dec_ctx *c, pb_plan_field *ext, uint8_t wt,
+ int sub_plans_idx, int result_idx);
static void decode_map_entry(dec_ctx *c, pb_plan_field *f, int sub_plans_idx,
int map_idx);
+static inline int field_is_packable(const pb_plan_field *f);
/* Decode one singular sub-message field. On entry, `c->pos` points at
* the length-varint byte; on exit, `c->pos == c->pos + plen`. Pushes
@@ 2038,7 2275,7 @@ decode_submessage_field(dec_ctx *c, pb_plan_field *f, int sub_plans_idx)
* spill past it. */
size_t saved_len = c->len;
c->len = c->pos + (size_t)plen;
- decode_body(c, subplan, sub_result_idx);
+ decode_body(c, subplan, sub_result_idx, /* stop_group_id */ 0);
if (c->pos != c->len)
luaL_error(L,
"nested message body underflow at offset %d (expected %d)",
@@ 2046,6 2283,118 @@ decode_submessage_field(dec_ctx *c, pb_plan_field *f, int sub_plans_idx)
c->len = saved_len;
}
+/* Decode one proto2 group field. On entry, c->pos sits just past the
+ * SGROUP tag — we walk the body via decode_body with stop_group_id set
+ * to the field's id; decode_body terminates on EGROUP with matching id
+ * and leaves c->pos just past the closing tag. The decoded sub-table
+ * is left on top of the Lua stack (mirrors decode_submessage_field). */
+static void
+decode_group_field(dec_ctx *c, pb_plan_field *f, int sub_plans_idx)
+{
+ lua_State *L = c->L;
+ lua_rawgeti(L, sub_plans_idx, f->sub_plan_idx);
+ pb_plan *subplan = (pb_plan *)lua_touserdata(L, -1);
+ if (subplan == NULL)
+ luaL_error(L, "group sub-plan at index %d is not a userdata",
+ f->sub_plan_idx);
+ lua_pop(L, 1);
+
+ lua_createtable(L, 0, subplan->n_fields);
+ int sub_result_idx = lua_gettop(L);
+
+ /* Groups have no length prefix; decode_body walks raw bytes until
+ * the matching EGROUP tag. The outer c->len bound still applies
+ * (unterminated group ⇒ error). */
+ decode_body(c, subplan, sub_result_idx, f->field_number);
+}
+
+/* Decode one proto2 extension's wire bytes into result._extensions[full_name].
+ * Mirrors codec.lua's decode_extension: scalar/enum/message/group, singular
+ * vs repeated, packed-payload handling. */
+static void
+decode_extension_into(dec_ctx *c, pb_plan_field *ext, uint8_t wt,
+ int sub_plans_idx, int result_idx)
+{
+ lua_State *L = c->L;
+
+ /* Find or create result._extensions; leave it on top of the stack
+ * as `exts_idx`. */
+ lua_getfield(L, result_idx, "_extensions");
+ if (!lua_istable(L, -1)) {
+ lua_pop(L, 1);
+ lua_createtable(L, 0, 4);
+ lua_pushvalue(L, -1);
+ lua_setfield(L, result_idx, "_extensions");
+ }
+ int exts_idx = lua_gettop(L);
+ const char *key = ext->full_name != NULL ? ext->full_name : "";
+
+ if (ext->repeated) {
+ lua_getfield(L, exts_idx, key);
+ if (!lua_istable(L, -1)) {
+ lua_pop(L, 1);
+ lua_createtable(L, 0, 0);
+ lua_pushvalue(L, -1);
+ lua_setfield(L, exts_idx, key);
+ }
+ int list_idx = lua_gettop(L);
+
+ if (ext->kind == PB_KIND_MESSAGE) {
+ if (ext->is_group) {
+ if (wt != PB_WIRE_SGROUP)
+ luaL_error(L,
+ "repeated group extension '%s' expected wire 3, got %d",
+ key, (int)wt);
+ decode_group_field(c, ext, sub_plans_idx);
+ } else {
+ if (wt != PB_WIRE_LEN)
+ luaL_error(L,
+ "repeated message extension '%s' expected wire 2, got %d",
+ key, (int)wt);
+ decode_submessage_field(c, ext, sub_plans_idx);
+ }
+ lua_rawseti(L, list_idx,
+ (int)lua_objlen(L, list_idx) + 1);
+ } else if (wt == PB_WIRE_LEN && field_is_packable(ext)) {
+ /* Packed payload for a packable extension element. */
+ uint64_t plen = dec_varint(c);
+ if (c->len - c->pos < plen)
+ luaL_error(L,
+ "truncated packed extension '%s' payload", key);
+ size_t saved_len = c->len;
+ c->len = c->pos + (size_t)plen;
+ while (c->pos < c->len) {
+ dec_push_one(c, ext);
+ lua_rawseti(L, list_idx,
+ (int)lua_objlen(L, list_idx) + 1);
+ }
+ if (c->pos != c->len)
+ luaL_error(L,
+ "packed extension '%s' underflow", key);
+ c->len = saved_len;
+ } else {
+ dec_push_one(c, ext);
+ lua_rawseti(L, list_idx,
+ (int)lua_objlen(L, list_idx) + 1);
+ }
+ lua_pop(L, 2); /* list, _extensions */
+ return;
+ }
+
+ /* Singular extension. Scalars/enums use last-wins; messages merge. */
+ if (ext->kind == PB_KIND_MESSAGE) {
+ if (ext->is_group)
+ decode_group_field(c, ext, sub_plans_idx);
+ else
+ decode_submessage_field(c, ext, sub_plans_idx);
+ lua_setfield(L, exts_idx, key);
+ } else {
+ dec_push_one(c, ext);
+ lua_setfield(L, exts_idx, key);
+ }
+ lua_pop(L, 1); /* _extensions */
+}
+
/* Decode one map<K,V> entry from the wire and lua_rawset it into the
* map table at absolute stack index `map_idx`.
*
@@ 2122,7 2471,8 @@ decode_map_entry(dec_ctx *c, pb_plan_field *f, int sub_plans_idx,
int new_val = lua_gettop(L);
size_t saved2 = c->len;
c->len = c->pos + (size_t)sub_len;
- decode_body(c, vsub, new_val);
+ decode_body(c, vsub, new_val,
+ /* stop_group_id */ 0);
if (c->pos != c->len)
luaL_error(L,
"nested map<,message> body underflow");
@@ 2172,7 2522,8 @@ field_is_packable(const pb_plan_field *f)
}
static void
-decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
+decode_body(dec_ctx *c, pb_plan *plan, int result_idx,
+ uint32_t stop_group_id)
{
lua_State *L = c->L;
if (plan->override_decode_ref != LUA_NOREF) {
@@ 2235,6 2586,22 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
uint32_t field_number = (uint32_t)(tag >> 3);
uint8_t wt = (uint8_t)(tag & 0x07);
+ /* Proto2 group body: EGROUP with matching id terminates this
+ * decode_body call. A mismatched id is a hard error per spec. */
+ if (wt == PB_WIRE_EGROUP) {
+ if (stop_group_id == 0)
+ luaL_error(L,
+ "unexpected EGROUP for field id %d at top level",
+ (int)field_number);
+ if (field_number != stop_group_id)
+ luaL_error(L,
+ "EGROUP id %d does not match SGROUP id %d",
+ (int)field_number, (int)stop_group_id);
+ /* Successful close — drop into the unknown-fields
+ * tail handling below. */
+ break;
+ }
+
/* Linear scan over plan->fields. n_fields is typically small;
* tag-keyed dispatch table is a future optimization. */
pb_plan_field *f = NULL;
@@ 2247,9 2614,25 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
}
}
- /* Unknown tag — capture tag+payload verbatim into `unknown`. */
+ /* Unknown tag — proto2 extensions get a second chance before
+ * the bytes are stashed verbatim as result._unknown_fields. */
if (f == NULL) {
- dec_skip(c, wt);
+ if (plan->n_extensions > 0) {
+ pb_plan_field *ext = NULL;
+ for (int i = 0; i < plan->n_extensions; i++) {
+ if (plan->extensions[i].field_number
+ == field_number) {
+ ext = &plan->extensions[i];
+ break;
+ }
+ }
+ if (ext != NULL) {
+ decode_extension_into(c, ext, wt,
+ sub_plans_idx, result_idx);
+ continue;
+ }
+ }
+ dec_skip_with_id(c, wt, field_number);
size_t chunk = c->pos - tag_start;
ebuf_reserve(L, &unknown, chunk);
ebuf_put_bytes(&unknown, c->buf + tag_start, chunk);
@@ 2301,13 2684,23 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
list_count[f_idx] = 0;
}
- /* Repeated message: per-element length-delimited body. */
+ /* Repeated message: per-element length-delimited body.
+ * Repeated proto2 group: SGROUP-framed body, one per
+ * element. */
if (f->kind == PB_KIND_MESSAGE) {
- if (wt != PB_WIRE_LEN)
- luaL_error(L,
- "repeated message field %d expected wire 2, got %d",
- (int)field_number, (int)wt);
- decode_submessage_field(c, f, sub_plans_idx);
+ if (f->is_group) {
+ if (wt != PB_WIRE_SGROUP)
+ luaL_error(L,
+ "repeated group field %d expected wire 3, got %d",
+ (int)field_number, (int)wt);
+ decode_group_field(c, f, sub_plans_idx);
+ } else {
+ if (wt != PB_WIRE_LEN)
+ luaL_error(L,
+ "repeated message field %d expected wire 2, got %d",
+ (int)field_number, (int)wt);
+ decode_submessage_field(c, f, sub_plans_idx);
+ }
/* Stack top is the decoded sub-table. */
list_count[f_idx]++;
lua_rawseti(L, list_idx, list_count[f_idx]);
@@ 2351,7 2744,10 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
* Singular dispatch *
* ------------------------------------------------------ */
if (f->kind == PB_KIND_MESSAGE) {
- decode_submessage_field(c, f, sub_plans_idx);
+ if (f->is_group)
+ decode_group_field(c, f, sub_plans_idx);
+ else
+ decode_submessage_field(c, f, sub_plans_idx);
/* stack: ..., names, sub_plans, [lists...], sub_result */
lua_rawgeti(L, names_idx, f_idx + 1);
lua_insert(L, -2); /* name, sub_result */
@@ 2423,7 2819,7 @@ decode_lua(lua_State *L)
c.len = buf_len;
c.pos = 0;
- decode_body(&c, plan, result_idx);
+ decode_body(&c, plan, result_idx, /* stop_group_id */ 0);
return 1;
}
@@ 0,0 1,264 @@
+-- Tests for bd-m7u / ra6 3i: proto2 — required, defaults, groups, extensions.
+--
+-- The proto2 semantics (required-missing on encode, presence-tracked
+-- optionals, SGROUP/EGROUP framing, registered extensions) must match the
+-- pure-Lua codec byte-for-byte. We exercise the same fixtures as
+-- test/proto2_test.lua but route encode/decode through the C runtime, and
+-- compare against the full-mode pure-Lua output for parity.
+
+local t = require('luatest')
+
+local pb = require('pb')
+local c_runtime = pb.c_runtime
+
+local function skip_if_no_c()
+ if c_runtime == nil then
+ t.skip('PB_ENABLE_C not set or pb.c_runtime not available')
+ end
+end
+
+local function hex(s)
+ local out = {}
+ for i = 1, #s do out[i] = string.format('%02x', s:byte(i)) end
+ return table.concat(out)
+end
+
+for _, mode in ipairs({'full', 'runtime'}) do
+ local g = t.group('c_runtime_proto2.' .. mode)
+ local pb2
+ local full
+
+ g.before_all(function()
+ skip_if_no_c()
+ pb2 = require(mode .. '.proto2_basic.proto2_basic_pb')
+ full = require('full.proto2_basic.proto2_basic_pb')
+ end)
+
+ g.before_each(skip_if_no_c)
+
+ -- ---------- Required: error on missing, force-emit at zero ----------
+
+ function g.test_required_missing_errors()
+ local plan = c_runtime.compile_plan(pb2.Cardinality_descriptor)
+ local ok, err = pcall(c_runtime.encode, plan, {})
+ t.assert_equals(ok, false)
+ t.assert_str_contains(err, 'required field missing on encode')
+ t.assert_str_contains(err, 'proto2_basic.Cardinality.r')
+ end
+
+ function g.test_required_zero_emitted_byte_equal()
+ local plan = c_runtime.compile_plan(pb2.Cardinality_descriptor)
+ local c_bytes = c_runtime.encode(plan, {r = 0})
+ -- tag 1 / wire VARINT (0x08) + varint 0.
+ t.assert_equals(hex(c_bytes), '0800')
+ t.assert_equals(c_bytes, full.Cardinality_encode({r = 0}))
+ end
+
+ function g.test_required_set_round_trip()
+ local plan = c_runtime.compile_plan(pb2.Cardinality_descriptor)
+ local bytes = c_runtime.encode(plan, {r = 7})
+ t.assert_equals(bytes, full.Cardinality_encode({r = 7}))
+ local dec = c_runtime.decode(plan, bytes)
+ t.assert_equals(dec.r, 7)
+ end
+
+ function g.test_nested_required_message_missing_errors()
+ local plan = c_runtime.compile_plan(pb2.Nested_descriptor)
+ local ok, err = pcall(c_runtime.encode, plan, {})
+ t.assert_equals(ok, false)
+ t.assert_str_contains(err, 'proto2_basic.Nested.inner')
+ end
+
+ function g.test_nested_required_inner_required_errors()
+ -- Outer .inner is present (table) but inner.x is missing.
+ local plan = c_runtime.compile_plan(pb2.Nested_descriptor)
+ local ok, err = pcall(c_runtime.encode, plan, {inner = {}})
+ t.assert_equals(ok, false)
+ t.assert_str_contains(err, 'proto2_basic.Nested.Inner.x')
+ end
+
+ function g.test_nested_required_filled_round_trips_byte_equal()
+ local plan = c_runtime.compile_plan(pb2.Nested_descriptor)
+ local val = {inner = {x = 5}, inner_opt = {x = 9}}
+ local c_bytes = c_runtime.encode(plan, val)
+ t.assert_equals(c_bytes, full.Nested_encode(val))
+ local dec = c_runtime.decode(plan, c_bytes)
+ t.assert_equals(dec.inner.x, 5)
+ t.assert_equals(dec.inner_opt.x, 9)
+ end
+
+ -- ---------- Defaults: presence-tracked, not auto-emitted ----------
+
+ function g.test_empty_message_round_trips_to_empty_bytes()
+ local plan = c_runtime.compile_plan(pb2.Defaults_descriptor)
+ local c_bytes = c_runtime.encode(plan, {})
+ t.assert_equals(c_bytes, '', 'no defaults on the wire')
+ t.assert_equals(c_runtime.decode(plan, c_bytes), {})
+ end
+
+ function g.test_set_to_proto_default_still_serializes()
+ local plan = c_runtime.compile_plan(pb2.Defaults_descriptor)
+ local c_bytes = c_runtime.encode(plan, {i = 17})
+ t.assert_not_equals(c_bytes, '')
+ t.assert_equals(c_bytes, full.Defaults_encode({i = 17}))
+ local dec = c_runtime.decode(plan, c_bytes)
+ t.assert_equals(dec.i, 17)
+ end
+
+ function g.test_defaults_set_value_round_trip()
+ local plan = c_runtime.compile_plan(pb2.Defaults_descriptor)
+ local val = {i = 42, s = 'world', b = false, f = -1.5}
+ local c_bytes = c_runtime.encode(plan, val)
+ t.assert_equals(c_bytes, full.Defaults_encode(val))
+ local dec = c_runtime.decode(plan, c_bytes)
+ t.assert_equals(dec.i, 42)
+ t.assert_equals(dec.s, 'world')
+ t.assert_equals(dec.b, false)
+ t.assert_equals(dec.f, -1.5)
+ -- Defaults are NOT auto-filled on decode for absent fields.
+ t.assert_equals(dec.d, nil)
+ t.assert_equals(dec.color, nil)
+ end
+
+ -- ---------- Groups: SGROUP/EGROUP framing ----------
+
+ function g.test_group_singular_wire_bytes_byte_equal()
+ local plan = c_runtime.compile_plan(pb2.WithGroup_descriptor)
+ local val = {singlegroup = {a = 7, s = 'ok'}}
+ local c_bytes = c_runtime.encode(plan, val)
+ -- field 1 SGROUP (tag 0x0b), a=7 (0x10 0x07), s='ok' (0x1a 0x02 'ok'),
+ -- EGROUP (tag 0x0c).
+ t.assert_equals(hex(c_bytes),
+ '0b' .. '10' .. '07' .. '1a' .. '02' .. '6f' .. '6b' .. '0c')
+ t.assert_equals(c_bytes, full.WithGroup_encode(val))
+ end
+
+ function g.test_group_round_trip()
+ local plan = c_runtime.compile_plan(pb2.WithGroup_descriptor)
+ local val = {singlegroup = {a = 7, s = 'ok'}}
+ local bytes = c_runtime.encode(plan, val)
+ local dec = c_runtime.decode(plan, bytes)
+ t.assert_equals(dec.singlegroup.a, 7)
+ t.assert_equals(dec.singlegroup.s, 'ok')
+ end
+
+ function g.test_repeated_group_byte_equal()
+ local plan = c_runtime.compile_plan(pb2.WithGroup_descriptor)
+ local val = {repgroup = {{n = 1}, {n = 2}}}
+ local c_bytes = c_runtime.encode(plan, val)
+ -- Each rep wraps its own SGROUP(4)/EGROUP(4) bracket.
+ t.assert_equals(hex(c_bytes),
+ '23' .. '28' .. '01' .. '24' ..
+ '23' .. '28' .. '02' .. '24')
+ t.assert_equals(c_bytes, full.WithGroup_encode(val))
+
+ local dec = c_runtime.decode(plan, c_bytes)
+ t.assert_equals(#dec.repgroup, 2)
+ t.assert_equals(dec.repgroup[1].n, 1)
+ t.assert_equals(dec.repgroup[2].n, 2)
+ end
+
+ function g.test_group_decode_of_full_emit_bytes()
+ -- Decode wire bytes produced by the pure-Lua encoder (which is the
+ -- conformance reference). Catches any SGROUP/EGROUP framing skew.
+ local plan = c_runtime.compile_plan(pb2.WithGroup_descriptor)
+ local val = {
+ singlegroup = {a = 11, s = 'wkt'},
+ repgroup = {{n = 100}, {n = 200}, {n = 300}},
+ }
+ local bytes = full.WithGroup_encode(val)
+ local dec = c_runtime.decode(plan, bytes)
+ t.assert_equals(dec.singlegroup.a, 11)
+ t.assert_equals(dec.singlegroup.s, 'wkt')
+ t.assert_equals(dec.repgroup[1].n, 100)
+ t.assert_equals(dec.repgroup[2].n, 200)
+ t.assert_equals(dec.repgroup[3].n, 300)
+ end
+
+ -- ---------- Extensions: registered into extendee._extensions ----------
+
+ function g.test_extension_round_trip_byte_equal()
+ local plan = c_runtime.compile_plan(pb2.BenchPayload_descriptor)
+ local msg = {
+ id = 7,
+ _extensions = {
+ ['proto2_basic.ext_count'] = 42,
+ ['proto2_basic.ext_label'] = 'tag',
+ },
+ }
+ local c_bytes = c_runtime.encode(plan, msg)
+ t.assert_equals(c_bytes, full.BenchPayload_encode(msg),
+ 'C encode of extensions must match full-mode byte-for-byte')
+ local dec = c_runtime.decode(plan, c_bytes)
+ t.assert_equals(dec.id, 7)
+ t.assert_equals(dec._extensions['proto2_basic.ext_count'], 42)
+ t.assert_equals(dec._extensions['proto2_basic.ext_label'], 'tag')
+ end
+
+ function g.test_extension_absent_emits_nothing()
+ local plan = c_runtime.compile_plan(pb2.BenchPayload_descriptor)
+ local msg = {id = 1}
+ local c_bytes = c_runtime.encode(plan, msg)
+ t.assert_equals(c_bytes, full.BenchPayload_encode(msg))
+ t.assert_equals(hex(c_bytes), '0801')
+ end
+
+ function g.test_extension_decode_from_full_emit_bytes()
+ -- Wire bytes for a registered extension must land in _extensions,
+ -- not in _unknown_fields, when the extension is registered on the
+ -- descriptor at plan-compile time.
+ local plan = c_runtime.compile_plan(pb2.BenchPayload_descriptor)
+ local msg = {
+ id = 9,
+ _extensions = {['proto2_basic.ext_count'] = 17},
+ }
+ local bytes = full.BenchPayload_encode(msg)
+ local dec = c_runtime.decode(plan, bytes)
+ t.assert_equals(dec.id, 9)
+ t.assert_equals(dec._extensions['proto2_basic.ext_count'], 17)
+ t.assert_equals(dec._unknown_fields, nil,
+ 'registered extensions must not fall through to _unknown_fields')
+ end
+
+ -- ---------- BenchPayload: combined required + group + extensions ----------
+
+ function g.test_benchpayload_full_round_trip_byte_equal()
+ local plan = c_runtime.compile_plan(pb2.BenchPayload_descriptor)
+ local val = {
+ id = 1,
+ name = 'x',
+ retries = 5,
+ lucky_numbers = {1, 2, 3},
+ tags = {'a', 'b'},
+ inner = {key = 'k', weight = 9},
+ stats = {latency_ns = 1234, attempts = 2},
+ _extensions = {
+ ['proto2_basic.ext_count'] = 11,
+ ['proto2_basic.ext_label'] = 'lbl',
+ },
+ }
+ local c_bytes = c_runtime.encode(plan, val)
+ t.assert_equals(c_bytes, full.BenchPayload_encode(val),
+ 'BenchPayload byte-equality with full mode')
+
+ local dec = c_runtime.decode(plan, c_bytes)
+ t.assert_equals(dec.id, 1)
+ t.assert_equals(dec.name, 'x')
+ t.assert_equals(dec.retries, 5)
+ t.assert_equals(dec.lucky_numbers, {1, 2, 3})
+ t.assert_equals(dec.tags, {'a', 'b'})
+ t.assert_equals(dec.inner.key, 'k')
+ t.assert_equals(dec.inner.weight, 9)
+ t.assert_equals(dec.stats.latency_ns, 1234)
+ t.assert_equals(dec.stats.attempts, 2)
+ t.assert_equals(dec._extensions['proto2_basic.ext_count'], 11)
+ t.assert_equals(dec._extensions['proto2_basic.ext_label'], 'lbl')
+ end
+
+ function g.test_benchpayload_required_missing_errors()
+ local plan = c_runtime.compile_plan(pb2.BenchPayload_descriptor)
+ local ok, err = pcall(c_runtime.encode, plan, {name = 'no_id'})
+ t.assert_equals(ok, false)
+ t.assert_str_contains(err, 'proto2_basic.BenchPayload.id')
+ end
+end