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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-18 23:28:43 +02:00
parent 4cea54f758
commit 5c5964efb9
7 changed files with 953 additions and 192 deletions
+425 -158
View File
@@ -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;
}
+1
View File
@@ -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;
+132 -16
View File
@@ -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));
+109 -14
View File
@@ -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(<path>)
@@ -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++) {
+4 -3
View File
@@ -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.
+87 -1
View File
@@ -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);
+195
View File
@@ -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, "<UNKNOWN>");
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, "<UNKNOWN>");
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, "<UNKNOWN>");
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, "<UNKNOWN>");
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, "<UNKNOWN>");
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, "<UNKNOWN>");
free(got);
got = helm_capture(vals, assigned, NULL, 2);
ASSERT_STR_EQ(got, "<UNKNOWN>");
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, "<UNKNOWN>");
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, "<UNKNOWN>");
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);