Conversation
VerdictThe change resolves M9 of #690 correctly, is minimal, and is gated by CI. Findings below are all low severity; none are blocking. The PR is still Does it solve the stated problem?Yes, and it solves it the same way nnstreamer already solves it internally.
Two details that make the guard actually sound rather than accidentally sound:
Error-path handling is correct: at the point of the new Worth recording as a second, unstated benefit: on Regression riskLow. The only newly-rejected inputs are memories shorter than the header they declare, which no valid flexible producer emits — a zero-payload tensor is exactly 128 bytes and still passes ( Findings1. (Low) 2. (Low, coverage) Every test uses a single-memory buffer. 3. (Low, test robustness) 4. (Info) Negative tests wait on a fixed 5. (Nit) Commit message: "84 bytes" -> up to 88 bytes (the Future-change detection and CI gatingConfirmed the tests actually block a merge:
The For detecting breakage introduced by other modules: if a future nnstreamer change altered the flexible header layout, DocumentationNo documentation change is required and none is missing. |
8717989 to
4e49a3c
Compare
VerdictThe three items marked Addressed are genuinely in the tree and are correct. Of the two Not changed items, one rebuttal is technically sound but rests on a reachability claim that looks wrong, and the other answers a different question than the one that was asked — though neither is a reason to block. Two findings that round 1 missed matter more than either of those, and both sit on the changed lines:
Both are cheap to fix. The PR remains 1. Verification of the three "Addressed" items(a) Nit: the new control flow conflates two failure modes — an invalid meta and a failed (b)
It does not fully retire round-1 finding 4: preroll proves the buffer arrived, not that (c) 84 -> 88 bytes — confirmed in the commit body of 2. The two "Not changed" rebuttals2a.
|
4e49a3c to
20ea3ac
Compare
VerdictBoth round-2 blockers (N1, N2) are genuinely fixed, and the restructuring is better than the minimal patch either finding asked for. I found no correctness, safety, or regression defect on the changed lines. Nothing here is blocking. Mergeable, with one gating condition and one correction to the record:
Remaining findings are two nits on the PR narrative and three pre-existing test-style nits. None require a respin. 1. Focus item 1 — is
|
| read | indices | end offset |
|---|---|---|
magic, version, type |
val[0..2] |
12 |
memcpy (meta->dimension, &val[3], 4 * NNS_TENSOR_RANK_LIMIT) |
val[3..18] (rank limit = 16) |
76 |
format, media_type |
val[19], val[20] |
84 |
sparse_info.nnz, on the _NNS_TENSOR_FORMAT_SPARSE branch only |
val[21] |
88 |
GstTensorMetaInfo (tensor_typedef.h:310-326) is uint32_t magic; uint32_t version; uint32_t type; tensor_dim dimension; uint32_t format; uint32_t media_type; union { GstSparseTensorInfo sparse_info; }; = 4 + 4 + 4 + 64 + 4 + 4 + 4 = 88 bytes.
Padding/alignment: every member is uint32_t (tensor_dim is uint32_t[16]; the anonymous union's only member is a one-uint32_t struct). Alignment is 4 for every member and for the aggregate, all offsets are already 4-aligned, and there is no tail padding. sizeof is therefore 88 on every ABI this repo targets — x86, x86_64, armeabi-v7a, arm64-v8a, aarch64, armv7l. There is no ILP32/LP64 divergence to worry about, and no #pragma pack dependence.
So the guard's window and the parser's read window coincide exactly, byte for byte. Round 1's cur_hsize (128) was merely sufficient; sizeof is tight, and the tightness is what makes the second guard reachable (finding N2's real fix — see §3).
Durability. The PR body says the bound "does not depend on how meta versions are numbered". That is correct and is the property that mattered — the whole round-1/round-2 cur_hsize thread is now moot, because the bound is no longer derived from GST_TENSOR_META_IS_V1. One nuance for the record: the exactness holds at NNS_TENSOR_RANK_LIMIT == 16. If the rank limit grew, sizeof grows to (6 + rank) * 4 while the fixed val[19..21] reads stay at 88, so the bound stays sound (just no longer tight). If it ever shrank below 16, parse_header() would already be reading past its own struct — an nnstreamer-internal bug, not something this guard could be expected to absorb. Framed as "the size of the struct the parser fills", the bound is correct by construction. No change wanted.
Comparison with nnstreamer. gst_tensor_meta_info_parse_memory() bounds with hsize (128) before parsing, so it is stricter up front. This PR is deliberately looser up front (88) and catches the 88..127 window in the second guard instead. Net accept/reject behaviour is identical; the only difference is that parse_header is now allowed to run on an 88..127-byte memory, which is safe by the table above. Confirmed intentional and correct.
2. Focus item 2 — is the hsize == 0 guard correct?
Yes, and it cannot reject a valid stream.
GST_TENSOR_META_VERSION_VALID(v)is((v) & 0xDE000000) == 0xDE000000(top byte only);GST_TENSOR_META_IS_V1(v)additionally requires the major-version nibble.gst_tensor_meta_info_get_header_size()returns 128 for V1 and 0 for anything else, including metas that passgst_tensor_meta_info_validate(). Round 2's N1 analysis is confirmed verbatim against the source.- Every conforming producer writes its header through
gst_tensor_meta_info_update_header(), whichg_return_val_if_fails ongst_tensor_meta_info_validate()and whose only in-tree source ofversionisgst_tensor_meta_info_init()->GST_TENSOR_META_VERSION=0xDE001000. Parsed back, that is V1,hsize= 128.hsize == 0is reachable only from a hand-forged or foreign-version header, which is exactly the input class this PR exists to reject. No false-rejection path exists. - The old behaviour with
hsize == 0was to hand the 128 header bytes to the application as payload, described by an attacker-chosentype/dimension. Closing it is right, andsink_unsupported_version_npins it.
Also checked: gst_tensor_meta_info_convert()'s return is still dropped, but it is now provably TRUE — its g_return_val_if_fail (gst_tensor_meta_info_validate (meta)) re-runs a pure predicate on the same unmodified meta that parse_header() already returned TRUE for. No latent CRITICAL remains on this path.
3. Focus item 3 — do the six tests actually reach their branches?
I walked each case through the guard with the real constants (sizeof = 88, hsize = 128 or 0). All six reach distinct, intended arms; none is vacuous.
| test | file | size < 88? |
parse_header |
hsize |
arm actually taken |
|---|---|---|---|---|---|
sink_short_header_n |
16 B | yes | not called (short-circuit) | — | guard 1, size clause |
sink_invalid_magic_n |
128 B all-zero | no | FALSE (magic == 0) |
— | guard 1, parse clause |
sink_unsupported_version_n |
128 B, version = 0xDE000000 |
no | TRUE | 0 | guard 2, hsize == 0 clause |
sink_truncated_header_n |
96 B, complete meta | no (96 >= 88) | TRUE | 128 | guard 2, size < hsize clause |
sink_truncated_header_appsink_n |
96 B | no | TRUE | 128 | same, via cb_appsink_new_sample() |
sink_header_only |
128 B valid | no | TRUE | 128 | accepted |
Spot-checks that the table depends on:
sink_unsupported_version_nreally produces a validating non-V1 meta.GST_TENSOR_META_VERSION=GST_TENSOR_META_MAKE_VERSION(1,0)=0xDE001000;& 0xFF000000=0xDE000000.VERSION_VALID-> true;IS_V1-> false.gst_tensor_meta_info_validate()still passes (magicok,type = _NNS_INT32 < _NNS_END,dimension {4,0,...}rank-1 valid,format = _NNS_TENSOR_FORMAT_STATIC,media_type = _NNS_TENSOR), soupdate_header()writes the file rather than bailing. Insideupdate_header,hsizeis 0 so thememsetis a no-op — harmless, becausecontentis alreadyg_malloc0'd at 128. Correct by construction, not by luck.create_flex_header_file()'ssize == 0guard. This is the fix for the vacuous pass the PR body describes, and it is placed correctly: it fires before the allocation, so a zero-length file can never be written, and every caller doesASSERT_TRUE (file != NULL).content = g_malloc0 (MAX (size, hsize))keepsupdate_header's 88-bytememcpyin bounds even forsize == 16, andg_file_set_contentsthen truncates tosize. No leak on either exit (contentis freed unconditionally;pathis only built in the success branch and cleared on write failure).init_flex_meta()usage. Called by four of the six tests;sink_invalid_magic_ndeliberately does not use it (it passesmeta = NULLto get a zeroed file) and instead uses a locallyinit'd meta only to obtain the 128-byte header size. Consistent, and the_NNS_INT32/dimension[0] = 4choice is what makesvalidate()pass in the three cases that need it to.- Non-vacuity chain.
sink_header_onlyis a genuine control, not a decorative one: it drives the identicalfilesrc ! other/tensors,format=flexible ! tensor_sinkshape and requiresreceived == 1andresult.size == 0.resultis initialised{0U, 1}, so the size assertion cannot pass by omission — the callback must actually run and must actually observe a zero-length tensor. If caps ever stopped being parsed as flexible (get_tensors_info_from_caps->found == FALSE->goto errorbefore the flex branch), or buffers stopped being delivered at all,sink_header_onlyfails loudly while the five_ntests would silently go green. Verifiedml_tensors_data_get_tensor_data()returnsML_ERROR_NONEfor a zero-size tensor (c/src/ml-api-common.c), so that assertion is meaningful rather than accidentally skipped. waitPipelineStateChange (PLAYING)is a real signal here.gst_tensor_sink_class_initsetsrender/render_listbut never callsgst_base_sink_set_async_enabled (FALSE), so async preroll is on andPLAYINGis not reached until a buffer has arrived at the sink. That converts "the file never opened / nothing was pushed" from a silent vacuous pass into an explicitML_ERROR_STREAMS_PIPE. It is checked before the 300 ms settle window, so it does not shorten it.- No double-count risk in
sink_header_only.tensor_sinkemitsSIGNAL_NEW_DATAonly fromgst_tensor_sink_render_buffer(); there is noprerollvfunc override, andml_pipeline_constructconnects onlynew-sample(notnew-preroll) forappsink. So exactly one callback per buffer —EXPECT_EQ (result.received, 1U)is stable, not flaky-by-one. - The "fails on
main" claim holds for all five negative cases. Onmain, each of them either wrapsmap[i].size - hsizeor takes thehsize = 0path intogst_tensor_meta_info_convert()'sg_return_val_if_fail; either way the callback still runs andresult.receivedis 1. Verified by walking the pre-patch code, not taken on trust.
T1 (Low, informational — no change requested)
The new map[i].size < sizeof (GstTensorMetaInfo) clause is reached by sink_short_header_n, but it is not pinned: delete that clause and the suite still goes green. A 16-byte filesrc buffer lives inside a 4096-byte allocation that gst_buffer_resize shrank, so the 88-byte over-read stays in a live block; parse_header then either returns FALSE on garbage or (if the block is a fresh zero page, which is the common case) returns TRUE with hsize = 128, and guard 2 rejects at 16 < 128. Either way received stays 0 and the test passes.
This is the exact mirror image of round 2's N2, now applying to the clause that round 2's fix introduced. Unlike N2, it is inherent rather than an oversight: the clause is a memory-safety bound, and no observable behaviour distinguishes it from guard 2 under filesrc. Making it observable would need an exactly-sized allocation (e.g. appsrc + gst_buffer_new_wrapped) and an ASAN build — which is precisely the combination the PR body already documents as not working through filesrc.
I am not asking for that test. The clause's correctness is a static property (88 == sizeof == the parser's maximum read), established above and stable under the only realistic evolution of the struct. I raise it only so the PR's table is not read as claiming more than it delivers — see T3.
4. Focus item 4 — regressions introduced by the rewrite
None found.
- Accept/reject behaviour on valid inputs is unchanged. The only newly rejected classes are: memories under 88 bytes, memories whose meta fails
validate(), metas that validate but are not V1, and memories under the 128-byte header they declare. No conforming flexible producer emits any of these; a zero-payload flexible tensor is exactly 128 bytes and is explicitly pinned as accepted. - Streaming cost. One comparison against a compile-time constant, plus the existing
get_header_size()call, per tensor per buffer. The round-1/round-2 hoisting ofgst_tensor_meta_info_init/get_header_sizeout of the loop is gone, but it was only compensating for a call the current form no longer makes — the new form does less per-iteration work than either previous version. No_ml_logeon the success path. - Error path. Unchanged and still correct: at both
goto errorsites allnum_tensorsmemories are already mapped by the earlier loop, the flex loop never writesnum_tensors, anderror:runs its ownfor (i = 0; i < num_tensors; i++)._ml_tensors_data_destroy_internal (_data, FALSE)does not free borrowed pointers. Not nullingelem->sinkremains the right call. - Blast radius. 24 changed lines in one
staticfunction;grepconfirmscb_sink_eventis the only consumer ofgst_tensor_meta_info_parse_header/get_header_sizein the whole repository, so there is no sibling copy of the bug and no other module to regress. Theappsinkentry point is covered bysink_truncated_header_appsink_n, which matters because ml-service, the training-offloading receiver and the Android JNI sink all reachcb_sink_eventthat way. - Test-suite stability / runtime. Six tests x (~300 ms settle + setup); the
waitPipelineStateChangepoll costs ~10-30 ms in the healthy case and only reaches its 2 s cap on a genuine failure. ~2-3 s added against a 100 s meson timeout (and the GBS path invokes the binary directly, so no per-test timeout applies there). No shared state between the cases — each creates its owng_mkdtempdirectory, so they are order- and parallel-safe.
5. Focus item 5 — does the PR body match the code?
Substantially yes. I re-derived every load-bearing number (88, 128, 0xDE001000, val[21], the per-test guard column) and they all check out, including the 18446744073709551584 = 96 - 128 figure and the "all five negative cases fail on main" claim. The "Review rounds" section describes what actually happened, and the two deferred items are honestly scoped. Two narrative nits:
T2 (Nit) The _ml_loge methodology proves less than stated
Each negative case was confirmed to reach the guard it is named for by checking which
_ml_logefires
There are two distinct messages ("...header is invalid" / "...does not fit the memory") covering four arms. The log therefore distinguishes guard 1 from guard 2, but not sink_short_header_n from sink_invalid_magic_n, nor sink_unsupported_version_n from sink_truncated_header_n. The within-pair distinction is established by construction (16 < 88 cannot reach the parser; a 128-byte zeroed file cannot fail the size clause), which is sound — it is just not what the sentence claims. Either soften the sentence or, if you want the stronger property mechanically, give the two clauses of each guard their own message.
T3 (Nit) The table's first row overstates coverage
sink_short_header_n's "guard reached" cell is accurate, but per T1 the test does not fail if that guard is removed. Suggest a footnote to that effect, so a future reader does not delete the clause on the strength of a green suite.
6. Focus item 6 — CI gating (correcting rounds 1 and 2), size, docs
C1 (Info, but please act on it before merging) pdebuild does not gate this PR
Rounds 1 and 2 both asserted that debian/rules -> packaging/run_unittests.sh ./tests runs on the "pdebuild Ubuntu 22.04 pull_request job". That job is not running on this PR. gh run list --commit 20ea3ac returns only four workflows — Tizen/GBS, Spell Check, Static checkers, Android Build Test — and gh run list --workflow pdebuild.yml --limit 30 shows ["schedule"] as the only trigger event across its entire recent history, with no run at all since 2026-08-09, despite the on: pull_request: branches: [main] stanza in the file (present on origin/main, so it is not a base-branch problem).
Consequence: the sole PR check that executes unittest_capi_inference is Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1") (packaging/machine-learning-api.spec:429), and it is still pending as I write this. Everything else on the PR is a compile-only or lint job.
So the six new tests are gated, but by one job rather than two, and the redundancy both earlier rounds assumed is not there. Please confirm that job green before merging. (The dormant pdebuild PR trigger looks like a separate repo-infrastructure issue worth a standalone report — it is not this PR's problem.)
Current status: Static checks, DCO, Spell Check, and all four Android build (*) jobs pass. The two unit_test 0 GBS jobs are compile-only.
Code size
24 changed lines in c/src/ml-api-inference-pipeline.c for the fix; 285 added lines of test. The ratio is high but the tests cover four distinct rejection arms plus two entry points plus one positive boundary, so it is earned rather than padded.
T4 (Nit, optional): the six tests repeat the same ~13 lines of g_build_path / g_mkdtemp / g_remove / g_free boilerplate. A small create_flex_temp_dir() helper or a gtest fixture would cut roughly 40 lines and remove six copies of the same cleanup sequence. The file's existing tests do the same thing, so this is consistency-preserving as written — mentioning it only as an option, not a request.
Pre-existing test-style nits (not introduced here, no action needed)
- T5:
result->receivedis written by the callback underG_LOCK (callback_lock)but read unlocked bywait_pipeline_process_buffersandEXPECT_EQ. Technically a data race that TSAN would flag; identical to the existing*count_sinkpattern throughout this file. - T6: the
g_mkdtempdirectory and its file leak ifASSERT_TRUE (dir != NULL)orASSERT_TRUE (file != NULL)fires — same as:329,:623,:974,:7673.
Documentation
Unchanged conclusion from rounds 1 and 2, re-verified: cb_sink_event() is static; no public API, ABI, enum, or error-code change; the ml_pipeline_sink_cb contract is not altered, only made to actually hold. Nothing is missing. If the deferred payload-vs-header item is ever acted on, ml_pipeline_sink_register's documentation would then need a sentence about buffers being dropped silently — but that belongs with that change, not this one.
On the two deferred items
Both are correctly scoped out and the reasoning given is sound:
- Payload not checked against the declared dimension — genuinely a separate defect from M9, genuinely differs for the sparse format, and
sink_header_onlynow carries a@notesaying exactly what it does and does not pin. That is the right way to defer it. Please do file it on [C-Api] Memory-safety audit of C sources: 47 verified issues (6 HIGH / 15 MEDIUM / 26 LOW) — tracking checklist #690. goto errorati > 0— I agree there is no distinct cleanup path to cover, for the reason given in §4. The revised wording ("what they cannot easily produce is one whose second memory is truncated") is a fair and accurate retreat from round 1's "not reachable through the public API", which round 2 was right to challenge.
Summary
No blocking findings. N1 and N2 are properly closed, and the chosen fix — moving the pre-parse bound to sizeof (GstTensorMetaInfo) — is better than either finding asked for, because it makes the second guard reachable and therefore testable instead of dead. The six tests hit six distinct outcomes with a working non-vacuity control.
Recommendation: merge, once Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1") is green (per C1 it is the only job that runs these tests) and the DRAFT / DO NOT MERGE marks are lifted. T1-T6 are optional and can be folded into a later commit or dropped entirely; only T2/T3 (two sentences in the PR body) are worth touching before merge, and neither affects the code.
20ea3ac to
277ec6b
Compare
VerdictNo blocking findings, and nothing that warrants a respin. The test refactor is behaviour-preserving: all six cases still reach the same six distinct arms with the same inputs, and the one ordering that previously caused a vacuous pass is intact. The new helper's resource handling is correct on every exit path and is a small improvement on what round 3 flagged as T6. The PR body's two corrected sentences now match the code. Merge condition (unchanged from round 3's C1, and now stronger): 0. What actually changed since round 3
1. Focus item 1 — do the six tests still reach their intended arms?Yes. Every input that determines the arm is bit-identical before and after the refactor.
The four properties the earlier rounds established as load-bearing all survive:
No new vacuous-pass surface is introduced. The refactor removed the I also confirmed the six new 2. Focus item 2 —
|
| exit | tmpdir |
content |
path |
*dir |
|---|---|---|---|---|
size == 0 |
not created | not allocated | — | NULL |
g_mkdtemp fails |
g_free |
not allocated | — | NULL |
update_header FALSE |
g_remove + g_free |
g_free |
never built | NULL |
g_file_set_contents fails |
g_remove + g_free |
g_free |
g_clear_pointer |
NULL |
| success | transferred to *dir |
g_free |
returned | tmpdir |
content is freed unconditionally; tmpdir is transferred only in the path != NULL branch, so there is exactly one owner in every case. file != NULL implies dir != NULL by construction (*dir = tmpdir is guarded by if (path), and tmpdir is non-NULL there because g_mkdtemp returned it).
Buffer bound is sound. content = g_malloc0 (MAX (size, hsize)) where hsize comes from a freshly init'd _meta and is therefore 128. gst_tensor_meta_info_update_header() (nnstreamer_plugin_api_util_impl.c:1590-1603) writes memset (header, 0, get_header_size (meta)) followed by memcpy (header, meta, sizeof (GstTensorMetaInfo)) — at most max (128, 88) = 128 bytes, since get_header_size() returns only 128 (V1) or 0. So the 16-byte and 96-byte cases write into a 128-byte allocation and g_file_set_contents then truncates to size. In bounds, including for sink_unsupported_version_n, where the meta's own header size is 0 and the memset is a harmless no-op over already-zeroed memory.
Informational only: that bound is derived from a default meta's header size, not from the caller's meta. It is correct today because 128 is the maximum get_header_size() can return, but it is an implicit coupling. Not worth changing in a test helper — recording it so it is not rediscovered as a surprise.
remove_flex_header_file() is correct. File first, then directory — the required order, since g_remove on a non-empty directory fails. g_remove does handle directories: POSIX remove() dispatches to rmdir() for them, and GLib's Win32 path falls back to _wrmdir after _wremove. Both pointers are freed. It has no NULL guard, but it is unreachable with NULL given the ASSERT_TRUE (file != NULL) in all six callers and the file implies dir relation above.
This is a net improvement on round 3's T6. Previously, ASSERT_TRUE (file != NULL) firing leaked fullpath and left the g_mkdtemp directory behind in /tmp. Now the helper removes and frees its own directory before returning NULL, so that path leaks nothing. The residual T6 case — an ASSERT_* inside run_flex_header_pipeline() aborting the test before remove_flex_header_file() runs — still exists and is still identical to the pattern at :329, :623, :974, :7673. No action.
3. Focus item 3 — does the PR body match the code now?
Yes, both round-3 corrections landed accurately, and I re-derived every load-bearing figure independently.
- T2 is properly fixed. The body now says the two guards log different messages and that the within-guard clause "follows from the buffer size", spelling out all four discriminators (16 < 88 vs 128 >= 88;
hsize128 over 96 bytes vshsize0). That is exactly what the code and the six inputs support, and it no longer claims the log distinguishes clauses it cannot distinguish. - T3 is properly fixed, and honestly. "the first row is reached but not pinned", with the correct reason (
filesrcover-allocates, so the over-read stays inside a live block and no sanitizer fires) and the correct consequence (deleting the clause leaves the suite green). I confirmed this independently: a 16-byte file yields a buffer holding the first 16 valid header bytes, and without the size clauseparse_headerwould readval[0..20]out of the surrounding live allocation, after which guard 2 rejects at16 < 128either way. The paragraph claims no more than it delivers. -17 lines netmatchesgit diff --statexactly (52 insertions, 69 deletions).clang-format— I re-ran clang-format 16.0.6 with the repo's.clang-formatover the whole post-refactor test file: zero diff. The body's formatting claim survives the refactor. (Theindentclaim is trivially still true; the C file did not change.)- Re-checked and still holding: 88, 128,
0xDE001000,val[21],18446744073709551584= 96 − 128, the six-row table, and "all five negative cases fail onmain".
Two editorial nits on the body, neither affecting the code:
B1 (Nit) The "Review rounds" section still opens with a stale count
Two review agents have gone over this.
The section then goes on to describe round 3 (and after this comment, round 4). Suggest "Three review agents" / "Four review rounds", or just drop the count.
B2 (Nit) The Verification block's local numbers predate this refactor
242 / 48 / 4 PASSED was measured on the pre-refactor head. The refactor is test-only and does not change the test count, so the numbers remain correct in substance — but the compile-and-run evidence for 277ec6b currently comes from CI rather than from the quoted local run. Either re-run locally or lean explicitly on the GBS job; not worth blocking on, given the CI section below.
4. Other findings (all Low, none requiring action)
- L1 (doc).
create_flex_header_file()'s Doxygen documentsmeta,dirand@return, but notsize.@param dirsays "The caller should free it" without noting that it is set toNULLon failure or thatremove_flex_header_file()is the intended disposer;remove_flex_header_file()documents neither that it takes ownership nor that it frees both arguments. Cosmetic, andStatic checksis green on this head. - L2 (optional simplification).
diris never used for anything but cleanup, and it is alwaysg_path_get_dirname (file). A single-argumentremove_flex_header_file (gchar *file)that derives the directory itself would remove the out-param, its NULL-initialisation question, and one declaration from each of the six tests. Purely a taste call — the explicit out-param is equally defensible, and I am not asking for it. - L3 (pre-existing, unchanged). Round 3's T5 stands:
result->receivedis written underG_LOCK (callback_lock)but read unlocked bywait_pipeline_process_buffersandEXPECT_EQ. Identical to the*count_sinkpattern used throughout this file. No action. - L4 (pre-existing, unchanged). The pipeline string interpolates the temp path into
location="%s". Spaces inTMPDIRare fine inside the quotes; a"or\would not be. Same as every other file-driven test in this file; not introduced here.
Also confirmed as non-issues: g_mkdtemp's template ends in exactly six X; glib/gstdio.h is already included (:12) so g_remove is declared; the redundant (gchar *) cast on g_mkdtemp is correctly gone; no new unused-variable or maybe-uninitialised warning surface; each test still gets its own g_mkdtemp directory, so the suite stays order- and parallel-safe.
5. CI gating on this head
Round 3's C1 is re-verified and has grown more important, because this delta is entirely inside tests/capi/unittest_capi_inference.cc:
- The Android
build (*)jobs build the Android bindings and do not compile the C-API unit tests. - The two
unit_test 0GBS jobs build the package without the test suite. Static checks,Spell CheckandDCOare lint/metadata jobs.
So Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1") is the only check on this PR that compiles the changed file at all, let alone runs the six tests. A typo in the new helper would be invisible everywhere else.
Status as I finish: DCO, Spell Check, Static checks, build (x86), build (x86_64), build (armeabi-v7a) pass; build (arm64-v8a), both unit_test 0 GBS jobs, and the gating unit_test 1 GBS job pending.
Summary
The refactor does exactly what it claims and nothing more: −17 lines, six tests with identical inputs reaching six identical arms, the round-2 ordering fix and the zero-length backstop both preserved, no new vacuous pass, and cleaner failure-path cleanup than before. create_flex_header_file() and remove_flex_header_file() are leak-free and bounds-correct on every path. The two body sentences round 3 asked for are corrected, and every number in the body re-derives.
No remaining issues. Recommendation: merge, once Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1") is green — per the CI section it is the only job that compiles or runs any of this delta — and the DRAFT / DO NOT MERGE marks are lifted. B1, B2 and L1-L4 are optional and can be folded in later or dropped; none of them touches shipped behaviour, and none justifies another round.
myungjoo-bot
left a comment
There was a problem hiding this comment.
Automated review (transcribed from an AI review agent's report; please verify before acting).
Summary: The PR adds three checks to the flexible-tensor loop in cb_sink_event (c/src/ml-api-inference-pipeline.c:364-382): map[i].size >= sizeof (GstTensorMetaInfo) before parsing, use of the gst_tensor_meta_info_parse_header result, and hsize != 0 && hsize <= map[i].size before the pointer/size arithmetic; a rejected buffer takes the existing error: path with sink caps untouched. The defect is confirmed on main 8952123: the parser is called unchecked, its return is ignored, and map[i].size - hsize is gsize arithmetic, so a 96-byte valid-meta buffer hands the app a size of 2^64-32. Verified against the nnstreamer source (v2.6.0-209): GstTensorMetaInfo is 3*u32 + u32[16] + 2*u32 + union{u32} = 88 bytes, all uint32_t, no padding on any target; parse_header reads exactly those 88 bytes before calling validate; GST_TENSOR_META_VERSION_VALID tests only the 0xDE byte while GST_TENSOR_META_IS_V1 additionally needs bit 12, so version = 0xDE000000 validates yet get_header_size returns 0 — the zero check is necessary, not defensive; gst_tensor_meta_info_parse_memory uses the same guard shape. CI: all 10 checks pass, and the GBS x86_64 unit_test 1 log (job 102310634336) shows all 8 nnstreamer_capi_flex tests OK including the six new ones. merge-tree is clean. The actionable items from the four relayed reviews (hsize == 0, parse-arm coverage, the vacuous sink_unsupported_version_n, helper cleanup on failure) are all present on head 277ec6b.
Also verified: all num_tensors memories are mapped in the first loop and the flex loop never modifies num_tensors, so error: unmaps/unrefs exactly 0..num_tensors-1, _ml_tensors_data_destroy_internal (_data, FALSE) does not free borrowed pointers, and no callback runs — no leak, no double-unmap; the static branch is untouched; cb_appsink_new_sample still returns GST_FLOW_OK, so a dropped buffer does not error the pipeline, consistent with the static branch; leaving caps alone is correct since a malformed buffer says nothing about negotiated caps; hsize <= map[i].size (zero payload) is pinned by sink_header_only with result initialised so the size assertion cannot pass by omission; each negative test reaches the claimed arm by construction (16 < 88; 128 zeroed -> parse FALSE; version-masked -> hsize 0; 96 -> 128 > 96) and all five fail on main (callback runs once, received == 1); create_flex_header_file refuses size == 0 and cleans its dir on every failure exit; helper linking is unchanged (unittest_common_dep -> nnstreamer_dep, and the file already used nnstreamer util calls). Single commit, DCO present, message accurate. Approving; the items below are non-blocking.
- [Low]
c/src/ml-api-inference-pipeline.c:367-369, 376-378: the two_ml_logemessages name the element but not the tensor index,map[i].size, orhsize, so a field log cannot tell which clause fired or how short the memory was. Suggest appending" (tensor %u, memory %" G_GSIZE_FORMAT " bytes, header %" G_GSIZE_FORMAT " bytes)". - [Low]
tests/capi/unittest_capi_inference.cc:8250-8253(create_flex_header_fileDoxygen):@param sizeis undocumented and@param dirdoes not say it is NULL on failure / disposed byremove_flex_header_file. - [Low, optional]
c/include/nnstreamer.h:127-136(ml_pipeline_sink_cb): one sentence noting that buffers with a malformed flexible-tensor header are dropped without invoking the callback. Not required; the change only makes the existing contract (valid pointer, truthful size) hold. - [Low, pre-existing, out of scope]
ml-api-inference-pipeline.c:296:gst_tensor_buffer_get_countcan return up toNNS_TENSOR_SIZE_LIMIT(256) andmem[]/map[]are sizedML_TENSOR_SIZE_LIMIT(256); currently safe, but the two constants are independent definitions — worth aG_STATIC_ASSERTin a follow-up.
No back-door or suspicious behavior found: the product change is 19 lines of bounds checking with no new calls or success-path behaviour; test helpers only create/remove files under g_mkdtemp.
Addresses item M9 of nnstreamer#690. cb_sink_event() handed every flexible memory straight to gst_tensor_meta_info_parse_header() and then subtracted the header size it reported from the mapped size: gst_tensor_meta_info_parse_header (&meta, map[i].data); hsize = gst_tensor_meta_info_get_header_size (&meta); ... _data->tensors[i].size = map[i].size - hsize; Nothing there is checked. The parser fills the whole GstTensorMetaInfo out of the pointer it is given before it validates any of it, so a memory shorter than the struct is read past its end. The return value is dropped, so hsize is used even when the meta was rejected. And the subtraction is gsize arithmetic, so it wraps whenever the memory is shorter than the header it declares - the application callback then receives a pointer past the mapped region together with a size near 2^64. A 96 byte buffer carrying a valid meta reports 18446744073709551584 bytes today. All of it is reachable from any pipeline that puts other/tensors,format=flexible in front of tensor_sink or appsink: the memory that arrives is whatever the upstream element produced, and nothing upstream guarantees a complete header. The fix is the guarded form of the same operation, the way gst_tensor_meta_info_parse_memory() does it in nnstreamer: - The memory must be at least sizeof (GstTensorMetaInfo) before the parser touches it. That is exactly the window the parser fills, and it does not depend on how meta versions are numbered. - The parse result decides whether the meta is used at all. - The header size the parsed meta reports must be non-zero and must fit the memory. Zero means the meta validated but carries a version this build cannot place a header for; taking it as a zero-length header would hand the header bytes to the application as payload, described by an info the sender chose. A rejected buffer takes the existing error path, which unmaps and drops the data without invoking the sink callbacks. The sink pad caps are left alone: a malformed buffer is not a renegotiation. Five negative tests cover the branches - a memory shorter than the meta, a meta the parser rejects, a meta whose version has no header size, and a header that does not fit, through both tensor_sink and appsink. All five fail on the code they replace. sink_header_only pins the accepting boundary, where the memory is exactly the header and the payload empty. Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277ec6b to
35b4348
Compare
VerdictNo blocking findings. The Mergeable once the pending builds are green and the 1. The delta:
|
| message | conversion | argument | declared type |
|---|---|---|---|
| both | [%s] |
elem->name |
gchar * |
| both | tensor %u |
i |
guint (:289) |
| guard 1 | memory %" G_GSIZE_FORMAT " |
map[i].size |
gsize (GstMapInfo.size) |
| guard 2 | header %" G_GSIZE_FORMAT " |
hsize |
gsize (:360) |
| guard 2 | memory %" G_GSIZE_FORMAT " |
map[i].size |
gsize |
The conversions match the arguments in number, order and type. G_GSIZE_FORMAT is the right choice over the %zu used elsewhere in this file for size_t. GLib only promises that gsize is the same width as size_t, not the same type, while glibconfig.h defines G_GSIZE_FORMAT to match gsize exactly. So on LP64 (x86_64, aarch64) it is "lu" for unsigned long, and on ILP32 (armv7l, armeabi-v7a, x86) it is "u" for unsigned int.
Guard 1 does not print the header size, and that is correct. On that path hsize has not been assigned yet (it is uninitialised on the first iteration), and the parse either did not run or failed. Adding it, as the approving review's suggested string did, would have logged an indeterminate value.
Combination with _ml_detail and the -Werror set
-
_ml_detail(fmt, ...)(ml-api-internal.h:431) expands to"%s:%s:%d: " fmt, __FILE__, __func__, __LINE__, ##__VA_ARGS__. The newfmtargument is"...%" G_GSIZE_FORMAT " bytes).". The commas inside the literals do not split macro arguments, andG_GSIZE_FORMATexpands to a string literal. The whole format therefore stays a single concatenated literal, which is what-Wformat-nonliteral/-Wformat-security/-Werror=format=2need. -
Adjacent string-literal concatenation is C89, so the change is fine under
-std=gnu89. The GNU-isms here (##__VA_ARGS__,__func__in gnu89) are pre-existing and used throughout the file. -
I checked all four backends. They are selected at
ml-api-internal.h:32-65and the format reaches each one unchanged:- Linux:
g_critical→g_log(G_GNUC_PRINTF). - Tizen:
dlog_print (DLOG_ERROR, tag, ...). - Android:
__android_log_print (prio, tag, ...)(__printflike(3, 4)). FAKEDLOG: set only intests/meson.buildfor the test objects, not for the library.
Every backend receives the same format and argument list after its tag, so the type analysis above applies to all of them.
- Linux:
Verified by building, not only by reading
-
Build: local build of
35b4348with gcc 11.4, using the project's own flags (-std=gnu89 -Wall -Werror -Werror=format=2 -Wformat-nonliteral -Wformat-security ..., confirmed fromninja -t commands). It is warning-free. -
Negative control: replacing the
G_GSIZE_FORMATon:369with"u"makes the same build fail withml-api-internal.h:431:30: error: format '%u' expects argument of type 'unsigned int', but argument 9 has type 'gsize' {aka 'long unsigned int'} [-Werror=format=]. So format checking really does reach through_ml_loge→g_critical→g_log. The committed version passes because it is right, not because nothing checks it. -
Tests:
unittest_capi_inference242 PASSED,unittest_capi_inference_single48 PASSED,unittest_capi_datatype_consistency4 PASSED (nnstreamer 2.7.0, Ubuntu 22.04). The Verification block in the body is therefore also current for this head, which closes round 4's B2. -
Observed log lines from the five negative cases (path prefix trimmed):
cb_sink_event:367: ... header is invalid (tensor 0, memory 16 bytes). <- sink_short_header_n cb_sink_event:367: ... header is invalid (tensor 0, memory 128 bytes). <- sink_invalid_magic_n cb_sink_event:377: ... does not fit the memory (tensor 0, header 0 bytes, memory 128 bytes). <- sink_unsupported_version_n cb_sink_event:377: ... does not fit the memory (tensor 0, header 128 bytes, memory 96 bytes). <- sink_truncated_header_n cb_sink_event:377: ... does not fit the memory (tensor 0, header 128 bytes, memory 96 bytes). <- sink_truncated_header_appsink_nThe two examples quoted in the body's "Review rounds" item 1 match this output exactly. There is also a side benefit: the log alone now separates all four rejection arms (memory < 88 vs >= 88 in guard 1; header 0 vs header > memory in guard 2). What round 3's T2 said the old messages could not show, the new ones do.
-
Formatting: GNU
indentwith the options in.github/workflows/static.check.scripts/indent.shproduces no hunk insidecb_sink_event ()(the two hunks it does produce are elsewhere in the file and pre-existing).clang-formatproduces no diff on the test file. The new 84- and 88-column Doxygen lines are not reflowed because.clang-formathasCommentPragmas: '\* @'.Static checksis green on this head.
2. Doxygen of create_flex_header_file () vs. what the helper does
Every claim matches the code at unittest_capi_inference.cc:8253-8293:
@param size"truncated or zero-padded to it".content = g_malloc0 (MAX (size, hsize))withhsize= 128, theng_file_set_contents (path, content, size, NULL). Ifsize< 128 the header is cut; ifsize> 128 the tail is zero fromg_malloc0. Withmeta == NULLthe whole file is zero, which@param metaalready says.@param dir"NULL on failure".*dir = NULLis the first statement, ahead of every exit.*dir = tmpdirhappens only in theif (path)branch. On each of the four failure exits (size == 0,g_mkdtemp,update_headerFALSE,g_file_set_contents)*dirstays NULL, and the temp directory is removed and freed in the helper.@return"Release both with remove_flex_header_file ()". That function doesg_remove (file),g_remove (dir)(file first, as required), theng_freeon both. This is the intended disposer, and the doc now says so. It also resolves round 4's L1 from the creating side.
The one thing left implicit is that size == 0 is refused. It is covered by "NULL on failure", so it is not worth a respin.
3. The two "Not done" rebuttals
3a. Item 3 (document the drop in ml_pipeline_sink_cb): sound
I checked c/include/nnstreamer.h. Neither ml_pipeline_sink_cb (:126-136) nor ml_pipeline_sink_register () (:296-325) says anything about buffers being dropped. That is also true for the static branch's count and size mismatches, which have dropped silently all along. A sentence covering only the flexible case would indeed suggest the static path delivers malformed buffers. The public header is the Tizen native API contract, so this should be one change covering both branches, not an asymmetric note in this fix. Agree.
3b. Item 4 (G_STATIC_ASSERT between the two limits): sound in substance, two wording points
I read the three functions in C:/Users/myung/nnstreamer, which is checked out at 804bbe81 (v2.6.0-209):
gst_tensor_buffer_get_count ()(nnstreamer_plugin_api_impl.c:1912-1950): ifgst_buffer_n_memory () < NNS_TENSOR_MEMORY_MAX(16) it returns that. Otherwise it maps the last memory, and ifgst_memory_map_is_extra_tensor ()holds it returnsextra_info->num_extra_tensors + NNS_TENSOR_MEMORY_MAX.num_extra_tensorsis a rawuint32_tfrom buffer data, and theguintsum can even wrap.gst_memory_map_is_extra_tensor ()(:53-70) checks onlymap->size >= sizeof (GstTensorExtraInfo)andmagic == NNS_TENSOR_EXTRA_MAGIC. It does not check the version, and it does not boundnum_extra_tensorsagainstNNS_TENSOR_SIZE_EXTRA_LIMIT(240).grepfinds no other place that bounds it.gst_tensor_buffer_get_nth_memory ()(:1686-1757) takes that count as its index bound and walksextra_info->infos[]andgst_memory_share ()offsets derived fromreserved/infos[], again without comparing them to the mapped size.
So for that source, "gst_tensor_buffer_get_count () trusts the extra-tensor header" is accurate. The approving review's premise, that the count "can return up to NNS_TENSOR_SIZE_LIMIT (256)", holds only for well-formed producers. A G_STATIC_ASSERT that the two constants agree would therefore not bound mem[] / map[] (cb_sink_event () :287-288), or _data->tensors[], against a forged header. The body's conclusion, "the question is whether that count is validated, not whether the constants agree", is the right one. Not fixing it here is correct: it sits ahead of the M9 lines, in the first mapping loop, and is a different defect.
Two precision points on that paragraph. Neither changes the conclusion, and I am not asking for a code change.
- "for more than 16 memories" cannot happen. A
GstBufferholds at most 16 memories:GST_BUFFER_MEM_MAX, whichNNS_TENSOR_MEMORY_MAXmirrors pertensor_typedef.h:45-52. The header is consulted once the buffer is at 16 memories, which is how more than 16 tensors are represented. Suggest "for more than 16 tensors" or "once the buffer holds 16 memories". - The sentence is accurate for the nnstreamer the reasoning was based on, but stale for nnstreamer
main.nnstreamer/nnstreamermainhas since landedd4de7840"[Common] Do not trust the extra-tensor header of a gst-buffer" (committed 2026-09-07).gst_memory_map_is_extra_tensor ()there now rejectsnum_extra_tensors > NNS_TENSOR_SIZE_EXTRA_LIMIT, so with that commit the count is at most 16 + 240 = 256, andget_nth_memory ()bounds every offset and size against the map. No tag containsd4de7840yet. This repository does not pin a minimum nnstreamer (meson.build:32,dependency('nnstreamer')with no version). The body's conclusion therefore still holds for the nnstreamer versions this API can be built against, and an API-side check ofnum_tensorsagainstML_TENSOR_SIZE_LIMITis still what closes it there.ml_pipeline_sink_cb's own doc (nnstreamer.h:132, "the maximum number of tensors is #ML_TENSOR_SIZE_LIMIT") gives that check its contract. The follow-up should also note that, withd4de7840, a static assert thatNNS_TENSOR_SIZE_LIMIT <= ML_TENSOR_SIZE_LIMITdoes become a real bound, so the two are complementary. Suggest qualifying the sentence along the lines of "which, before nnstreamerd4de7840, trusts the extra-tensor header".
4. Anything new
Nothing. On the success path, behaviour is unchanged: the only edits are to strings and arguments of two _ml_loge calls that run only just before goto error. No control flow, no new calls, and no new per-buffer cost on accepted buffers. Round 2's N4, about per-buffer logging on a malformed stream, stands as it was and was already accepted as consistent with the static branch. The Doxygen change has no effect on the build. Single commit, DCO present, and Spell Check is green.
5. CI on this head
Unlike 277ec6b, this delta touches c/src/ml-api-inference-pipeline.c, so the build-only jobs are now meaningful for it:
Tizen GBS build on Ubuntu (armv7l, --define "unit_test 0")is the only job that compiles the new format strings under meson's-Werrorset on an ILP32 target, through the realdlog_print.aarch64does the same for LP64 on Tizen.- The Android
build (*)jobs compile the file (Android-nnstreamer.mk:33) but without-Werror(LOCAL_CFLAGS := -O3 -fPIC ...), so they would only warn.armeabi-v7aandx86, the two 32-bit ABIs, have already passed. Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1")is still the only job that runsunittest_capi_inference.
Status when I finished: DCO, Spell Check, Static checks, build (armeabi-v7a) and build (x86) pass. build (arm64-v8a), build (x86_64) and all three GBS jobs are pending.
Summary
- Delta: the format strings and arguments match on every backend and ABI. Proven by a clean
-Werror=format=2build plus a negative control that fails as expected. The log output matches the body verbatim, and now distinguishes all four arms. - Doxygen: now accurate on size, padding, failure state and disposal.
- Rebuttal 3: correct.
- Rebuttal 4: correct about the nnstreamer source it cites. Its wording should say "tensors" rather than "memories", and should acknowledge nnstreamer
d4de7840, which now bounds the count onmain. It is still out of M9's scope.
Recommendation: merge, once Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1") and the two GBS unit_test 0 builds are green, and the DRAFT / DO NOT MERGE marks are lifted. The two body-wording points in 3b are optional, and no sixth round is needed.
myungjoo-bot
left a comment
There was a problem hiding this comment.
Automated re-review of head 35b4348 (delta since the previously approved 277ec6b; verified directly by the review process, no separate agent this round).
Summary: The delta is 2 files, +8/-6: the two _ml_loge calls in the flexible branch of cb_sink_event now report the tensor index and sizes, and the create_flex_header_file Doxygen documents @param size, the NULL-on-failure state of dir, and disposal via remove_flex_header_file (). Both close the Low items from the previous review. Checked on the PR head: i is guint (%u), hsize and map[i].size are gsize (G_GSIZE_FORMAT), so the conversions match on LP64 and ILP32; _ml_detail keeps the format a single concatenated literal, so -Werror=format=2 still applies; the first guard deliberately omits hsize, which is unassigned on that path. The guards themselves, the error: path, and the success path are byte-identical to 277ec6b. The log now distinguishes all four rejection arms (memory < 88 vs parse failure; header 0 vs header > memory), which is what the earlier review asked for.
CI on 35b4348: all 10 checks pass, including the GBS armv7l / aarch64 build lanes that compile the new format strings under Tizen's -Werror set on ILP32 and LP64. The GBS x86_64 unit_test 1 job (102763088316) shows all 8 nnstreamer_capi_flex tests OK — sink_short_header_n, sink_invalid_magic_n, sink_unsupported_version_n, sink_truncated_header_n, sink_truncated_header_appsink_n, sink_header_only at ~301 ms each — and 246 tests passed in unittest_capi_inference. Single commit, DCO present, merge-base is current main. The author's decision to leave the ml_pipeline_sink_cb doc note and the G_STATIC_ASSERT out of this PR is reasonable: the former should cover both branches in one change, and the latter does not bound a forged extra-tensor header on nnstreamer releases without d4de7840, so an API-side num_tensors check is the real follow-up. Approval stands; nothing further requested.
No back-door or suspicious behavior found.
Addresses item M9 of #690.
The defect
cb_sink_event()(c/src/ml-api-inference-pipeline.c) handled a flexible tensor like this:map[i]is the mapped GstMemory that arrived at the sink, somap[i].sizeis whatever the upstream element produced. Nothing in those four lines is checked.The parser reads before it validates.
gst_tensor_meta_info_parse_header()fills the wholeGstTensorMetaInfo— 88 bytes,val[0]throughval[21]— out of the pointer it is given, and only then callsgst_tensor_meta_info_validate(). A memory shorter than the struct is read past its end.The return value is dropped.
hsizeis used whether or not the meta was accepted.The subtraction wraps.
map[i].size - hsizeisgsizearithmetic. When the memory is shorter than the header it declares, the callback receives a pointer past the end of the mapped region together with a size near 2^64.Measured on
main(8952123), a 96-byte buffer carrying a complete, valid meta reaches the application callback withNote on tooling: the over-read is not something a sanitizer catches here.
filesrcallocates its blocksize and resizes the buffer down, so the 88-byte read stays inside a much larger live allocation; an ASAN build ofmainreports nothing and fails only on the assertion. The wrapped size is the part that is reliably observable, and that is what the tests assert.The fix
The guarded form of the same operation, the way
gst_tensor_meta_info_parse_memory()does it in nnstreamer:sizeof (GstTensorMetaInfo)before the parser touches it. That is exactly the window the parser fills — the parser reads the struct through auint32_t *view of itself — so the bound is structural and does not depend on how meta versions are numbered.Step 3's zero check matters on its own.
GST_TENSOR_META_VERSION_VALIDonly tests the0xDEtag whileGST_TENSOR_META_IS_V1needs the version bits, so a meta can validate and still have no header size. Treating that as a zero-length header would hand the header bytes to the application as payload, described by an info the sender chose.A rejected buffer takes the existing
error:path: the memories are unmapped and unreffed, the data handle is destroyed, and no sink callback runs. The sink pad caps are deliberately left alone — unlike the tensor-count and size mismatches in the static branch, a malformed buffer is not a reason to renegotiate.Tests
Six cases in
nnstreamer_capi_flex, all driving a real pipeline through the public C API:sink_short_header_nsize < sizeof (GstTensorMetaInfo)sink_invalid_magic_nparse_headerreturns FALSEsink_unsupported_version_nhsize == 0sink_truncated_header_nhsize128 > 96sink_truncated_header_appsink_nappsinkentry pointsink_header_onlyWhich guard each case reaches was confirmed from the
_ml_logeit triggers; the two guards log different messages, and within a guard the clause follows from the buffer size (16 < 88 against 128 ≥ 88 for the first,hsize128 over a 96-byte memory againsthsize0 for the second).One honest limit on that table: the first row is reached but not pinned. Deleting the
size < sizeof (GstTensorMetaInfo)clause would let the 16-byte case fall through to the second guard, which still rejects it, so the suite would stay green. What that clause prevents — the parser reading past the memory — is not observable here for the reason given above:filesrcover-allocates, so the read stays inside a live allocation and no sanitizer fires. The clause is kept because the read is out of bounds of the buffer, which is what the sink is contractually given.Headers are built with nnstreamer's own
gst_tensor_meta_info_update_header()and truncated, so the tests follow the header layout rather than hardcoding it, and the unsupported version is produced by masking the real version tag rather than by writing a literal. They are fed in throughfilesrcwith the flexible caps forced in front of the sink.On
mainall five negative cases fail (result.receivedis 1, expected 0); with this change all six pass.sink_header_onlypasses before and after, so an over-strict guard would be caught too.sink_truncated_header_appsink_ncovers the second way intocb_sink_event—cb_appsink_new_sample()— which is what the ml-service extension, the training-offloading receiver and the Android JNI sink all end up using.Verification
Built against nnstreamer 2.7.0 on Ubuntu 22.04,
-Denable-ml-service=false:clang-formatreports no diff on the test file, and GNUindentwith the CI options reports no diff inside the changed function.Gating:
unittest_capi_inferenceruns on a PR throughTizen GBS build on Ubuntu (x86_64, --define "unit_test 1")only —pdebuilddeclareson: pull_requestbut has not actually been triggered by one since 2026-08-09. A regression in these cases blocks the merge through that job.Review rounds
Four review passes, each by a fresh agent, and one approving review: round 1, round 2, round 3, round 4. Everything actionable is in the branch.
Round 2's two substantive findings drove the restructuring above:
parse_headerreturn value had no coverage. Every negative test was short-circuiting on the size check, so deleting that clause would have left the suite green.sink_invalid_magic_ncloses it, and moving the pre-parse bound from the header size (128) tosizeof (GstTensorMetaInfo)(88) means the 96-byte cases now exercise the second guard instead of the first.hsize == 0. Round 1's reply had reasoned that zero was benign; it is not, and it is the same input class this PR exists to reject. Guarded, andsink_unsupported_version_ncovers it.Round 2 also corrected round 1's
cur_hsizefinding — the real hazard was the zero case, not a future larger header.sizeof (GstTensorMetaInfo)removes the version coupling that started that thread.While covering the version case,
sink_unsupported_version_nwas found to be passing vacuously: it sized the file from the meta it had just mutated, so the header size came back 0 andfilesrcpushed nothing. Fixed by taking the size before the mutation, andcreate_flex_header_file()now refuses a zero-length file so the same mistake cannot pass silently again.Round 3 found no blocking issue. It verified
sizeof (GstTensorMetaInfo)is 88 on every target this repo builds for — all members areuint32_t, including the anonymous union's, so there is no padding — and that it matches the parser's read window exactly. Its remaining points were the two body inaccuracies corrected above, plus the temp-directory boilerplate, which is now factored intocreate_flex_header_file ()andremove_flex_header_file ()(−17 lines net in the test file).Round 4 re-checked the test refactoring against the head it replaced: the inputs that select each arm are unchanged byte for byte, including the ordering in
sink_unsupported_version_nthat the round-2 vacuous pass turned on, and the helper now cleans up its own directory when it fails, which removes a leak the earlier tests had on an assertion failure. No blocking issue.Round 3 also independently confirmed what is noted under Verification below:
pdebuildhas not been triggered by a pull request since 2026-08-09 despite itson: pull_request, so the GBS x86_64 job is the only PR check that runsunittest_capi_inference. That dormancy is an infrastructure matter for its own issue, not something to fix here.After the four rounds, an approving review (myungjoo-bot) raised four Low items on
277ec6b:_ml_logecalls now carry the tensor index and the sizes, e.g.(tensor 0, header 128 bytes, memory 96 bytes)for a truncated header and(tensor 0, header 0 bytes, memory 128 bytes)for an unsupported version.create_flex_header_file ()Doxygen missessizeand the failure/ownership contract ofdir. Done.ml_pipeline_sink_cb. Not done.nnstreamer.his the public native API header, and it has never said that the static branch drops buffers whose tensor count or size mismatch either; a note on the flexible case alone would read as if the static path delivered malformed buffers. This PR makes the existing contract — a valid pointer and a truthful size — hold rather than changing it. Documenting the drop belongs to a change that covers both branches.G_STATIC_ASSERTbetweenNNS_TENSOR_SIZE_LIMITandML_TENSOR_SIZE_LIMIT. Not done here. The two limits are both 256 today, but an assertion between them would not bound what reachesmem[]/map[]:num_tensorsis taken from the buffer itself (gst_tensor_buffer_get_count (), which for a buffer at the 16-memory limit reads the count out of the extra-tensor header), so the question is whether that count is validated, not whether the constants agree. nnstreamermainnow validates it — nnstreamer/nnstreamer@d4de7840 (item A1 of [Memory] Memory-safety audit of Tizen-built C/C++ components: tracking checklist nnstreamer#4920) rejects a header whose count exceeds the array — but that is not in a tagged release yet, and this repository does not require a minimum nnstreamer version, so a build against a current release still trusts the header. Whether the api should bound the count itself is a separate item from M9 and is being looked at on its own.Two items are deliberately left out of this PR:
sink_header_onlydocuments that: the callback gets an info claiming 16 bytes over a 0-byte payload. That is a separate defect from M9, it applies to the sparse format differently, and fixing it here would risk rejecting legitimate flexible streams. It belongs in [C-Api] Memory-safety audit of C sources: 47 verified issues (6 HIGH / 15 MEDIUM / 26 LOW) — tracking checklist #690 as its own item.goto errorati > 0has no test. The flex loop does not modifynum_tensorsand every memory is mapped before it runs, so theerror:cleanup is index-independent and there is no distinct path to cover. Round 2 is right thattensor_mux/tensor_mergecan produce multi-memory flexible buffers; what they cannot easily produce is one whose second memory is truncated, which is what such a test would need.🤖 Generated with Claude Code