From 5c5964efb9ab637cd6a42a12ff417f5ae0b502db Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 23:28:43 +0200 Subject: [PATCH] fix(deploy): bound Helm render memory, no wrong image from a skipped change Review round 2 of #2677 (N1-N12). Every case ends in an explicit unknown, never in an OOM, a quadratic stall or a wrong image. Bounds (helm_template.c, pass_k8s.c): - N1: capture arrays grow by doubling, so n captures cost memory linear in n. Arena growth since the render began counts against a third budget (256 MiB) beside steps and output; a trip is logged with its reason. - N10: poisoned maps are a pointer hash set (merged values a path list), so a loop poisoning a new dict per iteration keeps reads O(1). Release images are deduplicated by hash. Wrong images (helm_template.c): - N2: a set / merge in a body an UNKNOWN condition skips is judged by its target whatever it is -- a call's result like `index .Values "image"` is evaluated in the frame: own, or a known map (poisoned), else it taints. - N3: putting a value another part holds into an own map revokes own; a part of a poisoned map is never own. - N4: a run-time set whose target is an UNKNOWN call result taints. - N5: an UNKNOWN break / continue spoils the rest of the loop body around it; a continue is a continue, not a break. - N6: the scan notes redefinitions and assignments, carries what it saw across the actions of one branch, and never judges a variable the part rebinds by its value in the frame. - N11: a define's "may not change" that leaned on a define still being scanned (mutual includes) is not kept. - N12: an include not run because its argument is poisoned or UNKNOWN spoils that argument (poisoned deep, or a taint). Releases and charts (pass_k8s.c, pass_terraform.c): - N8: an empty values.yaml is values {} (Helm's), not a file not read. - N9: a Terraform set_sensitive whose name overlaps a key an image reads, a Terraform postrender, or a kustomization with its own transformers (images, patches, replacements, transformers, components) leaves the release without an image of its own. Tests (N7): the symlink probes are POSIX-only (SKIP_PLATFORM on Windows). One test per item, RED on 4cea54f7 by its assertion; N10's is a correctness control, its cost measured (linear vs quadratic) outside the suite. Signed-off-by: Martin Vogel --- src/pipeline/helm_template.c | 583 ++++++++++++++++++++++--------- src/pipeline/helm_template.h | 1 + src/pipeline/pass_k8s.c | 148 +++++++- src/pipeline/pass_terraform.c | 123 ++++++- src/pipeline/pipeline_internal.h | 7 +- tests/test_grammar_probe_f.c | 88 ++++- tests/test_pipeline.c | 195 +++++++++++ 7 files changed, 953 insertions(+), 192 deletions(-) diff --git a/src/pipeline/helm_template.c b/src/pipeline/helm_template.c index fa516ae00..2f96db45d 100644 --- a/src/pipeline/helm_template.c +++ b/src/pipeline/helm_template.c @@ -39,13 +39,15 @@ enum { HELM_MAX_NESTING = 1000 }; /* Helm engine's recursionMaxNums */ enum { HELM_MAX_SET_INDEX = 65536 }; /* Helm strvals' MaxIndex for `--set a[n]` */ /* A render doing more than this -- steps (an action, an evaluation, a range - * iteration) or bytes written to any sink -- is one Helm never finishes + * iteration), bytes written to any sink, or memory it takes (the arena's + * growth: captures, list copies, strings) -- is one Helm never finishes * either (a range over a huge int, defines that call each other in a fan): * it stops, and every probe of the template is UNKNOWN. Counted, never timed; * far above any real chart (the 20 infra corpora and the helm oracle charts * never reach it). */ enum { HELM_MAX_STEPS = 10000000 }; -static const size_t HELM_MAX_OUTPUT = (size_t)CBM_SZ_64 * CBM_SZ_1K * CBM_SZ_1K; /* 64 MiB */ +static const size_t HELM_MAX_OUTPUT = (size_t)CBM_SZ_64 * CBM_SZ_1K * CBM_SZ_1K; /* 64 MiB */ +static const size_t HELM_MAX_MEMORY = (size_t)CBM_SZ_256 * CBM_SZ_1K * CBM_SZ_1K; /* 256 MiB */ /* ── Values: YAML -> JSON ─────────────────────────────────────────── */ @@ -1364,12 +1366,6 @@ typedef struct { hsink_t *out; /* NULL: the template's own output, which the probes read */ } hframe_t; -typedef struct { - const void *map; /* the hv_map_t / yyjson object changed */ - const char *path; /* its values path (NULL: none) */ - bool deep; /* merge: every map within it may have changed too */ -} hpoison_t; - typedef struct { CBMArena *a; yyjson_mut_doc *scratch; @@ -1382,14 +1378,16 @@ typedef struct { bool cont; long steps; size_t written; /* bytes appended to any sink */ - bool stop; /* over HELM_MAX_STEPS / HELM_MAX_OUTPUT: the render is UNKNOWN */ + size_t arena0; /* the arena's total when the render began */ + bool stop; /* over HELM_MAX_STEPS / _OUTPUT / _MEMORY: the render is UNKNOWN */ /* maps a set / unset / merge changed in place (not modelled): what they - * hold is UNKNOWN from then on */ - hpoison_t *poison; - int npoison; - int pcap; + * hold is UNKNOWN from then on -- by address ("%p" keys in the arena), + * and for a merge of values also by values path (everything below it) */ + CBMHashTable *poison_maps; + CBMHashTable *poison_paths; bool tainted; /* a change of unknown target: every capture ending later is UNKNOWN */ - CBMHashTable *mutates; /* define name -> it may change a map ((void *)1 / (void *)2) */ + CBMHashTable *mutates; /* define name -> it may change a map (he_define_may_mutate) */ + long busy_hits; /* defines met again while being scanned */ /* probes of the top-level template */ const char *top_src; size_t top_len; @@ -1408,9 +1406,14 @@ typedef struct { int tcap; } hst_t; +/* Is the render over its memory budget (the arena grown since it began)? */ +static bool hst_over_memory(const hst_t *st) { + return cbm_arena_total(st->a) - st->arena0 > HELM_MAX_MEMORY; +} + /* One step of work; false once the render is over its budget. */ static bool hst_step(hst_t *st) { - if (!st->stop && ++st->steps > HELM_MAX_STEPS) { + if (!st->stop && (++st->steps > HELM_MAX_STEPS || hst_over_memory(st))) { st->stop = true; } return !st->stop; @@ -1418,7 +1421,7 @@ static bool hst_step(hst_t *st) { /* n more bytes of output; false (and the render stopped) past the budget. */ static bool hst_write(hst_t *st, size_t n) { - if (st->stop || n > HELM_MAX_OUTPUT - st->written) { + if (st->stop || n > HELM_MAX_OUTPUT - st->written || hst_over_memory(st)) { st->stop = true; return false; } @@ -1548,20 +1551,54 @@ static bool hv_poisoned(const hst_t *st, const hv_t *v) { if (!map) { return false; } - for (int i = 0; i < st->npoison; i++) { - const hpoison_t *p = &st->poison[i]; - if (p->map == map) { - return true; - } - if (p->deep && p->path && v->path) { - size_t pl = strlen(p->path); - if (pl == 0 || (strncmp(v->path, p->path, pl) == 0 && - (v->path[pl] == '\0' || v->path[pl] == '.' || v->path[pl] == '['))) { - return true; - } - } + char key[CBM_SZ_32]; + snprintf(key, sizeof(key), "%p", map); + if (st->poison_maps && cbm_ht_get(st->poison_maps, key)) { + return true; } - return false; + if (!st->poison_paths || !v->path) { + return false; + } + /* a merged values path covers what is below it: each prefix of v's path + * that ends at a key ("", "a", "a.b", "a.b[0]" ...; an escaped `\.` is no + * end) is looked up */ + size_t pl = strlen(v->path); + char small[CBM_SZ_256]; + char *buf = pl < sizeof(small) ? small : cbm_alloc(CBM_MEM_CLASS_OTHER, pl + SKIP_ONE); + bool hit = false; + for (size_t i = 0; buf && i <= pl && !hit; i++) { + bool end = i == pl || ((v->path[i] == '.' || v->path[i] == '[') && + !(i > 0 && v->path[i - SKIP_ONE] == '\\')); + if (!end) { + continue; + } + memcpy(buf, v->path, i); + buf[i] = '\0'; + hit = cbm_ht_get(st->poison_paths, buf) != NULL; + } + if (buf != small) { + cbm_free(CBM_MEM_CLASS_OTHER, buf); + } + return hit || !buf; +} + +/* Key `k` into set *set (made when NULL; the key copied into the arena). */ +static bool hst_set_add(hst_t *st, CBMHashTable **set, const char *k) { + if (!*set) { + *set = cbm_ht_create(0); + } + if (!*set) { + return false; + } + if (cbm_ht_get(*set, k)) { + return true; + } + const char *dup = cbm_arena_strdup(st->a, k); + if (!dup) { + return false; + } + cbm_ht_set(*set, dup, (void *)(uintptr_t)SKIP_ONE); + return true; } static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth); @@ -1574,7 +1611,7 @@ static bool hst_poison(hst_t *st, const hv_t *v, bool deep) { } static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth) { - if (depth > HELM_MAX_NESTING) { + if (depth > HELM_MAX_NESTING || !hst_step(st)) { return false; } if (deep && v->k == HV_LIST) { @@ -1602,20 +1639,12 @@ static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth) { } } } - if (st->npoison == st->pcap) { - int cap = st->pcap ? st->pcap * PAIR_LEN : CBM_SZ_16; - hpoison_t *g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); - if (!g) { - return false; - } - if (st->npoison) { - memcpy(g, st->poison, (size_t)st->npoison * sizeof(*g)); - } - st->poison = g; - st->pcap = cap; + char key[CBM_SZ_32]; + snprintf(key, sizeof(key), "%p", map); + if (!hst_set_add(st, &st->poison_maps, key)) { + return false; } - st->poison[st->npoison++] = (hpoison_t){map, v->path, deep}; - return true; + return !(deep && v->path) || hst_set_add(st, &st->poison_paths, v->path); } static bool hn_is(TSNode n, const char *t) { @@ -1743,13 +1772,24 @@ static void hst_capture_end(hst_t *st) { return; } cbm_helm_probe_t *p = &st->probes[st->capture]; - const char **img = cbm_arena_alloc(st->a, (size_t)(p->n + SKIP_ONE) * sizeof(*img)); - const char **keys = cbm_arena_alloc(st->a, (size_t)(p->n + SKIP_ONE) * sizeof(*keys)); - if (img && keys) { - if (p->n) { - memcpy(img, p->image, (size_t)p->n * sizeof(*img)); - memcpy(keys, p->keys, (size_t)p->n * sizeof(*keys)); + if (p->n == p->cap) { + /* the captures of a probe grow by doubling: n captures cost O(n) */ + int cap = p->cap ? p->cap * PAIR_LEN : CBM_SZ_4; + const char **gi = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gi)); + const char **gk = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gk)); + if (gi && gk) { + if (p->n) { + memcpy(gi, p->image, (size_t)p->n * sizeof(*gi)); + memcpy(gk, p->keys, (size_t)p->n * sizeof(*gk)); + } + p->image = gi; + p->keys = gk; + p->cap = cap; } + } + const char **img = p->n < p->cap ? p->image : NULL; + const char **keys = p->n < p->cap ? p->keys : NULL; + if (img && keys) { img[p->n] = st->cap.unk || st->tainted ? NULL : hst_scalar(st, st->cap.buf ? st->cap.buf : "", st->cap.n); @@ -1786,8 +1826,6 @@ static void hst_capture_end(hst_t *st) { if (!whole || (json && !keys[p->n])) { img[p->n] = NULL; /* a mark not written in full: no image, never one unmarked */ } - p->image = img; - p->keys = keys; p->n++; } st->capture = -SKIP_ONE; @@ -2453,14 +2491,25 @@ static bool he_deep_value(const hfresh_t *f, const char *src, TSNode x, int dept return true; } -/* Variable definition `def` (`$x := ...`) into f when what it makes is new. */ -static void he_fresh_note(hst_t *st, hfresh_t *f, const char *src, TSNode def) { - TSNode var = hn_field(def, "variable"); - TSNode val = hn_field(def, "value"); - hfresh_lvl_t lvl = ts_node_is_null(var) ? HF_NONE - : he_deep_value(f, src, val, 0) ? HF_DEEP - : he_fresh_value(f, src, val); - if (lvl == HF_NONE) { +/* Variable var is `lvl` new from here on (HF_NONE too: what it held before + * no longer counts -- the latest entry of a name is the one looked up). */ +/* Has the scan seen variable var defined or assigned (an entry in f)? */ +static bool he_fresh_seen(const hfresh_t *f, const char *src, TSNode var) { + size_t vl = 0; + const char *vs = he_var_text(src, var, &vl); + for (int i = 0; i < f->n; i++) { + if (f->len[i] == vl && memcmp(f->name[i], vs, vl) == 0) { + return true; + } + } + return false; +} + +/* Note in f that variable var is now lvl-new. A first HF_NONE entry is kept + * too: it hides the running frame's value of var from the scan. */ +static void he_fresh_put(hst_t *st, hfresh_t *f, const char *src, TSNode var, hfresh_lvl_t lvl) { + if (ts_node_is_null(var) || + (lvl == HF_NONE && he_fresh_seen(f, src, var) && he_fresh_var(f, src, var) == HF_NONE)) { return; } if (f->n == f->cap) { @@ -2483,6 +2532,77 @@ static void he_fresh_note(hst_t *st, hfresh_t *f, const char *src, TSNode def) { f->n++; } +/* Is expression x deep-new for the scan (f) -- or, a variable the scan did + * not see made, with fr (a part of the running template), a value of fr + * that is own? */ +static bool he_value_owned(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode x) { + if (he_deep_value(f, src, x, 0)) { + return true; + } + if (!fr || !hn_is(x, "variable") || he_fresh_seen(f, src, x)) { + return false; + } + size_t l = 0; + const char *name = hn_var_name(fr, x, &l); + hvar_t *v = hst_var(st, fr, name, l); + return v && v->v.own; +} + +/* Is call node c a set / unset / merge of variable var whose every other + * argument is own (the variable stays as new as it is)? */ +static bool he_self_change(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode c, + TSNode var) { + size_t fl = 0; + const char *fs = hn_is(c, "function_call") ? he_fn_text(src, c, &fl) : NULL; + TSNode args = fs && he_mutator(fs, fl) ? hn_field(c, "arguments") : (TSNode){0}; + uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); + TSNode first = na ? ts_node_named_child(args, 0) : (TSNode){0}; + if (!hn_is(first, "variable")) { + return false; + } + size_t al = 0; + size_t bl = 0; + const char *as = he_var_text(src, first, &al); + const char *bs = he_var_text(src, var, &bl); + if (al != bl || memcmp(as, bs, al) != 0) { + return false; + } + for (uint32_t i = SKIP_ONE; i < na; i++) { + if (!he_value_owned(st, fr, f, src, ts_node_named_child(args, i))) { + return false; + } + } + return true; +} + +/* A definition (`$x := v`) or an assignment (`$x = v`) into f: how new $x is + * now -- what v is, or, for `$x = set $x ...` of own values, what $x was. */ +static void he_fresh_note(hst_t *st, hframe_t *fr, hfresh_t *f, const char *src, TSNode def) { + TSNode var = hn_field(def, "variable"); + TSNode val = hn_field(def, "value"); + if (ts_node_is_null(var)) { + return; + } + hfresh_lvl_t lvl = he_deep_value(f, src, val, 0) ? HF_DEEP : he_fresh_value(f, src, val); + if (lvl == HF_NONE && hn_is(def, "assignment") && he_self_change(st, fr, f, src, val, var)) { + lvl = he_fresh_var(f, src, var); + } + he_fresh_put(st, f, src, var, lvl); +} + +/* Are the values set / merge call c puts into its target own (see + * he_value_owned)? */ +static bool he_puts_deep(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode c) { + TSNode args = hn_field(c, "arguments"); + uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); + for (uint32_t i = SKIP_ONE; i < na; i++) { + if (!he_value_owned(st, fr, f, src, ts_node_named_child(args, i))) { + return false; + } + } + return !hn_is(ts_node_parent(c), "chained_pipeline"); /* a piped value: not known here */ +} + /* Does expression x read `.` (a field of it)? In a with / range body `.` is * not the frame's. */ static bool he_reads_dot(TSNode x) { @@ -2505,12 +2625,36 @@ static bool he_reads_dot(TSNode x) { return found; } +/* Does expression x name a variable the scan (f) saw defined or assigned -- + * one whose value in the running frame is not the one x would read? */ +static bool he_names_rebound(const hfresh_t *f, const char *src, TSNode x) { + TSTreeCursor cur = ts_tree_cursor_new(x); + bool found = false; + for (bool more = true; more && !found;) { + TSNode c = ts_tree_cursor_current_node(&cur); + found = hn_is(c, "variable") && he_fresh_seen(f, src, c); + if (found || ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + more = false; + break; + } + } + } + ts_tree_cursor_delete(&cur); + return found; +} + /* What a change of the value of expression x (in frame fr; NULL: not * running) would reach: false when it reaches nothing another part holds -- * x is own, or a known map, now poisoned (`deep`: a merge). A value not known - * here: true. */ -static bool he_change_reaches(hst_t *st, hframe_t *fr, TSNode x, bool dot_known, bool deep) { - if (!fr || ts_node_is_null(x) || (!dot_known && he_reads_dot(x))) { + * here -- or x naming a variable the part (f) redefined: true. */ +static bool he_change_reaches(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode x, + bool dot_known, bool deep) { + if (!fr || ts_node_is_null(x) || (!dot_known && he_reads_dot(x)) || + he_names_rebound(f, src, x)) { return true; } /* read without recording: the part is not run, its reads are no capture's */ @@ -2523,21 +2667,27 @@ static bool he_change_reaches(hst_t *st, hframe_t *fr, TSNode x, bool dot_known, return !poisoned && !v.own; } +static void he_revoke_own(hst_t *st, hframe_t *fr, TSNode x); + /* May running node n (of text src) change a map another part holds -- a set / - * unset / merge whose target is `.`, `$`, a field path, or a variable n did - * not make itself from a new map (`$d := dict ...`), in n or in a define it - * includes (whose changes reach only its argument)? A call's result as the - * target is a new value: no. With fr (n is a part of the running template - * not run), a target or include argument that is a value of fr is looked at: - * own, or a known map (then poisoned): no. An include of a computed name is a - * template file by path, another template's work, which no render models. - * The text a tpl renders is not looked into. */ + * unset / merge whose target is not a value n made new itself (`$d := dict`, + * `set (dict) ...`; a merge needs it deep-new), in n or in a define it + * includes (whose changes reach only its argument)? With fr (n is a part of + * the running template not run), a target or include argument -- `.`, a + * field, a call's result such as `index .Values "image"`, a variable of fr -- + * is looked at: own, or a known map (then poisoned): no. A change that puts a + * value another part may hold into a variable makes it no longer new / own. + * An include of a computed name is a template file by path, another + * template's work, which no render models. The text a tpl renders is not + * looked into. `made` (NULL: none) is what the parts before n in its body + * made new or redefined; n's own are added to it. */ static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hframe_t *fr, - bool dot_known) { + bool dot_known, hfresh_t *made) { if (depth > HELM_MAX_NESTING) { return true; } - hfresh_t fresh = {0}; /* variables n makes from a new map */ + hfresh_t own = {0}; /* variables n makes from a new map */ + hfresh_t *fresh = made ? made : &own; TSTreeCursor cur = ts_tree_cursor_new(n); bool found = false; bool more = true; @@ -2546,20 +2696,31 @@ static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hfram const char *t = ts_node_type(c); size_t fl = 0; const char *fs = NULL; - if (strcmp(t, "variable_definition") == 0) { - he_fresh_note(st, &fresh, src, c); + if (strcmp(t, "variable_definition") == 0 || strcmp(t, "assignment") == 0) { + he_fresh_note(st, fr, fresh, src, c); } else if (strcmp(t, "function_call") == 0 && (fs = he_fn_text(src, c, &fl)) != NULL && he_mutator(fs, fl)) { TSNode args = hn_field(c, "arguments"); TSNode target = ts_node_is_null(args) ? (TSNode){0} : ts_node_named_child(args, 0); bool deep = fs[0] == 'm'; /* a merge reaches the maps within its target too */ - if (hn_is(target, "variable")) { - hfresh_lvl_t lvl = he_fresh_var(&fresh, src, target); - bool spared = lvl == HF_DEEP || (lvl == HF_SHALLOW && !deep); - found = !spared && he_change_reaches(st, fr, target, dot_known, deep); - } else if (ts_node_is_null(target) || hn_is(target, "dot") || hn_is(target, "field") || - hn_is(target, "selector_expression")) { - found = he_change_reaches(st, fr, target, dot_known, deep); + bool puts_deep = he_puts_deep(st, fr, fresh, src, c); + /* how new the target is: a variable n made, or a new value itself + * (`set (dict) ...`); anything else -- `.`, a field, a call's + * result (`index .Values "image"`) -- is looked at in fr */ + hfresh_lvl_t lvl = hn_is(target, "variable") ? he_fresh_var(fresh, src, target) + : ts_node_is_null(target) ? HF_NONE + : he_deep_value(fresh, src, target, 0) + ? HF_DEEP + : he_fresh_value(fresh, src, target); + bool spared = lvl == HF_DEEP || (lvl == HF_SHALLOW && !deep); + found = !spared && he_change_reaches(st, fr, fresh, src, target, dot_known, deep); + if (!puts_deep && hn_is(target, "variable")) { + /* it now holds a value another part may hold */ + if (lvl == HF_DEEP) { + he_fresh_put(st, fresh, src, target, HF_SHALLOW); + } else if (fr && lvl == HF_NONE) { + he_revoke_own(st, fr, target); + } } } else if (strcmp(t, "function_call") == 0 || strcmp(t, "template_action") == 0 || strcmp(t, "block_action") == 0) { @@ -2572,8 +2733,8 @@ static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hfram ? (ts_node_is_null(args) ? (TSNode){0} : ts_node_named_child(args, SKIP_ONE)) : hn_field(c, "argument"); - found = !he_deep_value(&fresh, src, arg, 0) && - he_change_reaches(st, fr, arg, dot_known, true); + found = !he_deep_value(fresh, src, arg, 0) && + he_change_reaches(st, fr, fresh, src, arg, dot_known, true); } } else if (strcmp(t, "identifier") == 0 && (fs = he_fn_text(src, c, &fl)) != NULL && he_mutator(fs, fl) && hn_is(ts_node_parent(c), "chained_pipeline")) { @@ -2594,9 +2755,12 @@ static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hfram return found || st->stop; } -/* Memoized per define (a define reached again while it is scanned: no). */ +/* Memoized per define. A define reached again while it is being scanned + * counts as no for now; a no that leaned on such a one is not kept (STALE: + * scanned again when next asked), so defines including each other never fix + * a wrong no -- a yes is final. */ static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { - enum { HM_NO = 1, HM_YES = 2 }; + enum { HM_NO = 1, HM_YES = 2, HM_BUSY = 3, HM_STALE = 4 }; const hd_def_t *d = hd_find(st->defs, name); if (!d) { return false; /* a define not found is an error: nothing of it runs */ @@ -2604,36 +2768,54 @@ static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { if (!st->mutates) { st->mutates = cbm_ht_create(0); } - void *memo = st->mutates ? cbm_ht_get(st->mutates, name) : NULL; - if (memo) { - return memo == (void *)(uintptr_t)HM_YES; + uintptr_t memo = st->mutates ? (uintptr_t)cbm_ht_get(st->mutates, name) : 0; + if (memo == HM_YES || memo == HM_NO) { + return memo == HM_YES; } - const char *key = cbm_arena_strdup(st->a, name); - if (st->mutates && key) { - cbm_ht_set(st->mutates, key, (void *)(uintptr_t)HM_NO); + if (memo == HM_BUSY) { + st->busy_hits++; + return false; } - bool m = he_may_mutate(st, d->file->src, d->node, depth, NULL, false); + const char *key = memo ? cbm_ht_get_key(st->mutates, name) : cbm_arena_strdup(st->a, name); if (st->mutates && key) { - cbm_ht_set(st->mutates, key, (void *)(uintptr_t)(m ? HM_YES : HM_NO)); + cbm_ht_set(st->mutates, key, (void *)(uintptr_t)HM_BUSY); + } + long hits = st->busy_hits; + bool m = he_may_mutate(st, d->file->src, d->node, depth, NULL, false, NULL); + bool leaned = st->busy_hits > hits; /* on a define still being scanned */ + if (st->mutates && key) { + uintptr_t res = m ? HM_YES : leaned ? HM_STALE : HM_NO; + cbm_ht_set(st->mutates, key, (void *)res); } return m; } static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n); +/* A define not run would change its argument v (or a map within it): a known + * one is poisoned whole; one not known that is not own spoils what follows. */ +static void he_spoil_arg(hst_t *st, const hv_t *v) { + bool known = hv_is_map(v) || hv_is_list(v); + if (!(known && hst_poison(st, v, true)) && !v->own && + (v->k == HV_UNK || hv_is_map(v) || hv_is_list(v))) { + st->tainted = true; + } +} + /* Is the result of `fn` over a own (see hv_t): a part dug / got / indexed / - * taken out of an own value; a dict / list / default / coalesce / ternary - * whose every map or list (and every value not known) is own? */ -static bool he_own_result(const char *fn, const hv_t *a, int n) { + * taken out of an own value no change has touched (a poisoned map may hold + * another's now); a dict / list / default / coalesce / ternary whose every map + * or list (and every value not known) is own? */ +static bool he_own_result(const hst_t *st, const char *fn, const hv_t *a, int n) { if (n < SKIP_ONE) { return strcmp(fn, "dict") == 0 || strcmp(fn, "list") == 0; /* empty and new */ } if (strcmp(fn, "dig") == 0) { - return a[n - SKIP_ONE].own; + return a[n - SKIP_ONE].own && !hv_poisoned(st, &a[n - SKIP_ONE]); } if (strcmp(fn, "get") == 0 || strcmp(fn, "index") == 0 || strcmp(fn, "first") == 0 || strcmp(fn, "last") == 0) { - return a[0].own; + return a[0].own && !hv_poisoned(st, &a[0]); } if (strcmp(fn, "dict") == 0 || strcmp(fn, "list") == 0 || strcmp(fn, "default") == 0 || strcmp(fn, "coalesce") == 0 || strcmp(fn, "ternary") == 0) { @@ -2654,28 +2836,73 @@ static hv_t he_apply(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) { return HV_UNKNOWN_OWN; /* fromYaml / deepCopy: a new value, known or not */ } hv_t r = he_apply_fn(st, fr, fn, a, n); - r.own = r.own || he_own_result(fn, a, n); + r.own = r.own || he_own_result(st, fn, a, n); return r; } +/* The variable expression x reads its value out of (`$v`, `$v.a`, `(index $v + * "a")`, `get $v "a"`, `dig "a" $v`); a null node when there is none. */ +static TSNode he_root_var(const hframe_t *fr, TSNode x) { + for (int depth = 0; depth < HELM_MAX_NESTING && !ts_node_is_null(x); depth++) { + if (hn_is(x, "variable")) { + return x; + } + if (hn_is(x, "parenthesized_pipeline") && ts_node_named_child_count(x) == SKIP_ONE) { + x = ts_node_named_child(x, 0); + } else if (hn_is(x, "selector_expression")) { + x = hn_field(x, "operand"); + } else if (hn_is(x, "function_call")) { + size_t fl = 0; + const char *fs = he_fn_text(fr->src, x, &fl); + TSNode args = hn_field(x, "arguments"); + uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); + bool last = fs && fl == strlen("dig") && memcmp(fs, "dig", fl) == 0; + bool first = fs && ((fl == strlen("get") && memcmp(fs, "get", fl) == 0) || + (fl == strlen("index") && memcmp(fs, "index", fl) == 0) || + (fl == strlen("first") && memcmp(fs, "first", fl) == 0) || + (fl == strlen("last") && memcmp(fs, "last", fl) == 0)); + if (!na || (!first && !last)) { + break; + } + x = ts_node_named_child(args, last ? na - SKIP_ONE : 0); + } else { + break; + } + } + return (TSNode){0}; +} + +/* A change put a value another part may hold into what expression x reads: + * the variable it reads it out of is own no more. */ +static void he_revoke_own(hst_t *st, hframe_t *fr, TSNode x) { + TSNode var = he_root_var(fr, x); + if (ts_node_is_null(var)) { + return; + } + size_t l = 0; + const char *name = hn_var_name(fr, var, &l); + hvar_t *v = hst_var(st, fr, name, l); + if (v) { + v->v.own = false; + } +} + static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) { for (int i = 0; i < n; i++) { - if (a[i].k == HV_UNK) { - /* an include of a known define not run: a change it would have - * made to its argument is missed, unless that one is own (one of a - * name not known -- a template file by path -- is another - * template's work, which no render models) */ - if (strcmp(fn, "include") == 0 && a[0].k == HV_STR && n == PAIR_LEN && !a[1].own) { + if (a[i].k == HV_UNK || hv_poisoned(st, &a[i])) { + /* an include of a known define not run (an argument UNKNOWN or + * poisoned): a change it would make to its argument is missed -- + * a known argument is poisoned whole, one not known and not own + * spoils what follows (one of a name not known -- a template file + * by path -- is another template's work, which no render models) */ + if (strcmp(fn, "include") == 0 && a[0].k == HV_STR && n == PAIR_LEN) { const char *dn = cbm_arena_strndup(st->a, a[0].s, a[0].sn); if (!dn || he_define_may_mutate(st, dn, st->nesting)) { - st->tainted = true; + he_spoil_arg(st, &a[1]); } } return HV_UNKNOWN; } - if (hv_poisoned(st, &a[i])) { - return HV_UNKNOWN; - } } if (strcmp(fn, "include") == 0 && n == PAIR_LEN && a[0].k == HV_STR) { hsink_t out = {0}; @@ -3031,8 +3258,8 @@ static hv_t he_call(hst_t *st, hframe_t *fr, TSNode call, const hv_t *piped) { if (he_mutator(fn, fl)) { /* set / unset / merge change a map in place -- not modelled: what it * holds is UNKNOWN from here on (after a merge, every map within it). - * A target not known that may be one another part holds -- a variable, - * `.`, a field path; a call's result is a new value -- spoils every + * A target not known that is not own -- whatever expression gives it: + * index / get / first return a map another part holds -- spoils every * image after it. */ TSNode first = na ? ts_node_named_child(args, 0) : (TSNode){0}; hv_t target = na ? he_eval(st, fr, first) : piped ? *piped : HV_UNKNOWN; @@ -3042,13 +3269,14 @@ static hv_t he_call(hst_t *st, hframe_t *fr, TSNode call, const hv_t *piped) { hv_t v = i < na ? he_eval(st, fr, ts_node_named_child(args, i)) : *piped; put_own = put_own && (v.own || (v.k != HV_UNK && !hv_is_map(&v) && !hv_is_list(&v))); } - bool held = ts_node_is_null(first) || hn_is(first, "variable") || hn_is(first, "dot") || - hn_is(first, "field") || hn_is(first, "selector_expression"); /* a known map is poisoned even when own: what it holds is not known now */ bool poisoned = hst_poison(st, &target, fn[0] == 'm'); - if (!poisoned && !target.own && (held || hv_is_map(&target))) { + if (!poisoned && !target.own) { st->tainted = true; } + if (!put_own && !ts_node_is_null(first)) { + he_revoke_own(st, fr, first); + } /* the changed map is what set / merge return */ return target.own && put_own ? HV_UNKNOWN_OWN : HV_UNKNOWN; } @@ -3235,21 +3463,6 @@ static void he_items(hst_t *st, hframe_t *fr, TSNode parent, const char *field) } } -/* Is expression x deep-new (f: what the scan saw made new), or a variable of - * frame fr whose value is own? */ -static bool he_part_own(hst_t *st, hframe_t *fr, const hfresh_t *f, TSNode x) { - if (he_deep_value(f, fr->src, x, 0)) { - return true; - } - if (!hn_is(x, "variable")) { - return false; - } - size_t l = 0; - const char *name = hn_var_name(fr, x, &l); - hvar_t *v = hst_var(st, fr, name, l); - return v && v->v.own; -} - /* Does assigning expression `value` to variable var keep it own (when it is): * a deep-new value, or a set / merge of var itself with own values * (`$dst = ... | fromYaml | merge $dst`)? */ @@ -3287,36 +3500,40 @@ static bool he_assign_owns(hst_t *st, hframe_t *fr, const hfresh_t *f, TSNode va return false; } for (uint32_t i = SKIP_ONE; i < na; i++) { - if (!he_part_own(st, fr, f, ts_node_named_child(args, i))) { + if (!he_value_owned(st, fr, f, src, ts_node_named_child(args, i))) { return false; } } - return ts_node_is_null(piped) || he_part_own(st, fr, f, piped); + return ts_node_is_null(piped) || he_value_owned(st, fr, f, src, piped); } +/* How a part not run would leave the loop around it. */ +enum { HL_NONE = 0, HL_CONTINUE = 1, HL_BREAK = 2 }; + /* The variables of the frames around n that n assigns (`$x = ...`) become - * UNKNOWN (an own one stays own when what n gives it keeps it so); true when - * n would leave the loop around it -- a break / continue not inside a range - * of n's own (`in_range`: n is a range's body). */ -static bool he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range) { + * UNKNOWN (an own one stays own when what n gives it keeps it so); returns + * how n would leave the loop around it (HL_*) -- by a break / continue not + * inside a range of n's own (`in_range`: n is a range's body). `fresh` is + * what the parts before n in its body made new; n's own are added to it. */ +static int he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range, hfresh_t *fresh) { TSTreeCursor cur = ts_tree_cursor_new(n); - hfresh_t fresh = {0}; /* variables n makes new */ - bool leave = false; + int leave = HL_NONE; bool more = true; while (more && hst_step(st)) { TSNode c = ts_tree_cursor_current_node(&cur); const char *t = ts_node_type(c); if (strcmp(t, "variable_definition") == 0) { - he_fresh_note(st, &fresh, fr->src, c); + he_fresh_note(st, fr, fresh, fr->src, c); } else if (strcmp(t, "assignment") == 0) { TSNode var = hn_field(c, "variable"); size_t l = 0; const char *name = ts_node_is_null(var) ? NULL : hn_var_name(fr, var, &l); hvar_t *v = name ? hst_var(st, fr, name, l) : NULL; if (v) { - bool keeps = v->v.own && he_assign_owns(st, fr, &fresh, var, hn_field(c, "value")); + bool keeps = v->v.own && he_assign_owns(st, fr, fresh, var, hn_field(c, "value")); v->v = keeps ? HV_UNKNOWN_OWN : HV_UNKNOWN; } + he_fresh_note(st, fr, fresh, fr->src, c); } else if (strcmp(t, "break_action") == 0 || strcmp(t, "continue_action") == 0) { /* a range around it inside n (n itself included) is the one it leaves */ bool own = in_range || hn_is(n, "range_action"); @@ -3324,7 +3541,9 @@ static bool he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range) { p = ts_node_parent(p); own = hn_is(p, "range_action"); } - leave = leave || !own; + if (!own) { + leave |= t[0] == 'b' ? HL_BREAK : HL_CONTINUE; + } } if (ts_tree_cursor_goto_first_child(&cur)) { continue; @@ -3340,32 +3559,70 @@ static bool he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range) { return leave; } -/* The bodies of if / with / range n, not run because its condition or - * collection is UNKNOWN (that one was run): their output, the variables they - * would assign and a change they would make are UNKNOWN; a loop they would - * leave is left now (its later iterations are not known). */ -static void he_skipped(hst_t *st, hframe_t *fr, TSNode n) { - hst_out_unknown(st, fr); - bool range = hn_is(n, "range_action"); - bool with = hn_is(n, "with_action"); - bool leave = false; +/* The children of n but its condition / collection (what was run), each: + * the variables it would assign and the changes it would make spoiled (see + * he_scan_skipped / he_may_mutate); returns how it would leave the loop. */ +static int he_spoil_parts(hst_t *st, hframe_t *fr, TSNode n, const char *only, bool in_range, + bool dot_known) { + int leave = HL_NONE; bool mutates = false; + /* what a branch's parts made new so far: a later part of it sees it */ + hfresh_t scanned = {0}; + hfresh_t made = {0}; + const char *branch = NULL; uint32_t cc = ts_node_child_count(n); for (uint32_t i = 0; i < cc; i++) { TSNode c = ts_node_child(n, i); const char *f = ts_node_field_name_for_child(n, i); + if (f && strcmp(f, "condition") == 0) { + branch = NULL; /* an else if: a new branch */ + } if (!ts_node_is_named(c) || hn_is(c, "range_variable_definition") || - (f && (strcmp(f, "condition") == 0 || strcmp(f, "range") == 0))) { + (f && (strcmp(f, "condition") == 0 || strcmp(f, "range") == 0)) || + (only && !(f && strcmp(f, only) == 0))) { continue; } - leave = he_scan_skipped(st, fr, c, range) || leave; - mutates = mutates || he_may_mutate(st, fr->src, c, st->nesting, fr, !range && !with); + if (f && (!branch || strcmp(f, branch) != 0)) { + branch = f; + scanned = (hfresh_t){0}; + made = (hfresh_t){0}; + } + leave |= he_scan_skipped(st, fr, c, in_range, &scanned); + mutates = mutates || he_may_mutate(st, fr->src, c, st->nesting, fr, dot_known, &made); } if (mutates) { st->tainted = true; } - if (leave) { + return leave; +} + +/* The bodies of if / with / range n, not run because its condition or + * collection is UNKNOWN (that one was run): their output, the variables they + * would assign and a change they would make are UNKNOWN. A break / continue + * in them may or may not leave the loop around: what the rest of that loop's + * body would assign or change is UNKNOWN too, and a break leaves it now (its + * later iterations are not known), a continue this iteration. */ +static void he_skipped(hst_t *st, hframe_t *fr, TSNode n) { + hst_out_unknown(st, fr); + bool range = hn_is(n, "range_action"); + bool with = hn_is(n, "with_action"); + int leave = he_spoil_parts(st, fr, n, NULL, range, !range && !with); + if (leave == HL_NONE) { + return; + } + TSNode loop = ts_node_parent(n); + for (int depth = 0; + !ts_node_is_null(loop) && !hn_is(loop, "range_action") && depth < HELM_MAX_NESTING; + depth++) { + loop = ts_node_parent(loop); + } + if (hn_is(loop, "range_action")) { + (void)he_spoil_parts(st, fr, loop, "body", true, false); + } + if (leave & HL_BREAK) { st->brk = true; + } else { + st->cont = true; } } @@ -3508,9 +3765,9 @@ static void he_template(hst_t *st, hframe_t *fr, TSNode n) { if (a.k == HV_UNK || hv_poisoned(st, &a)) { hst_out_unknown(st, fr); /* the define not run: a change it would make to its argument is - * missed, unless that one is own */ - if (!a.own && he_define_may_mutate(st, name, st->nesting)) { - st->tainted = true; + * missed (see he_spoil_arg) */ + if (he_define_may_mutate(st, name, st->nesting)) { + he_spoil_arg(st, &a); } return; } @@ -3612,6 +3869,7 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, probes[i].n = 0; probes[i].image = NULL; probes[i].keys = NULL; + probes[i].cap = 0; } if (!src || nprobes == 0) { return true; @@ -3622,7 +3880,8 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, .top_len = len, .probes = probes, .nprobes = nprobes, - .capture = -SKIP_ONE}; + .capture = -SKIP_ONE, + .arena0 = cbm_arena_total(a)}; st.scratch = yyjson_mut_doc_new(cbm_json_writer_allocator()); st.parser = ts_parser_new(); if (!st.scratch || !st.parser || @@ -3679,6 +3938,12 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, if (st.mutates) { cbm_ht_free(st.mutates); } + if (st.poison_maps) { + cbm_ht_free(st.poison_maps); + } + if (st.poison_paths) { + cbm_ht_free(st.poison_paths); + } if (st.stop) { /* Helm would not finish this template: none of its images is known, * also those captured before the budget ran out */ @@ -3689,7 +3954,9 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, } } cbm_log_warn("helm.render.budget", "file", rel ? rel : "", "reason", - st.steps > HELM_MAX_STEPS ? "steps" : "output"); + st.steps > HELM_MAX_STEPS ? "steps" + : st.written >= HELM_MAX_OUTPUT ? "output" + : "memory"); } return !st.stop; } diff --git a/src/pipeline/helm_template.h b/src/pipeline/helm_template.h index 47bdb007b..44985a245 100644 --- a/src/pipeline/helm_template.h +++ b/src/pipeline/helm_template.h @@ -68,6 +68,7 @@ void cbm_helm_defines_free(cbm_helm_defines_t *d); typedef struct { int line; /* 1-based line of the key in the template */ int n; /* captures */ + int cap; /* room in image / keys (the render's own) */ const char **image; /* the rendered scalar, YAML quoting removed; NULL: UNKNOWN */ const char **keys; /* JSON array of the values paths it read; NULL: none */ } cbm_helm_probe_t; diff --git a/src/pipeline/pass_k8s.c b/src/pipeline/pass_k8s.c index 9a945c1d5..d20ca4cce 100644 --- a/src/pipeline/pass_k8s.c +++ b/src/pipeline/pass_k8s.c @@ -3904,6 +3904,7 @@ typedef struct { k8s_himg_t *v; int n; int cap; + CBMHashTable *seen; /* "src:img" of each v (keys cbm_alloc'd): one look-up a push */ } k8s_himgs_t; typedef struct { @@ -3919,6 +3920,9 @@ typedef struct { int tcap; k8s_himgs_t dflt; /* the containers' images with the defaults */ int edges; + /* the release rendered: its set_sensitive names (JSON array; NULL: none) -- + * an image reading one is not known, its value never read */ + const char *sensitive; } k8s_helm_t; /* The templated images of the facts, kept past them. */ @@ -3948,16 +3952,29 @@ static char *k8s_repo_read(cbm_pipeline_ctx_t *ctx, const char *rel, int *len) { } char path[CBM_SZ_4K]; int n = snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel); - return n > 0 && (size_t)n < sizeof(path) ? k8s_read_file(path, len) : NULL; + if (n <= 0 || (size_t)n >= sizeof(path)) { + return NULL; + } + char *buf = k8s_read_file(path, len); + cbm_path_info_t info; + if (!buf && cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular && + !info.is_symlink && info.size == 0) { + /* an empty file is read: empty text (padded like every read) */ + enum { CBM_TS_EMPTY_PAD = 16 }; + buf = cbm_calloc(CBM_MEM_CLASS_OTHER, CBM_TS_EMPTY_PAD); + *len = 0; + } + return buf; } -/* Is repository path `rel` there at all -- a file, a symlink, anything (a - * link is reported, never followed)? */ +/* Is repository path `rel` there, or can that not be told (a link is + * reported, never followed; only a path proven absent is not there)? */ static bool k8s_repo_has(cbm_pipeline_ctx_t *ctx, const char *rel) { char path[CBM_SZ_4K]; int n = snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel); cbm_path_info_t info; - return n > 0 && (size_t)n < sizeof(path) && cbm_path_info_utf8(path, &info) == 0; + return n > 0 && (size_t)n < sizeof(path) && + cbm_path_info_utf8(path, &info) != CBM_PATH_INFO_ABSENT; } /* A file of `dir` (one of `names`), parsed as values; *found when one exists. @@ -4232,14 +4249,38 @@ static const char *k8s_helm_overridden(k8s_helm_t *h, const char *keys, yyjson_m } static void k8s_himg_push(k8s_himgs_t *l, k8s_himg_t x) { - for (int i = 0; i < l->n; i++) { - if (l->v[i].src == x.src && l->v[i].img == x.img) { - return; /* the first (in file order) of the same Image stands for it */ - } + char key[CBM_SZ_64]; + snprintf(key, sizeof(key), "%lld:%lld", (long long)x.src, (long long)x.img); + if (!l->seen) { + l->seen = cbm_ht_create(0); } - if (k8s_grow((void **)&l->v, &l->cap, l->n, sizeof(*l->v))) { - l->v[l->n++] = x; + if (!l->seen || cbm_ht_get(l->seen, key)) { + return; /* the first (in file order) of the same Image stands for it */ } + size_t kl = strlen(key) + SKIP_ONE; + char *k = cbm_alloc(CBM_MEM_CLASS_OTHER, kl); + if (!k || !k8s_grow((void **)&l->v, &l->cap, l->n, sizeof(*l->v))) { + cbm_free(CBM_MEM_CLASS_OTHER, k); + return; + } + memcpy(k, key, kl); + cbm_ht_set(l->seen, k, (void *)(uintptr_t)SKIP_ONE); + l->v[l->n++] = x; +} + +static void k8s_himg_key_free(const char *key, void *value, void *ud) { + (void)value; + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); +} + +static void k8s_himgs_free(k8s_himgs_t *l) { + if (l->seen) { + cbm_ht_foreach(l->seen, k8s_himg_key_free, NULL); + cbm_ht_free(l->seen); + } + cbm_free(CBM_MEM_CLASS_OTHER, l->v); + *l = (k8s_himgs_t){0}; } /* Does `src` USE Image `img` already by a literal reference? */ @@ -4313,8 +4354,68 @@ static void k8s_helm_emit_uses(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const k8s } } +/* Do values paths a and b overlap -- one is the other or lies within it + * (a key boundary `.` / `[` after the shorter; "" is the whole values)? */ +static bool k8s_path_overlap(const char *a, const char *b) { + size_t al = strlen(a); + size_t bl = strlen(b); + size_t m = al < bl ? al : bl; + if (strncmp(a, b, m) != 0) { + return false; + } + char c = (al < bl ? b : a)[m]; + return m == 0 || c == '\0' || c == '.' || c == '['; +} + +/* Does values path `path` (of the release's chart) overlap a set_sensitive + * name of the release rendered? */ +static bool k8s_helm_sensitive_path(const k8s_helm_t *h, const char *path) { + if (!h->sensitive || !path) { + return false; + } + yyjson_doc *d = yyjson_read(h->sensitive, strlen(h->sensitive), 0); + bool hit = !d; /* names not read: say it touches */ + size_t idx, max; + yyjson_val *k; + yyjson_arr_foreach(d ? yyjson_doc_get_root(d) : NULL, idx, max, k) { + const char *s = yyjson_get_str(k); + hit = hit || (s && k8s_path_overlap(path, s)); + } + if (d) { + yyjson_doc_free(d); + } + return hit; +} + +/* Does an image reading values keys `keys` (a JSON array, of its chart at + * key path `prefix` within the release's; global.* are the release's own) + * read a set_sensitive name of the release rendered? */ +static bool k8s_helm_sensitive_keys(k8s_helm_t *h, const char *keys, const char *prefix) { + if (!h->sensitive || !keys) { + return false; + } + yyjson_doc *d = yyjson_read(keys, strlen(keys), 0); + bool hit = !d; + size_t idx, max; + yyjson_val *k; + yyjson_arr_foreach(d ? yyjson_doc_get_root(d) : NULL, idx, max, k) { + const char *key = yyjson_get_str(k); + bool global = key && strncmp(key, "global.", strlen("global.")) == 0; + const char *full = !key ? NULL + : global || !prefix || !prefix[0] + ? key + : cbm_arena_sprintf(&h->a, "%s.%s", prefix, key); + hit = hit || !full || k8s_helm_sensitive_path(h, full); + } + if (d) { + yyjson_doc_free(d); + } + return hit; +} + /* Chart `from` -DEPLOYS {via, ref: image, image, override_of}-> the images an - * overlay changed: rendered there, absent from the defaults' images. */ + * overlay changed: rendered there, absent from the defaults' images. An image + * reading a set_sensitive name (whose value is not read) is not known. */ static void k8s_helm_emit_overrides(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int64_t from, const char *via, const k8s_himgs_t *dflt, const k8s_himgs_t *got, yyjson_mut_val *overlay, @@ -4328,7 +4429,7 @@ static void k8s_helm_emit_overrides(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int6 strcmp(dflt->v[j].ref, x->ref) == 0; } const char *ovr = k8s_helm_overridden(h, x->keys, overlay, globals, prefix); - if (same || !ovr || from <= 0) { + if (same || !ovr || from <= 0 || k8s_helm_sensitive_keys(h, x->keys, prefix)) { continue; } k8s_himg_t e = {from, x->img, x->ref, NULL, ovr}; @@ -4593,7 +4694,7 @@ static void k8s_helm_release_local(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 yyjson_mut_val *globals = yyjson_mut_obj_get(rv, "global"); k8s_helm_emit_overrides(ctx, h, src, via, &h->dflt, &got, overlay, globals, path && path[0] ? path : NULL, emitted); - cbm_free(CBM_MEM_CLASS_OTHER, got.v); + k8s_himgs_free(&got); } cbm_free(CBM_MEM_CLASS_OTHER, srcs); from = to; @@ -4647,7 +4748,9 @@ static void k8s_helm_convention(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int64_t : "", dig[0] ? dig : tag) : NULL; - int64_t img = ref && k8s_helm_has_host(ref) && cbm_image_ref_valid(ref) + /* a set_sensitive name within the mapping: its value is not read */ + bool sensitive = k8s_helm_sensitive_path(h, path ? path : ""); + int64_t img = ref && !sensitive && k8s_helm_has_host(ref) && cbm_image_ref_valid(ref) ? cbm_pipeline_image_node(ctx, ref) : 0; int before = emitted->n; @@ -4699,7 +4802,7 @@ static void k8s_helm_release(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8s_helm cbm_ht_get(b->charts, dir)) { k8s_helm_release_local(ctx, b, h, l, src, via, dir, rv, &emitted); } - cbm_free(CBM_MEM_CLASS_OTHER, emitted.v); + k8s_himgs_free(&emitted); } /* A node's document (lines start_line..end_line of its file) as values. */ @@ -4792,6 +4895,16 @@ static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_he char *src = kust->file_path ? k8s_repo_read(ctx, kust->file_path, &len) : NULL; yyjson_mut_val *doc = src ? cbm_helm_values_parse(h->doc, src, (size_t)len) : NULL; cbm_free(CBM_MEM_CLASS_OTHER, src); + /* kustomize runs the kustomization's own transformers over the inflated + * chart: images / patches / replacements may change what it deploys */ + static const char *const TRANSFORMS[] = { + "images", "patches", "patchesJson6902", "patchesStrategicMerge", + "replacements", "transformers", "components", NULL}; + for (int i = 0; TRANSFORMS[i]; i++) { + if (k8s_helm_gives(doc, TRANSFORMS[i])) { + return NULL; + } + } yyjson_mut_val *charts = yyjson_mut_is_obj(doc) ? yyjson_mut_obj_get(doc, "helmCharts") : NULL; yyjson_mut_val *inline_vals = NULL; size_t idx, max; @@ -4874,9 +4987,12 @@ static void k8s_helm_link_releases(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 if (jd) { yyjson_doc_free(jd); } + h->sensitive = r->sensitive; k8s_helm_release(ctx, b, h, &l, r->src, "terraform", r->chart, rv); + h->sensitive = NULL; cbm_free(CBM_MEM_CLASS_OTHER, r->values); } + cbm_free(CBM_MEM_CLASS_OTHER, r->sensitive); } cbm_free(CBM_MEM_CLASS_OTHER, ctx->tf_releases); ctx->tf_releases = NULL; @@ -4905,7 +5021,7 @@ static void k8s_helm_free(k8s_helm_t *h) { yyjson_mut_doc_free(h->doc); } cbm_free(CBM_MEM_CLASS_OTHER, h->t); - cbm_free(CBM_MEM_CLASS_OTHER, h->dflt.v); + k8s_himgs_free(&h->dflt); cbm_arena_destroy(&h->a); if (h->edges > 0) { cbm_log_info("pass.k8s.helm_images", "edges", itoa_k8s(h->edges)); diff --git a/src/pipeline/pass_terraform.c b/src/pipeline/pass_terraform.c index 74ab37a2c..a320f6071 100644 --- a/src/pipeline/pass_terraform.c +++ b/src/pipeline/pass_terraform.c @@ -523,15 +523,27 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { static char *tf_read(const char *path, size_t *len); /* A repository file's text (cbm_alloc; capped and padded like every file - * read), NULL when unreadable -- or when it is no file discovery took in: a - * symlink, or a path through one, is never followed out of the repository. */ + * read; an empty file is empty text), NULL when unreadable -- or when it is + * no file discovery took in: a symlink, or a path through one, is never + * followed out of the repository. */ static char *tf_read_repo(const tf_t *t, const char *path, size_t *len) { if (!cbm_pipeline_file_node(t->ctx->gbuf, t->ctx->project_name, path)) { return NULL; } char full[CBM_SZ_4K]; int n = snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path); - return n > 0 && (size_t)n < sizeof(full) ? tf_read(full, len) : NULL; + if (n <= 0 || (size_t)n >= sizeof(full)) { + return NULL; + } + char *buf = tf_read(full, len); + cbm_path_info_t info; + if (!buf && cbm_path_info_utf8(full, &info) == CBM_PATH_INFO_OK && info.is_regular && + !info.is_symlink && info.size == 0) { + enum { TF_EMPTY_PAD = 16 }; + buf = cbm_calloc(CBM_MEM_CLASS_OTHER, TF_EMPTY_PAD); + *len = 0; + } + return buf; } /* A `values = [...]` item as values: a YAML string / heredoc, file() @@ -633,8 +645,8 @@ static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode b return entry; } -/* Is block `a` a `dynamic "set"` / `dynamic "set_list"` -- entries made by a - * for_each, never literal? */ +/* Is block `a` a `dynamic "set"` / `"set_list"` / `"set_sensitive"` -- + * entries (and names) made by a for_each, never literal? */ static bool tf_dynamic_set(const tf_t *t, TSNode a) { TSNode kw = ts_node_named_child(a, 0); TSNode label = ts_node_named_child(a, SKIP_ONE); @@ -649,17 +661,72 @@ static bool tf_dynamic_set(const tf_t *t, TSNode a) { uint32_t lb = ts_node_start_byte(label); size_t ll = ts_node_end_byte(label) - lb; return (ll == strlen("\"set\"") && strncmp(t->src + lb, "\"set\"", ll) == 0) || - (ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0); + (ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0) || + (ll == strlen("\"set_sensitive\"") && + strncmp(t->src + lb, "\"set_sensitive\"", ll) == 0); +} + +/* Is node n an identifier spelling `word`? */ +static bool tf_word(const tf_t *t, TSNode n, const char *word) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t b = ts_node_start_byte(n); + size_t l = ts_node_end_byte(n) - b; + return l == strlen(word) && strncmp(t->src + b, word, l) == 0; +} + +/* The expression of attribute `name` of a block's body or of an HCL object + * (`{ name = ..., value = ... }`); a null node when there is none. */ +static TSNode tf_entry_attr(const tf_t *t, TSNode entry, const char *name) { + TSNode e = tf_unwrap(entry); + for (uint32_t j = 0; j < ts_node_named_child_count(e); j++) { + TSNode c = ts_node_named_child(e, j); + if (tf_type(e, "block") && tf_type(c, "body")) { + for (uint32_t k = 0; k < ts_node_named_child_count(c); k++) { + TSNode at = ts_node_named_child(c, k); + if (tf_type(at, "attribute") && ts_node_named_child_count(at) >= PAIR_LEN && + tf_word(t, ts_node_named_child(at, 0), name)) { + return ts_node_named_child(at, SKIP_ONE); + } + } + } else if (tf_type(e, "object") && tf_type(c, "object_elem")) { + TSNode kn = tf_unwrap(ts_node_child_by_field_name(c, "key", (uint32_t)strlen("key"))); + TSNode id = tf_type(kn, "variable_expr") ? ts_node_named_child(kn, 0) : (TSNode){0}; + if (tf_word(t, id, name)) { + return ts_node_child_by_field_name(c, "val", (uint32_t)strlen("val")); + } + } + } + return (TSNode){0}; +} + +/* The literal name of set_sensitive entry `entry` (a block, or an object of + * the provider v3 list) appended to names -- the name only, the value is + * never read; false when the name is no literal. */ +static bool tf_sensitive_name(const tf_t *t, yyjson_mut_doc *doc, yyjson_mut_val *names, + TSNode entry) { + yyjson_mut_val *nm = tf_json(t, doc, tf_entry_attr(t, entry, "name")); + if (!yyjson_mut_is_str(nm)) { + return false; + } + yyjson_mut_arr_append(names, nm); + return true; } /* The values a helm_release body gives its chart -- `values` items * coalesced in order, then each `set` -- as JSON (cbm_alloc); NULL when a - * part is no literal, or is made by a `dynamic` block. set_sensitive is - * never read. */ -static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, int *nfiles) { + * part is no literal, is made by a `dynamic` block, or a postrender may + * rewrite what it deploys. set_sensitive values are never read: their names + * go to *sensitive (a JSON array, cbm_alloc; NULL: none), and an image that + * reads one is not known (pass_k8s.c). */ +static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, int *nfiles, + char **sensitive) { + *sensitive = NULL; yyjson_mut_doc *doc = yyjson_mut_doc_new(cbm_json_writer_allocator()); yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL; - bool ok = root != NULL; + yyjson_mut_val *names = doc ? yyjson_mut_arr(doc) : NULL; + bool ok = root != NULL && names != NULL; for (int pass = 0; ok && pass < PAIR_LEN; pass++) { for (uint32_t i = 0; ok && i < ts_node_named_child_count(body); i++) { TSNode a = ts_node_named_child(body, i); @@ -669,12 +736,30 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, bool is_set = kl == strlen("set") && strncmp(t->src + kb, "set", kl) == 0; bool is_values = kl == strlen("values") && strncmp(t->src + kb, "values", kl) == 0; /* set_list and dynamic set values are not read: the release's - * values would be partial */ + * values would be partial; a postrender may rewrite its images */ if ((kl == strlen("set_list") && strncmp(t->src + kb, "set_list", kl) == 0) || - tf_dynamic_set(t, a)) { + tf_dynamic_set(t, a) || (tf_type(a, "block") && tf_word(t, key, "postrender"))) { ok = false; break; } + bool is_sensitive = + kl == strlen("set_sensitive") && strncmp(t->src + kb, "set_sensitive", kl) == 0; + if (pass == 0 && is_sensitive && tf_type(a, "block")) { + ok = tf_sensitive_name(t, doc, names, a); + continue; + } + if (pass == 0 && is_sensitive && tf_type(a, "attribute")) { + /* provider v3: set_sensitive = [{ name, value }, ...] */ + TSNode list = tf_unwrap(ts_node_named_child(a, SKIP_ONE)); + ok = tf_type(list, "tuple"); + for (uint32_t j = 0; ok && j < ts_node_named_child_count(list); j++) { + TSNode it = ts_node_named_child(list, j); + if (!tf_type(it, "tuple_start") && !tf_type(it, "tuple_end")) { + ok = tf_sensitive_name(t, doc, names, it); + } + } + continue; + } if (pass == 0 && is_values && tf_type(a, "attribute")) { TSNode list = tf_unwrap(ts_node_named_child(a, SKIP_ONE)); ok = tf_type(list, "tuple"); @@ -706,6 +791,15 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, if (ok) { size_t len = 0; out = yyjson_mut_val_write_opts(root, 0, cbm_json_writer_allocator(), &len, NULL); + if (out && yyjson_mut_arr_size(names) > 0) { + *sensitive = + yyjson_mut_val_write_opts(names, 0, cbm_json_writer_allocator(), &len, NULL); + if (!*sensitive) { + /* the names not kept: no image of the release could be told apart */ + cbm_free(CBM_MEM_CLASS_OTHER, out); + out = NULL; + } + } } if (doc) { yyjson_mut_doc_free(doc); @@ -727,8 +821,9 @@ static void tf_release_push(tf_t *t, int64_t src, int64_t chart, TSNode body) { } const char **files = NULL; int nfiles = 0; - ctx->tf_releases[ctx->tf_release_n++] = - (cbm_tf_release_t){src, chart, tf_release_values(t, body, &files, &nfiles)}; + char *sensitive = NULL; + char *values = tf_release_values(t, body, &files, &nfiles, &sensitive); + ctx->tf_releases[ctx->tf_release_n++] = (cbm_tf_release_t){src, chart, values, sensitive}; /* the values files it reads, like a kustomization's valuesFile: an edit of * one alone renders the release again (pipeline_incremental.c) */ for (int i = 0; i < nfiles; i++) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 553aa19d9..402474c20 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -385,9 +385,10 @@ typedef struct { * when a part is not a literal (an interpolation, a variable). pass_k8s.c * renders the chart's images with them. */ typedef struct cbm_tf_release { - int64_t src; /* the resource.helm_release node */ - int64_t chart; /* the Chart node */ - char *values; /* owned (cbm_free); NULL: unknown */ + int64_t src; /* the resource.helm_release node */ + int64_t chart; /* the Chart node */ + char *values; /* owned (cbm_free); NULL: unknown */ + char *sensitive; /* owned: JSON array of its set_sensitive names (never values); NULL: none */ } cbm_tf_release_t; /* Shared context passed to each pass function. diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index 2227ac308..7b89c755a 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -4871,6 +4871,78 @@ TEST(probe_helm_release_terraform_dynamic_set) { PASS(); } +/* A set_sensitive whose value is never read: a release image reading its + * name (image.tag) is not known; one reading none of them still is. A + * postrender may rewrite what a release deploys: no image of its own. */ +TEST(probe_helm_release_terraform_sensitive_and_postrender) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", + "resource \"helm_release\" \"tagged\" {\n name = \"tagged\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n" + " value = \"quay.io/acme/app\"\n }\n set_sensitive {\n name = \"image.tag\"\n" + " value = var.tag\n }\n}\n" + "resource \"helm_release\" \"pw\" {\n name = \"pw\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n" + " value = \"quay.io/acme/pw\"\n }\n set_sensitive {\n name = \"db.password\"\n" + " value = var.pw\n }\n}\n" + "resource \"helm_release\" \"post\" {\n name = \"post\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n" + " value = \"3.3\"\n }\n postrender {\n binary_path = \"./kustomize.sh\"\n }\n}\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.repository\",\"image.tag\"]\n" + "Class:resource.helm_release.pw -> Image:quay.io/acme/pw DEPLOYS " + "image=quay.io/acme/pw:1.0 via=terraform ref=image override_of=[\"image.repository\"]\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image default_of=[\"image.repository\",\"image.tag\"]"; + if (!gpf_expect_image_edges(files, 4, want)) { + FAIL("set_sensitive names and postrender make release images unknown"); + } + PASS(); +} + +/* A kustomization that also transforms what it inflates (images:, patches) + * has no release image of its own: the transformer may change it. */ +TEST(probe_helm_release_kustomize_transformers) { + static const GpfFile files[] = { + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: kapp\n valuesInline:\n image:\n" + " tag: \"1.5\"\nimages:\n - name: ghcr.io/acme/app\n newTag: \"2.0\"\n"}, + {"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"kz/charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a kustomization's own transformers make its release images unknown"); + } + PASS(); +} + +/* An empty values.yaml is values {} (Helm's), not values not read: the + * subchart under it still renders its image. */ +TEST(probe_helm_empty_values_file_renders) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", + "apiVersion: v2\nname: app\nversion: 1.0.0\nappVersion: \"1.0\"\n"}, + {"charts/app/values.yaml", ""}, + {"charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image"; + if (!gpf_expect_image_edges(files, 3, want)) { + FAIL("an empty values.yaml renders with {}"); + } + PASS(); +} + /* A subchart a dependency aliases renders with .Chart.Name its alias (Helm's * getAliasDependency), so an image naming the chart names the alias. */ TEST(probe_helm_aliased_subchart_chart_name) { @@ -4919,8 +4991,10 @@ TEST(probe_helm_list_path_mark_on_chart_edge) { PASS(); } +#ifndef _WIN32 /* gpf_image_edges with symlinks laid in after the files: links[i] names a - * repository path and the absolute or relative target it points at. */ + * repository path and the absolute or relative target it points at (POSIX + * symlinks: the tests using it skip on Windows). */ static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfFile *links, int nlinks) { GpfProj lp; @@ -4975,6 +5049,7 @@ static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfF gpf_cleanup(&lp, store); return out; } +#endif /* A values file a chart, a kustomization or a Terraform file() names by path * is read only when it is a file of the repository: a symlink out of it -- @@ -4982,6 +5057,9 @@ static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfF * file is such a link renders nothing (its values are unknown, not empty); * the releases reading one have no image of their own. */ TEST(probe_helm_values_symlink_not_followed) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX symlink contract"); +#else char outside[256]; snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX"); ASSERT_NOT_NULL(cbm_mkdtemp(outside)); @@ -5024,11 +5102,15 @@ TEST(probe_helm_values_symlink_not_followed) { FAIL("no values read through a symlink out of the repository"); } PASS(); +#endif } /* A Terraform helm_release reading its values with file() of a symlink out * of the repository has no image of its own: the link is never followed. */ TEST(probe_helm_terraform_values_symlink_not_followed) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX symlink contract"); +#else char outside[256]; snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX"); ASSERT_NOT_NULL(cbm_mkdtemp(outside)); @@ -5057,6 +5139,7 @@ TEST(probe_helm_terraform_values_symlink_not_followed) { FAIL("no Terraform values read through a symlink out of the repository"); } PASS(); +#endif } /* A build argument holding a secret -- by its name (*_TOKEN) or its value (a @@ -6929,6 +7012,9 @@ SUITE(grammar_probe_f) { RUN_TEST(probe_helm_release_flux_unread_sources); RUN_TEST(probe_helm_release_kustomize_unread_sources); RUN_TEST(probe_helm_release_terraform_dynamic_set); + RUN_TEST(probe_helm_release_terraform_sensitive_and_postrender); + RUN_TEST(probe_helm_release_kustomize_transformers); + RUN_TEST(probe_helm_empty_values_file_renders); RUN_TEST(probe_helm_aliased_subchart_chart_name); RUN_TEST(probe_helm_list_path_mark_on_chart_edge); RUN_TEST(probe_helm_values_symlink_not_followed); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index e86fd2e77..dc03c8cf7 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4559,6 +4559,191 @@ TEST(helm_change_of_a_new_value_spoils_nothing) { PASS(); } +/* An image line inside a range: n captures of one probe cost memory linear + * in n (the capture arrays grow by doubling), not a copy of every earlier + * capture each time. */ +TEST(helm_render_captures_grow_linearly) { + enum { ITER = 5000 }; + static const char tpl[] = "{{- range 5000 }}\n" + "image: \"ghcr.io/acme/app:1\"\n" + "{{ end }}\n"; + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probe = {.line = 2}; + size_t before = cbm_arena_total(&a); + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), NULL, NULL, NULL, &probe, 1); + size_t used = cbm_arena_total(&a) - before; + int n = probe.n; + cbm_arena_destroy(&a); + ASSERT_EQ(n, ITER); + ASSERT_TRUE(used < (size_t)CBM_SZ_16 * CBM_SZ_1K * CBM_SZ_1K); /* far below 8*n^2 bytes */ + PASS(); +} + +/* Memory a render takes -- here a 20,000-item values list copied by each of + * 200 `first` calls -- counts against its budget: past 256 MiB it stops and + * the template's captures are UNKNOWN. */ +TEST(helm_render_memory_budget_ends_unknown) { + enum { ITEMS = 20000 }; + size_t cap = (size_t)ITEMS * 4 + CBM_SZ_64; + char *vals = malloc(cap); + ASSERT_NOT_NULL(vals); + size_t o = (size_t)snprintf(vals, cap, "tag: \"1.0\"\nbig: ["); + for (int i = 0; i < ITEMS; i++) { + o += (size_t)snprintf(vals + o, cap - o, "%s1", i ? "," : ""); + } + snprintf(vals + o, cap - o, "]\n"); + static const char tpl[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n" + "{{- range 200 }}{{ $x := first $.Values.big }}{{ end }}\n"; + char *got = helm_capture(vals, tpl, NULL, 1); + free(vals); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A set skipped by an UNKNOWN condition whose target is a call's result -- + * `index .Values "image"`, the idiom for a dashed key -- may change the map + * it names: what the image reads of it is UNKNOWN. */ +TEST(helm_skipped_set_of_a_call_result) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = + "{{- if semverCompare \">=1.29-0\" .Capabilities.KubeVersion.Version }}" + "{{- $_ := set (index .Values \"image\") \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A new map given a map another part holds is new no more: a change made + * through it (`set $img ...` of `index $d "image"`) reaches .Values. */ +TEST(helm_own_map_given_a_shared_one) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = "{{- $d := dict -}}{{- $_ := set $d \"image\" .Values.image -}}\n" + "{{- $img := index $d \"image\" -}}{{- $_ := set $img \"tag\" \"2.0\" -}}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A set whose target is an UNKNOWN call result (index by .Release.Name) may + * change any map of the values: every image after it is UNKNOWN. */ +TEST(helm_set_of_an_unknown_call_result) { + static const char vals[] = + "apps: {web: {repository: ghcr.io/acme/web, tag: \"1.0\"}}\n"; + static const char tpl[] = "{{- $_ := set (index .Values.apps .Release.Name) \"tag\" \"2.0\" }}\n" + "image: \"{{ .Values.apps.web.repository }}:{{ .Values.apps.web.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A break under an UNKNOWN condition: whether the rest of the loop ran is + * unknown, so what it assigns ($tag = .) is UNKNOWN after the loop. */ +TEST(helm_unknown_break_spoils_the_rest_of_the_loop) { + static const char vals[] = "versions: [\"1.0\", \"2.0\"]\n"; + static const char tpl[] = + "{{- $tag := \"0.9\" }}{{- range .Values.versions }}\n" + "{{- if semverCompare \">=1.29-0\" $.Capabilities.KubeVersion.Version }}{{ break }}" + "{{ end }}\n" + "{{- $tag = . }}{{- end }}\n" + "image: \"ghcr.io/acme/app:{{ $tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 4); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A variable once made from `dict` and then (in a body an UNKNOWN condition + * skips) redefined or given .Values.image is no new map: a set of it there + * may change .Values. Without the redefinition the set is of the new map. */ +TEST(helm_skipped_set_after_redefinition) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char redefined[] = + "{{- $cfg := dict }}{{- if .Capabilities.APIVersions }}" + "{{- $cfg := .Values.image }}{{- $_ := set $cfg \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + static const char assigned[] = + "{{- $cfg := dict }}{{- if .Capabilities.APIVersions }}" + "{{- $cfg = .Values.image }}{{- $_ := set $cfg \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + static const char kept[] = "{{- $cfg := dict }}{{- if .Capabilities.APIVersions }}" + "{{- $_ := set $cfg \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, redefined, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + got = helm_capture(vals, assigned, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + got = helm_capture(vals, kept, NULL, 2); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + PASS(); +} + +/* Many maps poisoned (a new dict changed each iteration) keep every later + * read correct: a values read after them is known. */ +TEST(helm_many_changed_maps_keep_reads_right) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = "{{- range 20000 }}{{ $c := dict }}{{ $_ := set $c \"k\" \"v\" }}" + "{{ end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 2); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + PASS(); +} + +/* Defines that include each other: A changes its argument and includes B, + * B includes A. Whether B may change its argument is not settled by A's + * scan still running -- an UNKNOWN include of B with .Values.image spoils + * what reads it. */ +TEST(helm_define_memo_under_mutual_recursion) { + static const char defs_src[] = + "{{- define \"A\" -}}{{- include \"B\" . -}}{{- $_ := set . \"tag\" \"2.0\" -}}{{- end -}}\n" + "{{- define \"B\" -}}{{- if false }}{{ include \"A\" . }}{{ end -}}{{- end -}}\n"; + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "templates/_ab.tpl", defs_src, strlen(defs_src)); + static const char vals[] = + "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\nother: {tag: \"x\"}\n"; + static const char tpl[] = + "{{- if .Capabilities.APIVersions }}{{ include \"A\" .Values.other }}{{ end }}\n" + "{{- if .Capabilities.APIVersions }}{{ include \"B\" .Values.image }}{{ end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, d, 3); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* An include not run because its argument is poisoned may still change maps + * within it: a handle taken before ($sub) reads UNKNOWN after. */ +TEST(helm_include_with_poisoned_argument) { + static const char defs_src[] = + "{{- define \"mut\" -}}{{- $_ := set .sub \"tag\" \"2.0\" -}}{{- end -}}\n"; + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "templates/_mut.tpl", defs_src, strlen(defs_src)); + static const char vals[] = "image: {sub: {repository: ghcr.io/acme/app, tag: \"1.0\"}}\n"; + static const char tpl[] = "{{- $sub := .Values.image.sub -}}\n" + "{{- $_ := set .Values.image \"x\" \"y\" -}}\n" + "{{- include \"mut\" .Values.image -}}\n" + "image: \"{{ $sub.repository }}:{{ $sub.tag }}\"\n"; + char *got = helm_capture(vals, tpl, d, 4); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + /* A compose project's .env feeds its compose file (variables in image, * build): changing it is no closure repair -- the unchanged compose file's * services would keep the old image. It rebuilds; no stale image remains. */ @@ -24297,6 +24482,16 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(helm_mutator_spoils_the_map_it_changes); RUN_TEST(helm_unknown_break_leaves_the_loop); RUN_TEST(helm_change_of_a_new_value_spoils_nothing); + RUN_TEST(helm_render_captures_grow_linearly); + RUN_TEST(helm_render_memory_budget_ends_unknown); + RUN_TEST(helm_skipped_set_of_a_call_result); + RUN_TEST(helm_own_map_given_a_shared_one); + RUN_TEST(helm_set_of_an_unknown_call_result); + RUN_TEST(helm_unknown_break_spoils_the_rest_of_the_loop); + RUN_TEST(helm_skipped_set_after_redefinition); + RUN_TEST(helm_many_changed_maps_keep_reads_right); + RUN_TEST(helm_define_memo_under_mutual_recursion); + RUN_TEST(helm_include_with_poisoned_argument); RUN_TEST(incremental_compose_dotenv_change_rebuilds); RUN_TEST(incremental_dockerfile_edit_keeps_docker_build_edges); RUN_TEST(incremental_second_build_declarer_keeps_the_edge);