diff --git a/runtime/pb/c/c_runtime.c b/runtime/pb/c/c_runtime.c index 94f16a420487357ef5cf9213ea48cb1adc66ba3a..66aba31fd09f46c66e60fd4ebd16ab9e7c930b6c 100644 --- a/runtime/pb/c/c_runtime.c +++ b/runtime/pb/c/c_runtime.c @@ -55,11 +55,13 @@ PB_KIND_MESSAGE, 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 @@ uint8_t kind; 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: "." key in data._extensions; NULL for regular fields */ } pb_plan_field; typedef struct pb_plan_oneof { @@ -145,6 +152,8 @@ int n_oneofs; 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 @@ if (p->fields != NULL) { 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 @@ lua_pushvalue(L, -1); /* dup */ 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); @@ -313,6 +334,9 @@ field->packed = lua_toboolean(L, -1) ? 1 : 0; lua_pop(L, 1); 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 */ @@ -344,14 +368,19 @@ field->enum_ref = luaL_ref(L, LUA_REGISTRYINDEX); } 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 @@ return; /* unreachable */ } /* 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 @@ } 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 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 @@ /* Forward decls for the recursive encode pair. */ 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 @@ if (n == 0) 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 @@ 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 field into `b`. * * Wire shape: each (k, v) pair becomes a length-delimited entry sub- @@ -1704,6 +1857,16 @@ lua_rawget(L, msg_idx); /* push msg[name] */ 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,12 +1879,40 @@ luaL_error(L, "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). @@ -1845,8 +2036,19 @@ c->pos += 8; 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: @@ -1865,6 +2067,31 @@ luaL_error(c->L, "truncated length-delimited payload"); 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_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 @@ * the sub-message boundary and so inner wire-prim reads cannot * 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 @@ (int)c->pos, (int)c->len); 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 entry from the wire and lua_rawset it into the * map table at absolute stack index `map_idx`. * @@ -2122,7 +2471,8 @@ lua_createtable(L, 0, vsub->n_fields); 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 @@ } } 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 @@ uint64_t tag = dec_varint(c); 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 @@ break; } } - /* 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 @@ list_stack_idx[f_idx] = list_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 @@ /* ------------------------------------------------------ * * 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 @@ c.buf = (const uint8_t *)buf; 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; } diff --git a/test/c_runtime_proto2_test.lua b/test/c_runtime_proto2_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..83d971e2161a024ddac290203b1c5f56db681128 --- /dev/null +++ b/test/c_runtime_proto2_test.lua @@ -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