From 4cea54f75835cadd402d60733c9310113e11a4a6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 21:08:20 +0200 Subject: [PATCH] fix(deploy): bound Helm rendering on hostile input, no partial or wrong release images Review round 1 of #2677. Every case ends in an explicit unknown, never in a hang, an OOM, a partial or a wrong image. Bounds (helm_template.c): - A per-render step and output budget (10,000,000 steps, 64 MiB): a range over a huge int, a huge indent, defines that include each other in a fan stop the render; every probe of the template is UNKNOWN, also one captured before, and the trip is logged (helm.render.budget). Counted, never timed. On the 20 infra corpora no render reaches it. - go-yaml v2's alias limit: values whose decodes are mostly alias expansion (a billion laughs) do not parse, as in Helm. - --set: an index past strvals' MaxIndex (65536) or a key longer than the parser holds is no value (the release is unknown), never a padded list or a split key. Reads (pass_k8s.c, pass_terraform.c): a values file, Chart.yaml, kustomize valuesFile or Terraform file() named by path is read only when it is a discovered file of the repository -- never through a symlink out of it -- with the size cap and tree-sitter padding of every other read. A chart file that is there but not read makes the chart's values unknown, not empty. Wrong images: - Terraform number sets reach the chart as Terraform's exact decimal (1.10 -> "1.1"), not %.17g. - A release with a value source not read -- Flux valuesFrom, valuesFiles, postRenderers; kustomize additionalValuesFiles, a valuesMerge other than override, a valuesFile not read; Terraform dynamic "set" -- has no image of its own. - An aliased subchart renders with .Chart.Name its alias. - An UNKNOWN if / with / range makes what its bodies assign UNKNOWN and leaves the loop a break in them would; set / unset / merge poison the map they change (merge: the maps within too). A change that may reach values another part holds taints every later capture; one of a new value (fromYaml, deepCopy, an empty dict, a part of one) does not. Marks and secrets: - The chart edge reads default_of as JSON (list paths kept), builds its properties to size, and writes no edge whose mark it could not carry whole. - Build arguments with a secret (by name or value) are never kept; build edge properties and values-path marks are never cut. Tests: one per bound and per wrong-image case, each RED on d21949ef by its assertion; a Helm chart joins the Kubernetes complexity fixture. Signed-off-by: Martin Vogel --- src/pipeline/helm_template.c | 839 +++++++++++++++++- src/pipeline/helm_template.h | 6 +- src/pipeline/pass_ci.c | 19 +- src/pipeline/pass_k8s.c | 230 +++-- src/pipeline/pass_shell.c | 28 +- src/pipeline/pass_terraform.c | 186 +++- .../complexity/Kubernetes/chart/Chart.yaml | 4 + .../Kubernetes/chart/templates/_helpers.tpl | 4 + .../Kubernetes/chart/templates/deploy.yaml | 14 + .../complexity/Kubernetes/chart/values.yaml | 12 + tests/test_grammar_probe_f.c | 355 ++++++++ tests/test_pipeline.c | 234 +++++ 12 files changed, 1780 insertions(+), 151 deletions(-) create mode 100644 tests/fixtures/complexity/Kubernetes/chart/Chart.yaml create mode 100644 tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl create mode 100644 tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml create mode 100644 tests/fixtures/complexity/Kubernetes/chart/values.yaml diff --git a/src/pipeline/helm_template.c b/src/pipeline/helm_template.c index 79293e638..fa516ae00 100644 --- a/src/pipeline/helm_template.c +++ b/src/pipeline/helm_template.c @@ -23,6 +23,7 @@ #include "foundation/constants.h" #include "foundation/hash_table.h" #include "foundation/json_writer.h" /* cbm_json_writer_allocator */ +#include "foundation/log.h" #include "foundation/mem_core.h" #include "cbm.h" #include "lang_specs.h" @@ -34,7 +35,17 @@ #include #include -enum { HELM_MAX_NESTING = 1000 }; /* Helm engine's recursionMaxNums */ +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 + * 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 */ /* ── Values: YAML -> JSON ─────────────────────────────────────────── */ @@ -42,6 +53,7 @@ typedef struct { const char *name; size_t len; yyjson_mut_val *val; + long decodes; /* the nodes its value took to decode, its aliases expanded */ } hy_anchor_t; typedef struct { @@ -51,8 +63,37 @@ typedef struct { int nanchors; int cap; bool unknown; + long decodes; /* nodes decoded, alias expansions included (go-yaml decodeCount) */ + long alias_decodes; /* of those, the ones an alias expanded (aliasCount) */ } hy_t; +/* go-yaml v2 (decode.go): a document whose decodes come mostly from alias + * expansion -- a billion laughs -- is an error, so Helm reads no values. The + * share allowed falls from 99% below 400,000 decodes to 10% above 4,000,000. */ +enum { + HY_ALIAS_MIN_ALIASES = 100, + HY_ALIAS_MIN_DECODES = 1000, + HY_ALIAS_RANGE_LOW = 400000, + HY_ALIAS_RANGE_HIGH = 4000000 +}; +static const double HY_ALIAS_RATIO_SMALL = 0.99; +static const double HY_ALIAS_RATIO_LARGE = 0.10; + +static bool hy_excessive_aliasing(const hy_t *y) { + if (y->alias_decodes <= HY_ALIAS_MIN_ALIASES || y->decodes <= HY_ALIAS_MIN_DECODES) { + return false; + } + double allowed = HY_ALIAS_RATIO_SMALL; + if (y->decodes >= HY_ALIAS_RANGE_HIGH) { + allowed = HY_ALIAS_RATIO_LARGE; + } else if (y->decodes > HY_ALIAS_RANGE_LOW) { + allowed = HY_ALIAS_RATIO_SMALL - (HY_ALIAS_RATIO_SMALL - HY_ALIAS_RATIO_LARGE) * + ((double)(y->decodes - HY_ALIAS_RANGE_LOW) / + (double)(HY_ALIAS_RANGE_HIGH - HY_ALIAS_RANGE_LOW)); + } + return (double)y->alias_decodes / (double)y->decodes > allowed; +} + static bool hy_type(TSNode n, const char *t) { return !ts_node_is_null(n) && strcmp(ts_node_type(n), t) == 0; } @@ -63,7 +104,7 @@ static const char *hy_text(const hy_t *y, TSNode n, size_t *len) { return y->src + b; } -static void hy_anchor(hy_t *y, const char *name, size_t len, yyjson_mut_val *v) { +static void hy_anchor(hy_t *y, const char *name, size_t len, yyjson_mut_val *v, long decodes) { if (y->nanchors == y->cap) { int cap = y->cap ? y->cap * PAIR_LEN : CBM_SZ_16; hy_anchor_t *g = cbm_realloc(CBM_MEM_CLASS_OTHER, y->anchors, (size_t)cap * sizeof(*g)); @@ -74,12 +115,19 @@ static void hy_anchor(hy_t *y, const char *name, size_t len, yyjson_mut_val *v) y->anchors = g; y->cap = cap; } - y->anchors[y->nanchors++] = (hy_anchor_t){name, len, v}; + y->anchors[y->nanchors++] = (hy_anchor_t){name, len, v, decodes}; } +/* An alias: its anchor's value decoded again (a copy), as go-yaml counts it. */ static yyjson_mut_val *hy_alias(hy_t *y, const char *name, size_t len) { for (int i = y->nanchors - SKIP_ONE; i >= 0; i--) { if (y->anchors[i].len == len && memcmp(y->anchors[i].name, name, len) == 0) { + y->decodes += y->anchors[i].decodes; + y->alias_decodes += y->anchors[i].decodes; + if (hy_excessive_aliasing(y)) { + y->unknown = true; + return NULL; + } return yyjson_mut_val_mut_copy(y->doc, y->anchors[i].val); } } @@ -609,20 +657,24 @@ static yyjson_mut_val *hy_node(hy_t *y, TSNode n) { } } yyjson_mut_val *v = NULL; + long before = y->decodes; if (ts_node_is_null(content)) { + y->decodes++; v = yyjson_mut_null(y->doc); } else if (str_tag && hy_type(content, "plain_scalar")) { size_t l = 0; const char *s = hy_text(y, content, &l); + y->decodes++; v = yyjson_mut_strncpy(y->doc, s, l); } else { v = hy_node(y, content); } if (v && anchor) { - hy_anchor(y, anchor, al, v); + hy_anchor(y, anchor, al, v, y->decodes - before); } return v; } + y->decodes++; /* a mapping, sequence, scalar or alias node */ size_t l = 0; const char *s = hy_text(y, n, &l); if (strcmp(t, "block_mapping") == 0) { @@ -786,11 +838,15 @@ bool cbm_helm_values_set(yyjson_mut_doc *doc, yyjson_mut_val *root, const char * key[k++] = *p++; } key[k] = '\0'; + if (*p && *p != '.' && *p != '[') { + return false; /* a key longer than the buffer: never split into two */ + } long index = -SKIP_ONE; if (*p == '[') { char *end = NULL; index = strtol(p + SKIP_ONE, &end, 10); - if (!end || *end != ']' || index < 0) { + /* strvals: a negative index or one past MaxIndex is an error */ + if (!end || *end != ']' || index < 0 || index > HELM_MAX_SET_INDEX) { return false; } p = end + SKIP_ONE; @@ -1192,9 +1248,12 @@ struct hv { hv_map_t *m; hv_list_t *l; const char *path; /* the values path it holds; NULL: none */ + bool own; /* a new value holding nothing another part holds (fromYaml, deepCopy, + * a part of one; known or not): changing it changes nothing else */ }; static const hv_t HV_UNKNOWN = {.k = HV_UNK}; +static const hv_t HV_UNKNOWN_OWN = {.k = HV_UNK, .own = true}; static hv_t hv_nil(void) { return (hv_t){.k = HV_NIL}; @@ -1305,6 +1364,12 @@ 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; @@ -1315,6 +1380,16 @@ typedef struct { int nesting; bool brk; 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 */ + /* 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; + 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) */ /* probes of the top-level template */ const char *top_src; size_t top_len; @@ -1333,10 +1408,32 @@ typedef struct { int tcap; } hst_t; +/* 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) { + st->stop = true; + } + return !st->stop; +} + +/* 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) { + st->stop = true; + return false; + } + st->written += n; + return true; +} + static void hs_append(hst_t *st, hsink_t *s, const char *p, size_t n) { if (s->unk || n == 0) { return; } + if (!hst_write(st, n)) { + s->unk = true; + return; + } if (s->n + n + SKIP_ONE > s->cap) { size_t cap = (s->cap ? s->cap : CBM_SZ_64) * PAIR_LEN; while (cap < s->n + n + SKIP_ONE) { @@ -1442,6 +1539,85 @@ static hvar_t *hst_var(hst_t *st, const hframe_t *fr, const char *name, size_t n return NULL; } +/* Has a set / unset / merge changed map v (or, for a merge, a map holding + * it)? Then what v holds is UNKNOWN. */ +static bool hv_poisoned(const hst_t *st, const hv_t *v) { + const void *map = v->k == HV_MAP ? (const void *)v->m + : v->k == HV_JSON ? (const void *)v->j + : NULL; + 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; + } + } + } + return false; +} + +static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth); + +/* Map v changed in place; false when v is no map (or no room to say so). A + * merge (`deep`) may change every map within it too -- a map the template + * made (dict) holds others by reference, so those are poisoned as well. */ +static bool hst_poison(hst_t *st, const hv_t *v, bool deep) { + return hst_poison_at(st, v, deep, 0); +} + +static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth) { + if (depth > HELM_MAX_NESTING) { + return false; + } + if (deep && v->k == HV_LIST) { + /* a list itself is never changed in place; the maps in it may be */ + for (int i = 0; i < v->l->n; i++) { + if ((hv_is_map(&v->l->items[i]) || hv_is_list(&v->l->items[i])) && + !hst_poison_at(st, &v->l->items[i], deep, depth + SKIP_ONE)) { + return false; + } + } + return true; + } + /* a values list (deep): by its path, which every item's path extends */ + const void *map = v->k == HV_MAP ? (const void *)v->m + : v->k == HV_JSON && (yyjson_mut_is_obj(v->j) || (deep && v->path)) + ? (const void *)v->j + : NULL; + if (!map) { + return false; + } + if (deep && v->k == HV_MAP) { + for (int i = 0; i < v->m->n; i++) { + if (hv_is_map(&v->m->vals[i]) && !hst_poison_at(st, &v->m->vals[i], deep, depth + 1)) { + return false; + } + } + } + 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; + } + st->poison[st->npoison++] = (hpoison_t){map, v->path, deep}; + return true; +} + static bool hn_is(TSNode n, const char *t) { return !ts_node_is_null(n) && strcmp(ts_node_type(n), t) == 0; } @@ -1574,10 +1750,13 @@ static void hst_capture_end(hst_t *st) { memcpy(img, p->image, (size_t)p->n * sizeof(*img)); memcpy(keys, p->keys, (size_t)p->n * sizeof(*keys)); } - img[p->n] = st->cap.unk ? NULL : hst_scalar(st, st->cap.buf ? st->cap.buf : "", st->cap.n); + img[p->n] = st->cap.unk || st->tainted + ? NULL + : hst_scalar(st, st->cap.buf ? st->cap.buf : "", st->cap.n); /* the leaves read: a path that is the prefix of another is dropped */ const char *json = NULL; - for (int i = 0; i < st->nck; i++) { + bool whole = true; + for (int i = 0; i < st->nck && whole; i++) { size_t li = strlen(st->ckeys[i]); bool prefix = false; for (int j = 0; j < st->nck && !prefix; j++) { @@ -1587,9 +1766,13 @@ static void hst_capture_end(hst_t *st) { if (prefix || !st->ckeys[i][0]) { continue; } - char esc[CBM_SZ_1K]; + char *esc = cbm_arena_alloc(st->a, li * PAIR_LEN + SKIP_ONE); + if (!esc) { + whole = false; + break; + } size_t o = 0; - for (const char *c = st->ckeys[i]; *c && o + 3 < sizeof(esc); c++) { + for (const char *c = st->ckeys[i]; *c; c++) { if (*c == '"' || *c == '\\') { esc[o++] = '\\'; } @@ -1597,8 +1780,12 @@ static void hst_capture_end(hst_t *st) { } esc[o] = '\0'; json = cbm_arena_sprintf(st->a, "%s%s\"%s\"", json ? json : "[", json ? "," : "", esc); + whole = json != NULL; + } + keys[p->n] = json && whole ? cbm_arena_sprintf(st->a, "%s]", json) : NULL; + if (!whole || (json && !keys[p->n])) { + img[p->n] = NULL; /* a mark not written in full: no image, never one unmarked */ } - keys[p->n] = json ? cbm_arena_sprintf(st->a, "%s]", json) : NULL; p->image = img; p->keys = keys; p->n++; @@ -1720,6 +1907,9 @@ static hv_t he_render_define(hst_t *st, const char *name, hv_t arg, hsink_t *int /* Field `name` of value v. */ static hv_t he_field(hst_t *st, const hv_t *v, const char *name, size_t nl) { + if (hv_poisoned(st, v)) { + return HV_UNKNOWN; + } if (v->k == HV_MAP) { for (int i = 0; i < v->m->n; i++) { if (strlen(v->m->keys[i]) == nl && memcmp(v->m->keys[i], name, nl) == 0) { @@ -1739,6 +1929,9 @@ static hv_t he_field(hst_t *st, const hv_t *v, const char *name, size_t nl) { } static hv_t he_index1(hst_t *st, const hv_t *v, const hv_t *k) { + if (hv_poisoned(st, v)) { + return HV_UNKNOWN; + } if (hv_is_map(v)) { if (k->k != HV_STR) { return HV_UNKNOWN; @@ -2074,10 +2267,413 @@ static hv_t he_strfn(hst_t *st, const char *fn, const hv_t *a, int n) { return HV_UNKNOWN; } -/* A function call `fn` with its evaluated arguments (the piped value last). */ +/* Sprig functions that change a map in place. */ +static bool he_mutator(const char *s, size_t n) { + static const char *const MUTATORS[] = { + "set", "unset", "merge", "mergeOverwrite", "mustMerge", "mustMergeOverwrite", NULL}; + return hy_in(s, n, MUTATORS); +} + +static bool he_define_may_mutate(hst_t *st, const char *name, int depth); + +/* The define a call / action names with a string literal into the arena; + * NULL (and *computed) when the name is no literal. */ +static const char *he_called_define(hst_t *st, const char *src, TSNode c, bool *computed) { + TSNode nm = {0}; + *computed = false; + if (hn_is(c, "function_call")) { + TSNode fn = hn_field(c, "function"); + TSNode args = hn_field(c, "arguments"); + uint32_t fb = ts_node_is_null(fn) ? 0 : ts_node_start_byte(fn); + size_t fl = ts_node_is_null(fn) ? 0 : ts_node_end_byte(fn) - fb; + if (fl != strlen("include") || strncmp(src + fb, "include", fl) != 0) { + return NULL; + } + nm = ts_node_is_null(args) ? (TSNode){0} : ts_node_named_child(args, 0); + } else { + nm = hn_field(c, "name"); + } + if (!hn_is(nm, "interpreted_string_literal") && !hn_is(nm, "raw_string_literal")) { + *computed = true; + return NULL; + } + uint32_t b = ts_node_start_byte(nm); + size_t l = ts_node_end_byte(nm) - b; + char *name = cbm_arena_alloc(st->a, l * 4 + SKIP_ONE); + size_t k = name ? hg_unquote(src + b, l, name) : (size_t)-1; + if (k == (size_t)-1) { + *computed = true; + return NULL; + } + name[k] = '\0'; + return name; +} + +/* How new a value is: HF_DEEP holds nothing another part holds (fromYaml, + * deepCopy, a dict / list of literals); HF_SHALLOW is a new map or list that + * may hold others' maps by reference (dict / list / omit / pick of values) -- + * a set / unset of it changes nothing else, a merge into it may. */ +typedef enum { HF_NONE = 0, HF_SHALLOW, HF_DEEP } hfresh_lvl_t; + +/* Sprig functions whose result holds nothing another part holds. */ +static bool he_own_fn(const char *s, size_t n) { + static const char *const OWN[] = { + "fromYaml", "fromJson", "fromYamlArray", "fromJsonArray", "deepCopy", "mustDeepCopy", NULL}; + return hy_in(s, n, OWN); +} + +/* Sprig functions whose result is a new map or list of what they are given. */ +static bool he_wrap_fn(const char *s, size_t n) { + static const char *const WRAP[] = {"dict", "list", "omit", "pick", NULL}; + return hy_in(s, n, WRAP); +} + +/* The text of call node c's function (a function_call's, or a bare + * identifier's); NULL when it has none. */ +static const char *he_fn_text(const char *src, TSNode c, size_t *n) { + TSNode fn = hn_is(c, "identifier") ? c : hn_field(c, "function"); + if (ts_node_is_null(fn)) { + return NULL; + } + uint32_t b = ts_node_start_byte(fn); + *n = ts_node_end_byte(fn) - b; + return src + b; +} + +/* The name text of variable node var ("" for `$`). */ +static const char *he_var_text(const char *src, TSNode var, size_t *n) { + TSNode nm = hn_field(var, "name"); + if (ts_node_is_null(nm)) { + *n = 0; + return ""; + } + uint32_t b = ts_node_start_byte(nm); + *n = ts_node_end_byte(nm) - b; + return src + b; +} + +/* The variables a scan saw made new, and how. */ +typedef struct { + const char **name; + size_t *len; + hfresh_lvl_t *lvl; + int n; + int cap; +} hfresh_t; + +static hfresh_lvl_t he_fresh_var(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 = f->n - SKIP_ONE; i >= 0; i--) { + if (f->len[i] == vl && memcmp(f->name[i], vs, vl) == 0) { + return f->lvl[i]; + } + } + return HF_NONE; +} + +static bool he_literal(TSNode x) { + return hn_is(x, "interpreted_string_literal") || hn_is(x, "raw_string_literal") || + hn_is(x, "int_literal") || hn_is(x, "float_literal") || hn_is(x, "true") || + hn_is(x, "false") || hn_is(x, "nil"); +} + +/* How new expression `val` is: a constructor's result (`... | fromYaml`, + * `dict`), or a part dug / got / indexed out of a variable that is new. */ +static hfresh_lvl_t he_fresh_value(const hfresh_t *f, const char *src, TSNode val) { + while (hn_is(val, "parenthesized_pipeline") && ts_node_named_child_count(val) == SKIP_ONE) { + val = ts_node_named_child(val, 0); + } + if (hn_is(val, "chained_pipeline")) { + uint32_t c = ts_node_named_child_count(val); + val = c ? ts_node_named_child(val, c - SKIP_ONE) : (TSNode){0}; + } + size_t fl = 0; + const char *fs = + hn_is(val, "function_call") || hn_is(val, "identifier") ? he_fn_text(src, val, &fl) : NULL; + if (!fs) { + return HF_NONE; + } + if (he_own_fn(fs, fl)) { + return HF_DEEP; + } + TSNode args = hn_field(val, "arguments"); + uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); + if (he_wrap_fn(fs, fl)) { + bool literal = true; /* a dict / list of literals holds no one's map */ + for (uint32_t i = 0; i < na && literal; i++) { + literal = he_literal(ts_node_named_child(args, i)); + } + return literal ? HF_DEEP : HF_SHALLOW; + } + if (na == 0) { + return HF_NONE; + } + /* a part of a deep-new value: dig takes its map last; get and index first */ + TSNode from = fl == strlen("dig") && memcmp(fs, "dig", fl) == 0 + ? ts_node_named_child(args, na - SKIP_ONE) + : (fl == strlen("get") && memcmp(fs, "get", fl) == 0) || + (fl == strlen("index") && memcmp(fs, "index", fl) == 0) + ? ts_node_named_child(args, 0) + : (TSNode){0}; + return hn_is(from, "variable") && he_fresh_var(f, src, from) == HF_DEEP ? HF_DEEP : HF_NONE; +} + +/* Is expression x deep-new: a literal, a deep-new variable or value, or a + * dict / list of such? */ +static bool he_deep_value(const hfresh_t *f, const char *src, TSNode x, int depth) { + while (hn_is(x, "parenthesized_pipeline") && ts_node_named_child_count(x) == SKIP_ONE) { + x = ts_node_named_child(x, 0); + } + if (depth > HELM_MAX_NESTING || ts_node_is_null(x)) { + return false; + } + if (he_literal(x)) { + return true; + } + if (hn_is(x, "variable")) { + return he_fresh_var(f, src, x) == HF_DEEP; + } + hfresh_lvl_t lvl = he_fresh_value(f, src, x); + if (lvl != HF_SHALLOW || !hn_is(x, "function_call")) { + return lvl == HF_DEEP; + } + size_t fl = 0; + const char *fs = he_fn_text(src, x, &fl); + if (!fs || !(fl == strlen("dict") || fl == strlen("list")) || + (memcmp(fs, "dict", fl) != 0 && memcmp(fs, "list", fl) != 0)) { + return false; /* omit / pick keep the maps they are given */ + } + TSNode args = hn_field(x, "arguments"); + for (uint32_t i = 0; !ts_node_is_null(args) && i < ts_node_named_child_count(args); i++) { + if (!he_deep_value(f, src, ts_node_named_child(args, i), depth + SKIP_ONE)) { + return false; + } + } + 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) { + return; + } + if (f->n == f->cap) { + int cap = f->cap ? f->cap * PAIR_LEN : CBM_SZ_16; + const char **g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); + size_t *gl = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gl)); + hfresh_lvl_t *gv = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gv)); + if (!g || !gl || !gv) { + return; + } + if (f->n) { + memcpy(g, f->name, (size_t)f->n * sizeof(*g)); + memcpy(gl, f->len, (size_t)f->n * sizeof(*gl)); + memcpy(gv, f->lvl, (size_t)f->n * sizeof(*gv)); + } + *f = (hfresh_t){g, gl, gv, f->n, cap}; + } + f->name[f->n] = he_var_text(src, var, &f->len[f->n]); + f->lvl[f->n] = lvl; + f->n++; +} + +/* 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) { + 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, "dot") || hn_is(c, "field"); + 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))) { + return true; + } + /* read without recording: the part is not run, its reads are no capture's */ + int capture = st->capture; + st->capture = -SKIP_ONE; + hv_t v = he_eval(st, fr, x); + st->capture = capture; + /* a known map is poisoned even when own: what it holds is not known now */ + bool poisoned = hst_poison(st, &v, deep); + return !poisoned && !v.own; +} + +/* 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. */ +static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hframe_t *fr, + bool dot_known) { + if (depth > HELM_MAX_NESTING) { + return true; + } + hfresh_t fresh = {0}; /* variables n makes from a new map */ + TSTreeCursor cur = ts_tree_cursor_new(n); + bool found = false; + bool more = true; + while (more && !found && hst_step(st)) { + TSNode c = ts_tree_cursor_current_node(&cur); + 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); + } 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); + } + } else if (strcmp(t, "function_call") == 0 || strcmp(t, "template_action") == 0 || + strcmp(t, "block_action") == 0) { + bool computed = false; + const char *name = he_called_define(st, src, c, &computed); + if (!computed && name && he_define_may_mutate(st, name, depth + SKIP_ONE)) { + /* what the define changes is its argument (or a map within it) */ + TSNode args = hn_field(c, "arguments"); + TSNode arg = hn_is(c, "function_call") + ? (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); + } + } 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")) { + found = true; /* `x | set`: the target is the piped value, not known here */ + } + /* a variable's name ($set) is no call */ + if (found || (strcmp(t, "variable") != 0 && 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 || st->stop; +} + +/* Memoized per define (a define reached again while it is scanned: no). */ +static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { + enum { HM_NO = 1, HM_YES = 2 }; + 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 */ + } + 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; + } + const char *key = cbm_arena_strdup(st->a, name); + if (st->mutates && key) { + cbm_ht_set(st->mutates, key, (void *)(uintptr_t)HM_NO); + } + bool m = he_may_mutate(st, d->file->src, d->node, depth, NULL, false); + if (st->mutates && key) { + cbm_ht_set(st->mutates, key, (void *)(uintptr_t)(m ? HM_YES : HM_NO)); + } + return m; +} + +static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n); + +/* 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) { + 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; + } + if (strcmp(fn, "get") == 0 || strcmp(fn, "index") == 0 || strcmp(fn, "first") == 0 || + strcmp(fn, "last") == 0) { + return a[0].own; + } + if (strcmp(fn, "dict") == 0 || strcmp(fn, "list") == 0 || strcmp(fn, "default") == 0 || + strcmp(fn, "coalesce") == 0 || strcmp(fn, "ternary") == 0) { + for (int i = 0; i < n; i++) { + if (!a[i].own && (a[i].k == HV_UNK || hv_is_map(&a[i]) || hv_is_list(&a[i]))) { + return false; + } + } + return true; + } + return false; +} + +/* A function call `fn` with its evaluated arguments (the piped value last), + * its result marked own when it is. */ static hv_t he_apply(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) { + if (he_own_fn(fn, strlen(fn))) { + 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); + return r; +} + +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) { + 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; + } + } + return HV_UNKNOWN; + } + if (hv_poisoned(st, &a[i])) { return HV_UNKNOWN; } } @@ -2348,16 +2944,25 @@ static hv_t he_apply(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) { if (!hv_to_int(&a[0], &w) || w < 0 || !hv_strval(st, &a[1], &s)) { return HV_UNKNOWN; } + /* every line gets w spaces: a width whose output cannot fit stops here */ + size_t lines = SKIP_ONE; + for (size_t i = 0; i < s.sn; i++) { + lines += s.s[i] == '\n'; + } + if ((uint64_t)w > HELM_MAX_OUTPUT / lines) { + st->stop = true; + return HV_UNKNOWN; + } hsink_t out = {0}; if (fn[0] == 'n') { hs_append(st, &out, "\n", SKIP_ONE); } - for (int64_t i = 0; i < w; i++) { + for (int64_t i = 0; i < w && !out.unk; i++) { hs_append(st, &out, " ", SKIP_ONE); } - for (size_t i = 0; i < s.sn; i++) { + for (size_t i = 0; i < s.sn && !out.unk; i++) { hs_append(st, &out, s.s + i, SKIP_ONE); - for (int64_t k = 0; s.s[i] == '\n' && k < w; k++) { + for (int64_t k = 0; s.s[i] == '\n' && k < w && !out.unk; k++) { hs_append(st, &out, " ", SKIP_ONE); } } @@ -2423,6 +3028,30 @@ static hv_t he_call(hst_t *st, hframe_t *fr, TSNode call, const hv_t *piped) { fn[fl] = '\0'; TSNode args = hn_is(call, "identifier") ? (TSNode){0} : hn_field(call, "arguments"); uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); + 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 + * 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; + /* what goes into it: own too, or the map now holds another's */ + bool put_own = true; + for (uint32_t i = SKIP_ONE; i < na + (piped && na ? SKIP_ONE : 0); i++) { + 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))) { + st->tainted = true; + } + /* the changed map is what set / merge return */ + return target.own && put_own ? HV_UNKNOWN_OWN : HV_UNKNOWN; + } bool lazy = strcmp(fn, "and") == 0 || strcmp(fn, "or") == 0; if (lazy) { hv_t v = HV_UNKNOWN; @@ -2485,7 +3114,7 @@ static hv_t he_literal_int(hframe_t *fr, TSNode n) { } static hv_t he_eval(hst_t *st, hframe_t *fr, TSNode n) { - if (ts_node_is_null(n) || ts_node_has_error(n) || ts_node_is_missing(n)) { + if (ts_node_is_null(n) || ts_node_has_error(n) || ts_node_is_missing(n) || !hst_step(st)) { return HV_UNKNOWN; } const char *t = ts_node_type(n); @@ -2593,7 +3222,7 @@ static void he_item(hst_t *st, hframe_t *fr, TSNode c); /* The named children of `parent` in field `field` (NULL: every one). */ static void he_items(hst_t *st, hframe_t *fr, TSNode parent, const char *field) { uint32_t cc = ts_node_child_count(parent); - for (uint32_t i = 0; i < cc && !st->brk && !st->cont; i++) { + for (uint32_t i = 0; i < cc && !st->brk && !st->cont && hst_step(st); i++) { TSNode c = ts_node_child(parent, i); if (!ts_node_is_named(c)) { continue; @@ -2606,6 +3235,140 @@ 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`)? */ +static bool he_assign_owns(hst_t *st, hframe_t *fr, const hfresh_t *f, TSNode var, TSNode value) { + const char *src = fr->src; + if (he_deep_value(f, src, value, 0)) { + return true; + } + while (hn_is(value, "parenthesized_pipeline") && ts_node_named_child_count(value) == SKIP_ONE) { + value = ts_node_named_child(value, 0); + } + TSNode call = value; + TSNode piped = {0}; + if (hn_is(value, "chained_pipeline")) { + uint32_t c = ts_node_named_child_count(value); + if (c < PAIR_LEN) { + return false; + } + call = ts_node_named_child(value, c - SKIP_ONE); + piped = ts_node_named_child(value, c - PAIR_LEN); + } + size_t fl = 0; + const char *fs = hn_is(call, "function_call") ? he_fn_text(src, call, &fl) : NULL; + TSNode args = fs && he_mutator(fs, fl) ? hn_field(call, "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_part_own(st, fr, f, ts_node_named_child(args, i))) { + return false; + } + } + return ts_node_is_null(piped) || he_part_own(st, fr, f, piped); +} + +/* 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) { + TSTreeCursor cur = ts_tree_cursor_new(n); + hfresh_t fresh = {0}; /* variables n makes new */ + bool leave = false; + 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); + } 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")); + v->v = keeps ? HV_UNKNOWN_OWN : HV_UNKNOWN; + } + } 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"); + for (TSNode p = c; !own && !ts_node_eq(p, n) && !ts_node_is_null(p);) { + p = ts_node_parent(p); + own = hn_is(p, "range_action"); + } + leave = leave || !own; + } + if (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 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; + bool mutates = false; + 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 (!ts_node_is_named(c) || hn_is(c, "range_variable_definition") || + (f && (strcmp(f, "condition") == 0 || strcmp(f, "range") == 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 (mutates) { + st->tainted = true; + } + if (leave) { + st->brk = true; + } +} + /* if / else if / else, and with / else with: the first branch whose * condition holds runs; an UNKNOWN condition runs none. */ static void he_branches(hst_t *st, hframe_t *fr, TSNode n, bool with) { @@ -2630,10 +3393,10 @@ static void he_branches(hst_t *st, hframe_t *fr, TSNode n, bool with) { continue; } hv_t v = he_eval(st, fr, c); - if (v.k == HV_UNK) { + if (v.k == HV_UNK || hv_poisoned(st, &v)) { stop = true; active = false; - hst_out_unknown(st, fr); /* which branch renders is unknown */ + he_skipped(st, fr, n); /* which branch renders is unknown */ continue; } active = hv_truth(&v); @@ -2670,18 +3433,18 @@ static void he_range(hst_t *st, hframe_t *fr, TSNode n) { ivar = (TSNode){0}; } hv_t coll = he_eval(st, fr, expr); - if (coll.k == HV_UNK) { - hst_out_unknown(st, fr); + if (coll.k == HV_UNK || hv_poisoned(st, &coll)) { + he_skipped(st, fr, n); return; } hv_t *items = NULL; const char **keys = NULL; - int count = 0; + int64_t count = 0; bool is_map = hv_is_map(&coll); if (is_map) { count = hv_keys(st, &coll, &keys); } else if (coll.k == HV_INT) { - count = coll.i > 0 ? (int)coll.i : 0; + count = coll.i > 0 ? coll.i : 0; } else { count = hv_items(st, &coll, &items); } @@ -2693,7 +3456,7 @@ static void he_range(hst_t *st, hframe_t *fr, TSNode n) { size_t el = 0; const char *iname = ts_node_is_null(ivar) ? NULL : hn_var_name(fr, ivar, &il); const char *ename = ts_node_is_null(evar) ? NULL : hn_var_name(fr, evar, &el); - for (int k = 0; k < count; k++) { + for (int64_t k = 0; k < count && hst_step(st); k++) { int mark = st->nvars; hv_t elem; hv_t index; @@ -2742,8 +3505,13 @@ static void he_template(hst_t *st, hframe_t *fr, TSNode n) { } name[k] = '\0'; hv_t a = ts_node_is_null(arg) ? hv_nil() : he_eval(st, fr, arg); - if (a.k == HV_UNK) { + 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; + } return; } if (fr->out) { @@ -2837,17 +3605,16 @@ static hv_t hv_cstr(const char *s) { return s ? hv_str(s, strlen(s)) : hv_nil(); } -void cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, +bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, yyjson_mut_val *values, const cbm_helm_chart_meta_t *chart, const cbm_helm_defines_t *defs, cbm_helm_probe_t *probes, int nprobes) { - (void)rel; for (int i = 0; i < nprobes; i++) { probes[i].n = 0; probes[i].image = NULL; probes[i].keys = NULL; } if (!src || nprobes == 0) { - return; + return true; } hst_t st = {.a = a, .defs = defs, @@ -2866,7 +3633,7 @@ void cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, if (st.scratch) { yyjson_mut_doc_free(st.scratch); } - return; + return true; } int nl = SKIP_ONE; for (size_t i = 0; i < len; i++) { @@ -2909,4 +3676,20 @@ void cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, } ts_parser_delete(st.parser); yyjson_mut_doc_free(st.scratch); + if (st.mutates) { + cbm_ht_free(st.mutates); + } + if (st.stop) { + /* Helm would not finish this template: none of its images is known, + * also those captured before the budget ran out */ + for (int i = 0; i < nprobes; i++) { + for (int c = 0; c < probes[i].n; c++) { + probes[i].image[c] = NULL; + probes[i].keys[c] = NULL; + } + } + cbm_log_warn("helm.render.budget", "file", rel ? rel : "", "reason", + st.steps > HELM_MAX_STEPS ? "steps" : "output"); + } + return !st.stop; } diff --git a/src/pipeline/helm_template.h b/src/pipeline/helm_template.h index ad24a6f78..47bdb007b 100644 --- a/src/pipeline/helm_template.h +++ b/src/pipeline/helm_template.h @@ -75,8 +75,10 @@ typedef struct { /* Render template `rel` (text src, len) with `values` (its .Values; NULL: * an empty map), `chart` and the family's defines, filling `probes` (strings * in arena a). Values paths are dot-joined keys, a key's own `.` escaped - * (`\.`), as --set writes them. */ -void cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, + * (`\.`), as --set writes them. False when the render ran past its step or + * output budget -- a template Helm would not finish either (logged): then + * every capture is UNKNOWN. */ +bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, yyjson_mut_val *values, const cbm_helm_chart_meta_t *chart, const cbm_helm_defines_t *defs, cbm_helm_probe_t *probes, int nprobes); diff --git a/src/pipeline/pass_ci.c b/src/pipeline/pass_ci.c index a44a72c9c..486b1a5b2 100644 --- a/src/pipeline/pass_ci.c +++ b/src/pipeline/pass_ci.c @@ -1086,7 +1086,8 @@ static int ci_compose_env(const ci_t *c, TSNode svc, const ci_env_t *dotenv, con /* A compose build's `args` (a mapping, or a list of "K=V" items), their * values interpolated, as a JSON object; NULL: none. An argument without a - * value (the environment's) or with an unknown one is left out. */ + * value (the environment's), with an unknown one or with a secret (by name or + * value -- never kept in the graph, never an image) is left out. */ static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_env_t *env) { TSNode args = ts_node_is_null(build) ? build : ci_get(c, build, "args"); if (ts_node_is_null(args)) { @@ -1109,13 +1110,19 @@ static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_e raw = ts_node_is_null(pairs[i].value) ? NULL : ci_scalar(c, pairs[i].value, NULL); } const char *value = raw ? ci_interp(c->a, raw, env) : NULL; - if (!key || !key[0] || !value) { + if (!key || !key[0] || !value || cbm_is_secret_binding(key, value)) { continue; } - char ek[CBM_SZ_256]; - char ev[CBM_SZ_1K]; - cbm_json_escape(ek, (int)sizeof(ek), key); - cbm_json_escape(ev, (int)sizeof(ev), value); + /* JSON escaping writes at most 6 bytes a byte: nothing is cut */ + int kn = (int)(strlen(key) * 6 + SKIP_ONE); + int vn = (int)(strlen(value) * 6 + SKIP_ONE); + char *ek = cbm_arena_alloc(c->a, (size_t)kn); + char *ev = cbm_arena_alloc(c->a, (size_t)vn); + if (!ek || !ev) { + return NULL; + } + cbm_json_escape(ek, kn, key); + cbm_json_escape(ev, vn, value); json = cbm_arena_sprintf(c->a, "%s%s\"%s\":\"%s\"", json ? json : "{", json ? "," : "", ek, ev); } diff --git a/src/pipeline/pass_k8s.c b/src/pipeline/pass_k8s.c index 60433514c..9a945c1d5 100644 --- a/src/pipeline/pass_k8s.c +++ b/src/pipeline/pass_k8s.c @@ -3939,22 +3939,45 @@ static void k8s_helm_collect(cbm_pipeline_ctx_t *ctx, const k8s_facts_t *f, k8s_ } } +/* Repository file `rel`, read only when discovery took it in: a values file, + * a Chart.yaml, a kustomize valuesFile named by path is never a symlink or + * under one -- nothing is read from outside the repository. */ static char *k8s_repo_read(cbm_pipeline_ctx_t *ctx, const char *rel, int *len) { + if (!cbm_pipeline_file_node(ctx->gbuf, ctx->project_name, rel)) { + return NULL; + } char path[CBM_SZ_4K]; - snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel); - return k8s_read_file(path, len); + 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; } -/* A file of `dir` (one of `names`), parsed as values; *found when one exists. */ +/* Is repository path `rel` there at all -- a file, a symlink, anything (a + * link is reported, never followed)? */ +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; +} + +/* A file of `dir` (one of `names`), parsed as values; *found when one exists. + * *unread when one is there but is not read (a symlink, too large): what it + * holds is unknown, which is not the same as no file. */ static yyjson_mut_val *k8s_helm_yaml(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const char *dir, - const char *const *names, bool *found) { + const char *const *names, bool *found, bool *unread) { *found = false; + *unread = false; for (int i = 0; names[i]; i++) { char rel[CBM_SZ_1K]; snprintf(rel, sizeof(rel), "%s%s%s", dir, dir[0] ? "/" : "", names[i]); int len = 0; char *src = k8s_repo_read(ctx, rel, &len); if (!src) { + if (k8s_repo_has(ctx, rel)) { + *found = true; + *unread = true; + return NULL; + } continue; } *found = true; @@ -3986,9 +4009,13 @@ static k8s_hchart_t *k8s_helm_chart(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_ static const char *const CHART[] = {"Chart.yaml", "Chart.yml", NULL}; static const char *const VALUES[] = {"values.yaml", "values.yml", NULL}; bool found = false; - c->chart_yaml = k8s_helm_yaml(ctx, h, dir, CHART, &found); - yyjson_mut_val *vals = k8s_helm_yaml(ctx, h, dir, VALUES, &found); - c->values = found ? vals : yyjson_mut_obj(h->doc); + bool chart_unread = false; + bool values_unread = false; + c->chart_yaml = k8s_helm_yaml(ctx, h, dir, CHART, &found, &chart_unread); + yyjson_mut_val *vals = k8s_helm_yaml(ctx, h, dir, VALUES, &found, &values_unread); + /* a Chart.yaml or values file there but not read: the chart's values are + * unknown (it renders nothing), never empty */ + c->values = chart_unread || values_unread ? NULL : found ? vals : yyjson_mut_obj(h->doc); const cbm_gbuf_node_t *cn = c->chart > 0 ? cbm_gbuf_find_by_id(ctx->gbuf, c->chart) : NULL; const char *name = k8s_helm_meta_str(c->chart_yaml, "name"); c->meta.name = name ? name : cn && cn->name ? cbm_arena_strdup(&h->a, cn->name) : NULL; @@ -4139,12 +4166,12 @@ static yyjson_mut_val *k8s_helm_dig(yyjson_mut_val *v, const char *path) { } /* Is values key path `key` (dot-joined, `\.` escaped) set in `v`? */ -static bool k8s_helm_sets(yyjson_mut_val *v, const char *key) { - char part[CBM_SZ_512]; +static bool k8s_helm_sets(k8s_helm_t *h, yyjson_mut_val *v, const char *key) { + char *part = cbm_arena_alloc(&h->a, strlen(key) + SKIP_ONE); /* a key is never cut */ const char *p = key; - while (v && *p) { + while (v && part && *p) { size_t k = 0; - while (*p && *p != '.' && *p != '[' && k + SKIP_ONE < sizeof(part)) { + while (*p && *p != '.' && *p != '[') { if (*p == '\\' && p[1]) { p++; } @@ -4180,13 +4207,19 @@ static const char *k8s_helm_overridden(k8s_helm_t *h, const char *keys, yyjson_m yyjson_arr_foreach(arr, idx, max, k) { const char *key = yyjson_get_str(k); bool global = key && strncmp(key, "global.", strlen("global.")) == 0; - bool set = key && (global ? k8s_helm_sets(globals, key + strlen("global.")) - : k8s_helm_sets(overlay, key)); + bool set = key && (global ? k8s_helm_sets(h, globals, key + strlen("global.")) + : k8s_helm_sets(h, overlay, key)); if (!set) { continue; } - char esc[CBM_SZ_1K]; - cbm_json_escape(esc, (int)sizeof(esc), key); + /* JSON escaping writes at most 6 bytes a byte */ + int en = (int)(strlen(key) * 6 + SKIP_ONE); + char *esc = cbm_arena_alloc(&h->a, (size_t)en); + if (!esc) { + out = NULL; + break; + } + cbm_json_escape(esc, en, key); out = cbm_arena_sprintf(&h->a, "%s%s\"%s%s%s\"", out ? out : "[", out ? "," : "", global || !prefix ? "" : prefix, global || !prefix ? "" : ".", esc); @@ -4245,14 +4278,18 @@ static void k8s_helm_take(cbm_pipeline_ctx_t *ctx, const cbm_helm_probe_t *p, in /* The images of templated containers [from, to) of one template, rendered * with `values` (NULL: the chart's own). */ static void k8s_helm_render(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_t *b, - const k8s_hchart_t *c, const k8s_timg_t *t, int from, int to, - yyjson_mut_val *values, const char *src, size_t len, + const k8s_hchart_t *c, const char *as_name, const k8s_timg_t *t, + int from, int to, yyjson_mut_val *values, const char *src, size_t len, cbm_helm_probe_t *probes, int np) { char fam[CBM_SZ_1K]; k8s_chart_family(b, c->dir, fam, sizeof(fam)); const cbm_helm_defines_t *defs = k8s_helm_defines(ctx, b, h, fam); - cbm_helm_render(&h->a, t[from].file, src, len, values ? values : c->values, &c->meta, defs, - probes, np); + /* a subchart a dependency aliases is .Chart.Name = its alias (Helm's + * getAliasDependency) */ + cbm_helm_chart_meta_t meta = c->meta; + meta.name = as_name ? as_name : meta.name; + cbm_helm_render(&h->a, t[from].file, src, len, values ? values : c->values, &meta, defs, probes, + np); (void)to; } @@ -4381,11 +4418,13 @@ static bool k8s_helm_disabled(k8s_helm_t *h, yyjson_mut_val *dep, yyjson_mut_val * with values `top_vals` (NULL: top's defaults): down the charts between * them, each subchart's defaults with its parent's `:` values and * `global:` coalesced over them (Helm's coalesce; a subchart always has a - * `global`). *path gets dir's key path from top ("" for top itself). NULL: - * a dependency condition disables it, or a values file does not parse. */ + * `global`). *path gets dir's key path from top ("" for top itself), *as_name + * the name dir's chart is included under (its alias; NULL for top). NULL: a + * dependency condition disables it, or a values file does not parse. */ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_t *b, k8s_dep_link_t *l, const char *dir, const char *top, - yyjson_mut_val *top_vals, const char **path) { + yyjson_mut_val *top_vals, const char **path, + const char **as_name) { const char **anc = NULL; int na = k8s_helm_ancestors(h, b, dir, &anc); int ti = -SKIP_ONE; /* top's index among the ancestors; na: dir itself */ @@ -4397,6 +4436,7 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, } } *path = ""; + *as_name = NULL; if (ti < 0) { return NULL; } @@ -4422,6 +4462,7 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, } *path = (*path)[0] ? cbm_arena_sprintf(&h->a, "%s.%s", *path, alias) : cbm_arena_strdup(&h->a, alias); + *as_name = alias; yyjson_mut_val *sub = yyjson_mut_is_obj(cur) ? yyjson_mut_obj_get(cur, alias) : NULL; yyjson_mut_val *next = cbm_helm_values_coalesce(h->doc, child->values, sub); yyjson_mut_val *pg = yyjson_mut_is_obj(cur) ? yyjson_mut_obj_get(cur, "global") : NULL; @@ -4441,10 +4482,10 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, /* Render the templated containers [from, to) of one template with `vals` * and take each container's images. */ static void k8s_helm_images_of(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_t *b, - const k8s_hchart_t *c, int from, int to, yyjson_mut_val *vals, - const char *src, size_t len, cbm_helm_probe_t *probes, int np, - k8s_himgs_t *out) { - k8s_helm_render(ctx, h, b, c, h->t, from, to, vals, src, len, probes, np); + const k8s_hchart_t *c, const char *as_name, int from, int to, + yyjson_mut_val *vals, const char *src, size_t len, + cbm_helm_probe_t *probes, int np, k8s_himgs_t *out) { + k8s_helm_render(ctx, h, b, c, as_name, h->t, from, to, vals, src, len, probes, np); for (int i = from; i < to; i++) { k8s_helm_take(ctx, probes, np, h->t[i].start_line, h->t[i].end_line, h->t[i].node, h->t[i].container, out); @@ -4471,17 +4512,19 @@ static void k8s_helm_link_defaults(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 ? k8s_helm_chart(ctx, h, &l, dir) : NULL; const char *path = NULL; + const char *as_name = NULL; yyjson_mut_val *vals = NULL; if (c) { k8s_chart_family(b, c->dir, fam, sizeof(fam)); - vals = k8s_helm_context(ctx, h, b, &l, c->dir, fam, NULL, &path); + vals = k8s_helm_context(ctx, h, b, &l, c->dir, fam, NULL, &path, &as_name); } int len = 0; char *src = vals ? k8s_repo_read(ctx, h->t[from].file, &len) : NULL; cbm_helm_probe_t *probes = NULL; int np = src ? k8s_helm_probes(h, h->t, from, to, src, (size_t)len, &probes) : 0; if (np > 0) { - k8s_helm_images_of(ctx, h, b, c, from, to, vals, src, (size_t)len, probes, np, &uses); + k8s_helm_images_of(ctx, h, b, c, as_name, from, to, vals, src, (size_t)len, probes, np, + &uses); } cbm_free(CBM_MEM_CLASS_OTHER, src); from = to; @@ -4535,14 +4578,17 @@ static void k8s_helm_release_local(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 (strcmp(dir, top) == 0 || (tl == 0 || (strncmp(dir, top, tl) == 0 && dir[tl] == '/'))); k8s_hchart_t *c = in ? k8s_helm_chart(ctx, h, l, dir) : NULL; const char *path = NULL; - yyjson_mut_val *vals = c ? k8s_helm_context(ctx, h, b, l, dir, top, rv, &path) : NULL; + const char *as_name = NULL; + yyjson_mut_val *vals = + c ? k8s_helm_context(ctx, h, b, l, dir, top, rv, &path, &as_name) : NULL; int len = 0; char *srcs = vals ? k8s_repo_read(ctx, h->t[from].file, &len) : NULL; cbm_helm_probe_t *probes = NULL; int np = srcs ? k8s_helm_probes(h, h->t, from, to, srcs, (size_t)len, &probes) : 0; if (np > 0) { k8s_himgs_t got = {0}; - k8s_helm_images_of(ctx, h, b, c, from, to, vals, srcs, (size_t)len, probes, np, &got); + k8s_helm_images_of(ctx, h, b, c, as_name, from, to, vals, srcs, (size_t)len, probes, np, + &got); yyjson_mut_val *overlay = path && path[0] ? k8s_helm_dig(rv, path) : rv; yyjson_mut_val *globals = yyjson_mut_obj_get(rv, "global"); k8s_helm_emit_overrides(ctx, h, src, via, &h->dflt, &got, overlay, globals, @@ -4700,8 +4746,40 @@ static bool k8s_json_str(const char *props, const char *key, char *out, size_t s return ok; } +/* Does field `key` of `obj` give a value (a non-null scalar, a non-empty list + * or map)? */ +static bool k8s_helm_gives(yyjson_mut_val *obj, const char *key) { + yyjson_mut_val *v = yyjson_mut_is_obj(obj) ? yyjson_mut_obj_get(obj, key) : NULL; + if (!v || yyjson_mut_is_null(v)) { + return false; + } + if (yyjson_mut_is_arr(v)) { + return yyjson_mut_arr_size(v) > 0; + } + if (yyjson_mut_is_obj(v)) { + return yyjson_mut_obj_size(v) > 0; + } + return !yyjson_mut_is_str(v) || yyjson_mut_get_len(v) > 0; +} + +/* A HelmRelease's values: spec.values; NULL when there are none -- or when a + * source not read also gives some (valuesFrom, the chart's valuesFiles, a + * postRenderer that may patch the images): its images are then unknown. */ +static yyjson_mut_val *k8s_helm_flux_values(yyjson_mut_val *docv) { + yyjson_mut_val *spec = yyjson_mut_is_obj(docv) ? yyjson_mut_obj_get(docv, "spec") : NULL; + yyjson_mut_val *chart = yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "chart") : NULL; + yyjson_mut_val *cspec = yyjson_mut_is_obj(chart) ? yyjson_mut_obj_get(chart, "spec") : NULL; + if (k8s_helm_gives(spec, "valuesFrom") || k8s_helm_gives(spec, "postRenderers") || + k8s_helm_gives(cspec, "valuesFiles") || k8s_helm_gives(cspec, "valuesFile")) { + return NULL; + } + return yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "values") : NULL; +} + /* A kustomize helmCharts entry's values: valuesFile, then valuesInline over - * it; NULL when neither. */ + * it; NULL when neither -- or when a part is not read (additionalValuesFiles, + * a valuesMerge other than the default override, a valuesFile that cannot be + * read): its images are then unknown. */ static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const cbm_gbuf_node_t *kust, const char *props) { char chart[CBM_SZ_512] = ""; @@ -4723,6 +4801,11 @@ static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_he const char *rel = k8s_helm_meta_str(c, "releaseName"); if (name && strcmp(name, chart) == 0 && (!release[0] || (rel && strcmp(rel, release) == 0))) { + const char *merge = k8s_helm_meta_str(c, "valuesMerge"); + if (k8s_helm_gives(c, "additionalValuesFiles") || + (merge && strcmp(merge, "override") != 0)) { + return NULL; + } inline_vals = yyjson_mut_obj_get(c, "valuesInline"); break; } @@ -4733,6 +4816,9 @@ static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_he char *fs = k8s_repo_read(ctx, vfile, &fl); file_vals = fs ? cbm_helm_values_parse(h->doc, fs, (size_t)fl) : NULL; cbm_free(CBM_MEM_CLASS_OTHER, fs); + if (!file_vals) { + return NULL; /* a valuesFile not read: the release's values would be partial */ + } } if (!inline_vals && !file_vals) { return NULL; @@ -4772,10 +4858,7 @@ static void k8s_helm_link_releases(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 bool hr = strstr(rels[i].props, "\"via\":\"helmrelease\"") != NULL; yyjson_mut_val *rv = NULL; if (hr) { - yyjson_mut_val *docv = k8s_helm_node_yaml(ctx, h, src); - yyjson_mut_val *spec = - yyjson_mut_is_obj(docv) ? yyjson_mut_obj_get(docv, "spec") : NULL; - rv = yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "values") : NULL; + rv = k8s_helm_flux_values(k8s_helm_node_yaml(ctx, h, src)); } else { rv = k8s_helm_kustomize_values(ctx, h, src, rels[i].props); } @@ -4840,14 +4923,13 @@ typedef struct { int64_t chart; int64_t img; bool literal; + bool lost; /* a mark that could not be carried whole: no edge, never an unmarked one */ char *keys; /* JSON array (cbm_alloc); NULL: none yet */ } k8s_cimg_t; -/* JSON array `a` (owned, may be NULL) with the strings of JSON array `b` - * appended once; the result is owned. */ -static char *k8s_keys_union(char *a, const char *b) { - yyjson_doc *db = b ? yyjson_read(b, strlen(b), 0) : NULL; - yyjson_val *bv = db ? yyjson_doc_get_root(db) : NULL; +/* JSON array `a` (owned, may be NULL) with the strings of JSON array value + * `bv` appended once; the result is owned. *lost when one could not be. */ +static char *k8s_keys_union(char *a, yyjson_val *bv, bool *lost) { size_t idx, max; yyjson_val *k; yyjson_arr_foreach(bv, idx, max, k) { @@ -4855,29 +4937,38 @@ static char *k8s_keys_union(char *a, const char *b) { if (!s) { continue; } - char esc[CBM_SZ_1K]; - char quoted[CBM_SZ_1K + PAIR_LEN + PAIR_LEN]; - cbm_json_escape(esc, (int)sizeof(esc), s); - snprintf(quoted, sizeof(quoted), "\"%s\"", esc); + /* JSON escaping writes at most 6 bytes (\u00XX) a byte */ + size_t qn = strlen(s) * 6 + PAIR_LEN + SKIP_ONE; + char *quoted = cbm_alloc(CBM_MEM_CLASS_OTHER, qn); + if (!quoted) { + *lost = true; + break; + } + quoted[0] = '"'; + cbm_json_escape(quoted + SKIP_ONE, (int)(qn - PAIR_LEN), s); + size_t ql = strlen(quoted); + quoted[ql++] = '"'; + quoted[ql] = '\0'; if (a && strstr(a, quoted)) { + cbm_free(CBM_MEM_CLASS_OTHER, quoted); continue; } size_t al = a ? strlen(a) : 0; - char *g = cbm_alloc(CBM_MEM_CLASS_OTHER, al + strlen(quoted) + 3); + char *g = cbm_alloc(CBM_MEM_CLASS_OTHER, al + ql + 3); if (!g) { + cbm_free(CBM_MEM_CLASS_OTHER, quoted); + *lost = true; break; } if (a) { - snprintf(g, al + strlen(quoted) + 3, "%.*s,%s]", (int)(al - SKIP_ONE), a, quoted); + snprintf(g, al + ql + 3, "%.*s,%s]", (int)(al - SKIP_ONE), a, quoted); } else { - snprintf(g, strlen(quoted) + 3, "[%s]", quoted); + snprintf(g, ql + 3, "[%s]", quoted); } + cbm_free(CBM_MEM_CLASS_OTHER, quoted); cbm_free(CBM_MEM_CLASS_OTHER, a); a = g; } - if (db) { - yyjson_doc_free(db); - } return a; } @@ -4923,30 +5014,41 @@ static void k8s_link_chart_images(cbm_pipeline_ctx_t *ctx) { if (!k8s_grow((void **)&v, &cap, n, sizeof(*v))) { break; } - v[n] = (k8s_cimg_t){charts[c]->id, img->id, false, NULL}; + v[n] = (k8s_cimg_t){charts[c]->id, img->id, false, false, NULL}; at = n++; } - const char *dflt = strstr(up, "\"default_of\":"); - if (!dflt) { + /* the USAGE's default_of, read as JSON (a key path may hold `]`) */ + yyjson_doc *ud = yyjson_read(up, strlen(up), 0); + yyjson_val *dv = ud ? yyjson_obj_get(yyjson_doc_get_root(ud), "default_of") : NULL; + if (!ud) { + v[at].lost = true; + } else if (!dv) { v[at].literal = true; + } else if (yyjson_is_arr(dv)) { + v[at].keys = k8s_keys_union(v[at].keys, dv, &v[at].lost); } else { - char keys[CBM_SZ_4K]; - const char *arr = dflt + strlen("\"default_of\":"); - const char *end = strchr(arr, ']'); - if (end && (size_t)(end - arr) + PAIR_LEN < sizeof(keys)) { - snprintf(keys, sizeof(keys), "%.*s", (int)(end - arr + SKIP_ONE), arr); - v[at].keys = k8s_keys_union(v[at].keys, keys); - } + v[at].lost = true; + } + if (ud) { + yyjson_doc_free(ud); } } } } for (int i = 0; i < n; i++) { - char props[CBM_SZ_4K]; bool marked = !v[i].literal && v[i].keys; - snprintf(props, sizeof(props), "{\"via\":\"chart\",\"ref\":\"image\"%s%s}", - marked ? ",\"default_of\":" : "", marked ? v[i].keys : ""); - cbm_gbuf_insert_edge(ctx->gbuf, v[i].chart, v[i].img, "DEPLOYS", props); + if (!v[i].literal && v[i].lost) { + cbm_free(CBM_MEM_CLASS_OTHER, v[i].keys); + continue; /* the mark is not whole: no edge rather than an unmarked one */ + } + size_t pn = (marked ? strlen(v[i].keys) : 0) + CBM_SZ_64; + char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn); + if (props) { + snprintf(props, pn, "{\"via\":\"chart\",\"ref\":\"image\"%s%s}", + marked ? ",\"default_of\":" : "", marked ? v[i].keys : ""); + cbm_gbuf_insert_edge(ctx->gbuf, v[i].chart, v[i].img, "DEPLOYS", props); + cbm_free(CBM_MEM_CLASS_OTHER, props); + } cbm_free(CBM_MEM_CLASS_OTHER, v[i].keys); } cbm_free(CBM_MEM_CLASS_OTHER, v); diff --git a/src/pipeline/pass_shell.c b/src/pipeline/pass_shell.c index e3a1e768a..334322d15 100644 --- a/src/pipeline/pass_shell.c +++ b/src/pipeline/pass_shell.c @@ -2489,7 +2489,8 @@ static const char *sh_image_repo(sh_t *s, TSNode cmd, const sh_argv_t *a, int i) } /* JSON object `json` (NULL: none) with build argument `kv` ("K=V") added; a - * `K` alone (the environment's value) or an unknown word adds nothing. */ + * `K` alone (the environment's value), an unknown word or a secret (by name + * or value -- never kept in the graph, never an image) adds nothing. */ static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) { const char *eq = kv ? strchr(kv, '=') : NULL; if (!eq || eq == kv) { @@ -2500,8 +2501,17 @@ static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) { return json; } } - char ev[CBM_SZ_1K]; - cbm_json_escape(ev, (int)sizeof(ev), eq + SKIP_ONE); + const char *key = cbm_arena_strndup(s->a, kv, (size_t)(eq - kv)); + if (!key || cbm_is_secret_binding(key, eq + SKIP_ONE)) { + return json; + } + /* JSON escaping writes at most 6 bytes a byte: nothing is cut */ + int vn = (int)(strlen(eq + SKIP_ONE) * 6 + SKIP_ONE); + char *ev = cbm_arena_alloc(s->a, (size_t)vn); + if (!ev) { + return json; + } + cbm_json_escape(ev, vn, eq + SKIP_ONE); const char *pair = cbm_arena_sprintf(s->a, "\"%.*s\":\"%s\"", (int)(eq - kv), kv, ev); if (!pair) { return json; @@ -3763,17 +3773,25 @@ static int sh_link_build(cbm_pipeline_ctx_t *ctx, const sh_hit_t *x, CBMHashTabl char esc_file[CBM_SZ_1K]; char esc_img[CBM_SZ_1K]; char ci[CBM_SZ_1K]; - char props[CBM_SZ_4K]; cbm_json_escape(esc_file, (int)sizeof(esc_file), x->file); cbm_json_escape(esc_img, (int)sizeof(esc_img), x->ext); sh_ci_props(x, ci, sizeof(ci)); - snprintf(props, sizeof(props), + /* sized to what it holds: default_of and build_args are never cut */ + size_t pn = strlen(esc_file) + strlen(esc_img) + strlen(ci) + + (x->default_of ? strlen(x->default_of) : 0) + + (x->build_args ? strlen(x->build_args) : 0) + CBM_SZ_256; + char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn); + if (!props) { + return 0; + } + snprintf(props, pn, "{\"via\":\"build\",\"tool\":\"docker-build\",\"file\":\"%s\",\"image\":\"%s\"%s," "\"line\":%d%s%s%s%s}", esc_file, esc_img, ci, x->line, x->default_of ? ",\"default_of\":" : "", x->default_of ? x->default_of : "", x->build_args ? ",\"build_args\":" : "", x->build_args ? x->build_args : ""); cbm_gbuf_insert_edge(ctx->gbuf, img, mod, "DEPENDS_ON", props); + cbm_free(CBM_MEM_CLASS_OTHER, props); return SKIP_ONE; } diff --git a/src/pipeline/pass_terraform.c b/src/pipeline/pass_terraform.c index 443bc4961..74ab37a2c 100644 --- a/src/pipeline/pass_terraform.c +++ b/src/pipeline/pass_terraform.c @@ -31,6 +31,7 @@ #include "helpers.h" #include "lang_specs.h" +#include #include #include #include @@ -330,9 +331,96 @@ static char *tf_heredoc(const tf_t *t, TSNode n) { return out; } +/* HCL number literal s (digits, a fraction, an exponent) as Terraform turns a + * number into a string: exact decimal, no exponent, no trailing zeros (cty's + * big.Float Text('f', -1); 1.10 -> "1.1", 2e3 -> "2000"). False when it is + * no such literal or does not fit out. */ +static bool tf_number_text(const char *s, size_t n, char *out, size_t sz) { + enum { TF_MAX_EXP = 4096 }; + char dig[CBM_SZ_256]; + size_t nd = 0; + long exp = 0; + bool frac = false; + size_t i = 0; + for (; i < n && (isdigit((unsigned char)s[i]) || s[i] == '.'); i++) { + if (s[i] == '.') { + if (frac) { + return false; + } + frac = true; + continue; + } + if (nd + SKIP_ONE >= sizeof(dig)) { + return false; + } + dig[nd++] = s[i]; + exp -= frac ? SKIP_ONE : 0; + } + if (i < n) { + if (s[i] != 'e' && s[i] != 'E') { + return false; + } + i++; + bool neg = i < n && s[i] == '-'; + i += i < n && (s[i] == '-' || s[i] == '+'); + long e = 0; + if (i == n) { + return false; + } + for (; i < n; i++) { + if (!isdigit((unsigned char)s[i]) || e > TF_MAX_EXP) { + return false; + } + e = e * 10 + (s[i] - '0'); + } + exp += neg ? -e : e; + } + size_t b = 0; + while (b < nd && dig[b] == '0') { + b++; + } + if (b == nd) { + return snprintf(out, sz, "0") == SKIP_ONE; /* every digit 0 (or none) */ + } + size_t e = nd; + while (dig[e - SKIP_ONE] == '0') { /* stops at dig[b], which is no 0 */ + e--; + exp++; + } + size_t m = e - b; /* significant digits: dig[b, e) x 10^exp */ + size_t o = 0; + long point = (long)m + exp; /* digits before the decimal point */ + size_t need = exp >= 0 ? m + (size_t)exp + : point > 0 ? m + SKIP_ONE + : m + PAIR_LEN + (size_t)-point; + if (need + SKIP_ONE > sz) { + return false; + } + if (point <= 0) { + out[o++] = '0'; + out[o++] = '.'; + for (long z = 0; z < -point; z++) { + out[o++] = '0'; + } + } + for (size_t k = 0; k < m; k++) { + if (point > 0 && (long)k == point) { + out[o++] = '.'; + } + out[o++] = dig[b + k]; + } + for (long z = 0; z < exp; z++) { + out[o++] = '0'; + } + out[o] = '\0'; + return true; +} + /* An HCL literal as JSON: a string without interpolation, a number, a bool, - * null, a tuple or object of those; NULL: anything else. */ -static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { + * null, a tuple or object of those; NULL: anything else. `num_text`: a number + * as the string Terraform makes of it (a helm_release set value is a string), + * else a float64. */ +static yyjson_mut_val *tf_json_v(const tf_t *t, yyjson_mut_doc *doc, TSNode n, bool num_text) { n = tf_unwrap(n); if (tf_type(n, "string_lit") || tf_type(n, "quoted_template")) { char buf[CBM_SZ_4K]; @@ -371,7 +459,11 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { uint32_t b = ts_node_start_byte(n); uint32_t e = ts_node_end_byte(n); if (tf_type(n, "numeric_lit")) { - char buf[CBM_SZ_64]; + char buf[CBM_SZ_256]; + if (num_text) { + return tf_number_text(t->src + b, e - b, buf, sizeof(buf)) ? yyjson_mut_strcpy(doc, buf) + : NULL; + } snprintf(buf, sizeof(buf), "%.*s", (int)(e - b), t->src + b); return yyjson_mut_real(doc, strtod(buf, NULL)); } @@ -388,7 +480,7 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { if (tf_type(c, "tuple_start") || tf_type(c, "tuple_end")) { continue; } - yyjson_mut_val *v = tf_json(t, doc, c); + yyjson_mut_val *v = tf_json_v(t, doc, c, num_text); if (!v) { return NULL; } @@ -411,9 +503,9 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { uint32_t kb = ts_node_start_byte(id); key = yyjson_mut_strncpy(doc, t->src + kb, ts_node_end_byte(id) - kb); } else { - key = tf_json(t, doc, kn); + key = tf_json_v(t, doc, kn, false); } - yyjson_mut_val *val = tf_json(t, doc, vn); + yyjson_mut_val *val = tf_json_v(t, doc, vn, num_text); if (!key || !yyjson_mut_is_str(key) || !val) { return NULL; } @@ -424,38 +516,22 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { return NULL; } -/* A repository file's text (cbm_alloc), NULL when unreadable. */ +static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { + return tf_json_v(t, doc, n, false); +} + +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. */ static char *tf_read_repo(const tf_t *t, const char *path, size_t *len) { - char full[CBM_SZ_4K]; - snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path); - FILE *f = cbm_fopen(full, "rb"); - if (!f) { + if (!cbm_pipeline_file_node(t->ctx->gbuf, t->ctx->project_name, path)) { return NULL; } - char *buf = NULL; - size_t cap = 0; - char chunk[CBM_SZ_4K]; - size_t r; - *len = 0; - while ((r = fread(chunk, SKIP_ONE, sizeof(chunk), f)) > 0) { - if (*len + r + SKIP_ONE > cap) { - cap = (*len + r + SKIP_ONE) * PAIR_LEN; - char *g = cbm_realloc(CBM_MEM_CLASS_OTHER, buf, cap); - if (!g) { - cbm_free(CBM_MEM_CLASS_OTHER, buf); - (void)fclose(f); - return NULL; - } - buf = g; - } - memcpy(buf + *len, chunk, r); - *len += r; - } - (void)fclose(f); - if (buf) { - buf[*len] = '\0'; - } - return buf; + 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; } /* A `values = [...]` item as values: a YAML string / heredoc, file() @@ -512,8 +588,8 @@ static yyjson_mut_val *tf_values_item(const tf_t *t, yyjson_mut_doc *doc, TSNode : NULL; } -/* A `set` entry (name / value / type) applied to root; false when it is no - * literal. */ +/* A `set` entry (name / value / type; read with numbers as text) applied to + * root; false when it is no literal. */ static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *entry) { yyjson_mut_val *nm = yyjson_mut_obj_get(entry, "name"); yyjson_mut_val *val = yyjson_mut_obj_get(entry, "value"); @@ -521,13 +597,9 @@ static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_v if (!yyjson_mut_is_str(nm) || !val) { return false; } - char vbuf[CBM_SZ_64]; const char *v = NULL; if (yyjson_mut_is_str(val)) { v = yyjson_mut_get_str(val); - } else if (yyjson_mut_is_real(val)) { - snprintf(vbuf, sizeof(vbuf), "%.17g", yyjson_mut_get_real(val)); - v = vbuf; } else if (yyjson_mut_is_bool(val)) { v = yyjson_mut_get_bool(val) ? "true" : "false"; } @@ -550,7 +622,7 @@ static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode b } TSNode key = ts_node_named_child(at, 0); uint32_t b = ts_node_start_byte(key); - yyjson_mut_val *v = tf_json(t, doc, ts_node_named_child(at, SKIP_ONE)); + yyjson_mut_val *v = tf_json_v(t, doc, ts_node_named_child(at, SKIP_ONE), true); if (!v) { return NULL; } @@ -561,9 +633,29 @@ 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? */ +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); + if (!tf_type(a, "block") || ts_node_is_null(kw) || ts_node_is_null(label)) { + return false; + } + uint32_t kb = ts_node_start_byte(kw); + if (ts_node_end_byte(kw) - kb != strlen("dynamic") || + strncmp(t->src + kb, "dynamic", strlen("dynamic")) != 0) { + return false; + } + 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); +} + /* 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. set_sensitive is never read. */ + * 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) { yyjson_mut_doc *doc = yyjson_mut_doc_new(cbm_json_writer_allocator()); yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL; @@ -576,8 +668,10 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, size_t kl = ts_node_is_null(key) ? 0 : ts_node_end_byte(key) - kb; 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 values are not read: the release's values are partial */ - if (kl == strlen("set_list") && strncmp(t->src + kb, "set_list", kl) == 0) { + /* set_list and dynamic set values are not read: the release's + * values would be partial */ + if ((kl == strlen("set_list") && strncmp(t->src + kb, "set_list", kl) == 0) || + tf_dynamic_set(t, a)) { ok = false; break; } @@ -598,7 +692,7 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, ok = entry && tf_set_entry(doc, root, entry); } else if (pass == SKIP_ONE && is_set && tf_type(a, "attribute")) { /* provider v3: set = [{ name, value, type }, ...] */ - yyjson_mut_val *list = tf_json(t, doc, ts_node_named_child(a, SKIP_ONE)); + yyjson_mut_val *list = tf_json_v(t, doc, ts_node_named_child(a, SKIP_ONE), true); ok = yyjson_mut_is_arr(list); size_t idx, max; yyjson_mut_val *entry; diff --git a/tests/fixtures/complexity/Kubernetes/chart/Chart.yaml b/tests/fixtures/complexity/Kubernetes/chart/Chart.yaml new file mode 100644 index 000000000..7da879473 --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: cx +version: 0.1.0 +appVersion: "1.0" diff --git a/tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl b/tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl new file mode 100644 index 000000000..f4aff46fe --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl @@ -0,0 +1,4 @@ +{{- define "cx.image" -}} +{{- $reg := .registry -}} +{{- printf "%s/%s:%s" $reg .repository .tag -}} +{{- end -}} diff --git a/tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml b/tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml new file mode 100644 index 000000000..52c14349c --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml @@ -0,0 +1,14 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cx +spec: + template: + spec: + containers: + - name: main + image: "{{ include "cx.image" .Values.image }}" + {{- range .Values.sidecars }} + - name: {{ .name }} + image: "{{ .image.repository }}:{{ .image.tag }}" + {{- end }} diff --git a/tests/fixtures/complexity/Kubernetes/chart/values.yaml b/tests/fixtures/complexity/Kubernetes/chart/values.yaml new file mode 100644 index 000000000..c279c38a7 --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/values.yaml @@ -0,0 +1,12 @@ +common: &common + pullPolicy: IfNotPresent +image: + <<: *common + registry: ghcr.io + repository: acme/cx + tag: "1.0" +sidecars: + - name: a + image: {repository: ghcr.io/acme/cx-side, tag: "2.0"} + - name: b + image: {repository: ghcr.io/acme/cx-side, tag: "2.1"} diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index f7405e959..2227ac308 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -4766,6 +4766,351 @@ TEST(probe_helm_release_images) { PASS(); } +/* The chart every release test below deploys: an image from two values. */ +#define GPF_APP_CHART \ + {"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, \ + {"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, \ + { \ + "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" \ + } + +/* The edges of the chart's defaults (every test below has them). */ +#define GPF_APP_DEFAULTS \ + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " \ + "default_of=[\"image.repository\",\"image.tag\"]\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\"]" + +/* A Terraform number set on a helm_release reaches the chart as the string + * Terraform makes of it -- its exact decimal (1.1, 1.10 -> "1.1", 2e3 -> + * "2000"), never a float's %.17g ("1.1000000000000001"). */ +TEST(probe_helm_release_terraform_number_set) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n" + " value = 1.10\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.app -> Image:ghcr.io/acme/app DEPLOYS " + "image=ghcr.io/acme/app:1.1 via=terraform ref=image override_of=[\"image.tag\"]\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("a Terraform number set is its exact decimal"); + } + PASS(); +} + +/* A Flux HelmRelease whose values also come from a source not read -- + * valuesFrom, the chart's valuesFiles, a postRenderer that may patch the + * images -- renders no image of its own: its values are not known whole. */ +TEST(probe_helm_release_flux_unread_sources) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"flux/app.yaml", + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: files\n" + "spec:\n chart:\n spec:\n chart: ./charts/app\n valuesFiles:\n" + " - charts/app/values-prod.yaml\n sourceRef:\n kind: GitRepository\n" + " name: repo\n values:\n image:\n tag: \"9.9\"\n---\n" + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: from\n" + "spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n" + " kind: GitRepository\n name: repo\n valuesFrom:\n" + " - kind: ConfigMap\n name: app-values\n values:\n image:\n" + " tag: \"8.8\"\n---\n" + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: post\n" + "spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n" + " kind: GitRepository\n name: repo\n postRenderers:\n" + " - kustomize:\n images:\n - name: ghcr.io/acme/app\n" + " newName: quay.io/acme/app\n values:\n image:\n tag: \"7.7\"\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a HelmRelease with a source not read has no image of its own"); + } + PASS(); +} + +/* A kustomize helmCharts entry with additionalValuesFiles, or a valuesMerge + * other than the default override, renders no image of its own. */ +TEST(probe_helm_release_kustomize_unread_sources) { + static const GpfFile files[] = { + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: more\n additionalValuesFiles:\n" + " - extra.yaml\n valuesInline:\n image:\n tag: \"5.5\"\n" + " - name: app\n releaseName: merged\n valuesMerge: merge\n" + " valuesInline:\n image:\n tag: \"6.6\"\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 helmCharts entry with a part not read has no image of its own"); + } + PASS(); +} + +/* A Terraform helm_release with a `dynamic "set"` block (entries made by a + * for_each) renders no image of its own, whatever its literal sets say. */ +TEST(probe_helm_release_terraform_dynamic_set) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n dynamic \"set\" {\n" + " for_each = var.sets\n content {\n name = set.key\n" + " value = set.value\n }\n }\n set {\n name = \"image.tag\"\n" + " value = \"3.3\"\n }\n}\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a helm_release with a dynamic set has no image of its own"); + } + 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) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", + "apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n - name: worker\n" + " version: 0.1.0\n alias: worker-high\n"}, + {"charts/app/values.yaml", "worker-high: {}\n"}, + {"charts/app/charts/worker/Chart.yaml", + "apiVersion: v2\nname: worker\nversion: 0.1.0\nappVersion: \"1.0\"\n"}, + {"charts/app/charts/worker/values.yaml", "{}\n"}, + {"charts/app/charts/worker/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n template:\n" + " spec:\n containers:\n - name: worker\n" + " image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}}; + static const char want[] = + "Chart:worker -> Image:ghcr.io/acme/worker-high DEPLOYS image=- via=chart ref=image\n" + "Resource:Deployment/worker -> Image:ghcr.io/acme/worker-high USAGE " + "image=ghcr.io/acme/worker-high:1.0 ref=image"; + if (!gpf_expect_image_edges(files, 5, want)) { + FAIL("an aliased subchart's .Chart.Name is the alias"); + } + PASS(); +} + +/* An image read from a values list keeps its mark on the chart's edge: + * default_of names the list paths (images[0].repository) whole. */ +TEST(probe_helm_list_path_mark_on_chart_edge) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"charts/app/values.yaml", + "images:\n - repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"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: \"{{ (index .Values.images 0).repository }}:" + "{{ (index .Values.images 0).tag }}\"\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]"; + if (!gpf_expect_image_edges(files, 3, want)) { + FAIL("a list path's mark is kept whole on the chart edge"); + } + PASS(); +} + +/* 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. */ +static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfFile *links, + int nlinks) { + GpfProj lp; + memset(&lp, 0, sizeof(lp)); + snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_gpf_XXXXXX"); + if (!cbm_mkdtemp(lp.tmpdir)) { + return NULL; + } + gpf_to_fwd_slashes(lp.tmpdir); + for (int i = 0; i < nfiles + nlinks; i++) { + const GpfFile *f = i < nfiles ? &files[i] : &links[i - nfiles]; + char path[700]; + snprintf(path, sizeof(path), "%s/%s", lp.tmpdir, f->name); + char *slash = strrchr(path, '/'); + if (slash && slash > path + strlen(lp.tmpdir)) { + *slash = '\0'; + cbm_mkdir_p(path, 0755); + *slash = '/'; + } + if (i >= nfiles) { + if (symlink(f->content, path) != 0) { + return NULL; + } + continue; + } + FILE *fp = fopen(path, "wb"); + if (!fp) { + return NULL; + } + fputs(f->content, fp); + fclose(fp); + } + cbm_store_t *store = gpf_open_indexed(&lp); + char *out = NULL; + if (store) { + sqlite3_stmt *st = NULL; + const char *sql = + "SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name " + "|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || " + "coalesce(json_extract(e.properties,'$.image'),'-') || " + "coalesce(' via=' || json_extract(e.properties,'$.via'), '') AS x " + "FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id " + "WHERE e.project=?1 AND t.label='Image' ORDER BY x)"; + if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + out = strdup((const char *)sqlite3_column_text(st, 0)); + } + } + sqlite3_finalize(st); + } + gpf_cleanup(&lp, store); + return out; +} + +/* 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 -- + * to a file holding an image -- is never followed. The chart whose values + * 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) { + char outside[256]; + snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + char leak[300]; + snprintf(leak, sizeof(leak), "%s/values.yaml", outside); + FILE *lf = fopen(leak, "wb"); + ASSERT_NOT_NULL(lf); + fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf); + fclose(lf); + const GpfFile files[] = { + GPF_APP_CHART, + {"linked/Chart.yaml", "apiVersion: v2\nname: linked\nversion: 1.0.0\n"}, + {"linked/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: linked\nspec:\n template:\n" + " spec:\n containers:\n - name: linked\n" + " image: \"ghcr.io/acme/linked:{{ .Values.tag | default \"latest\" }}\"\n"}, + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: kapp\n valuesFile: prod.yaml\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: kapp\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}}; + const GpfFile links[] = {{"linked/values.yaml", leak}, {"kz/prod.yaml", leak}}; + char *got = gpf_image_edges_linked(files, 9, links, 2); + th_rmtree(outside); + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n" + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0\n" + "Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0"; + bool ok = got && strcmp(got, want) == 0; + if (!ok) { + fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want); + } + free(got); + if (!ok) { + FAIL("no values read through a symlink out of the repository"); + } + PASS(); +} + +/* 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) { + char outside[256]; + snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + char leak[300]; + snprintf(leak, sizeof(leak), "%s/vals.yaml", outside); + FILE *lf = fopen(leak, "wb"); + ASSERT_NOT_NULL(lf); + fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf); + fclose(lf); + const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n values = [file(\"vals.yaml\")]\n}\n"}}; + const GpfFile links[] = {{"tf/vals.yaml", leak}}; + char *got = gpf_image_edges_linked(files, 4, links, 1); + th_rmtree(outside); + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0"; + bool ok = got && strcmp(got, want) == 0; + if (!ok) { + fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want); + } + free(got); + if (!ok) { + FAIL("no Terraform values read through a symlink out of the repository"); + } + PASS(); +} + +/* A build argument holding a secret -- by its name (*_TOKEN) or its value (a + * GitHub token) -- is never kept on a build edge; the others still choose the + * base image they set. */ +TEST(probe_docker_build_args_keep_no_secret) { + static const GpfFile files[] = { + {"web/Dockerfile", "ARG GO=1.22\nFROM golang:${GO}\n"}, + {"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"}, + {".env", "NPM_TOKEN=npm-secret-value\n"}, + {"compose.yaml", "services:\n web:\n image: ghcr.io/acme/web\n build:\n" + " context: ./web\n args:\n NPM_TOKEN: ${NPM_TOKEN}\n" + " GO: \"1.23\"\n"}, + {"scripts/build.sh", + "#!/bin/sh\ndocker build --build-arg CI_KEY=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB " + "--build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"}}; + int secrets = gpf_query_int(files, 5, + "SELECT count(*) FROM edges WHERE project=?1 AND " + "(properties LIKE '%npm-secret-value%' OR properties LIKE " + "'%ghp_abcdefghij%' OR properties LIKE '%NPM_TOKEN%')"); + int overrides = gpf_query_int(files, 5, + "SELECT count(*) FROM edges WHERE project=?1 AND " + "json_extract(properties,'$.override_of') IS NOT NULL"); + ASSERT_EQ(secrets, 0); + ASSERT_EQ(overrides, 2); + PASS(); +} + +/* Build arguments of any length are kept whole on the build edge (never cut + * to malformed JSON): the one choosing the base still makes its edge. */ +TEST(probe_docker_long_build_args_kept_whole) { + enum { ARGS = 40, VAL = 150, CAP = ARGS * (VAL + 40) + 512 }; + char *script = malloc(CAP); + ASSERT_NOT_NULL(script); + int o = snprintf(script, CAP, "#!/bin/sh\ndocker build"); + char val[VAL + 1]; + memset(val, 'v', VAL); + val[VAL] = '\0'; + for (int i = 0; i < ARGS; i++) { + o += snprintf(script + o, (size_t)(CAP - o), " --build-arg LABEL%d=%s", i, val); + } + snprintf(script + o, (size_t)(CAP - o), " --build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"); + const GpfFile files[] = {{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"}, + {"scripts/build.sh", script}}; + int overrides = + gpf_query_int(files, 2, + "SELECT count(*) FROM edges e JOIN nodes t ON t.id=e.target_id WHERE " + "e.project=?1 AND t.name='python' AND " + "json_extract(e.properties,'$.override_of') IS NOT NULL"); + free(script); + ASSERT_EQ(overrides, 1); + PASS(); +} + /* A template's `include "name"` calls the define of that name in its chart * family -- the chart and its subcharts share one template namespace -- never * a same-named define of an unrelated chart; a define may also be written in @@ -6580,6 +6925,16 @@ SUITE(grammar_probe_f) { RUN_TEST(probe_docker_default_images_marked_and_overrides); RUN_TEST(probe_helm_template_images_render_with_defaults); RUN_TEST(probe_helm_release_images); + RUN_TEST(probe_helm_release_terraform_number_set); + 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_aliased_subchart_chart_name); + RUN_TEST(probe_helm_list_path_mark_on_chart_edge); + RUN_TEST(probe_helm_values_symlink_not_followed); + RUN_TEST(probe_helm_terraform_values_symlink_not_followed); + RUN_TEST(probe_docker_build_args_keep_no_secret); + RUN_TEST(probe_docker_long_build_args_kept_whole); RUN_TEST(probe_helm_include_resolves_in_the_chart_family); RUN_TEST(probe_k8s_selector_stays_in_its_directory); RUN_TEST(probe_k8s_selector_within_kustomize_build); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 079df09da..e86fd2e77 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4335,6 +4335,230 @@ TEST(helm_values_follow_helm_semantics) { PASS(); } +/* Render template tpl with values src (one probe per `image:` line given) + * and return the capture of probe 0 ("" for UNKNOWN, "" when + * nothing was captured) -- a copy, freed by the caller. */ +static char *helm_capture(const char *vals_src, const char *tpl, const cbm_helm_defines_t *defs, + int line) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *v = doc ? cbm_helm_values_parse(doc, vals_src, strlen(vals_src)) : NULL; + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probe = {.line = line}; + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, defs, &probe, 1); + char *out = strdup(probe.n == 0 ? "" : probe.image[0] ? probe.image[0] : ""); + cbm_arena_destroy(&a); + if (doc) { + yyjson_mut_doc_free(doc); + } + return out; +} + +/* A render doing far more work than any chart -- here a range over an int + * of 20,000,000 -- stops at its step budget (helm_template.c): every capture + * of the template is UNKNOWN, also the one made before the loop, never a + * partial or wrong image; a range over a huge int ends the same way. */ +TEST(helm_render_range_budget_ends_unknown) { + static const char vals[] = "tag: \"1.0\"\n"; + static const char ok[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n"; + char *got = helm_capture(vals, ok, NULL, 1); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + static const char rng[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n" + "{{- range 20000000 }}{{ end }}\n"; + got = helm_capture(vals, rng, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + static const char huge[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n" + "{{- range 2000000000 }}{{ range 2000000000 }}{{ end }}{{ end }}\n"; + got = helm_capture(vals, huge, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* An indent whose output passes the output budget stops the render: no + * capture of it is known (an indent of 9e18 stops before any work). */ +TEST(helm_render_indent_budget_ends_unknown) { + static const char big[] = "image: \"ghcr.io/acme/app{{ indent 70000000 \"\" | trim }}:1\"\n"; + char *got = helm_capture("{}\n", big, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + static const char huge[] = + "image: \"ghcr.io/acme/app{{ indent 9000000000000000000 \"\" | trim }}:1\"\n"; + got = helm_capture("{}\n", huge, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* Defines that each include the one below twice multiply the work by two a + * level: past the step budget the render stops, its captures UNKNOWN. */ +TEST(helm_render_define_fanout_ends_unknown) { + enum { LEVELS = 21, FAN_CAP = 8192 }; + char *fan = malloc(FAN_CAP); + ASSERT_NOT_NULL(fan); + int o = snprintf(fan, FAN_CAP, "{{- define \"d0\" -}}x{{- end -}}\n"); + for (int k = 1; k < LEVELS; k++) { + o += snprintf(fan + o, (size_t)(FAN_CAP - o), + "{{- define \"d%d\" -}}{{ include \"d%d\" . }}{{ include \"d%d\" . }}" + "{{- end -}}\n", + k, k - 1, k - 1); + } + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "templates/_fan.tpl", fan, strlen(fan)); + free(fan); + static const char tpl[] = "image: \"ghcr.io/acme/app:{{ include \"d20\" . | trunc 1 }}\"\n"; + char *got = helm_capture("{}\n", tpl, d, 1); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* go-yaml v2's aliasing limit (Helm reads values with it): a document whose + * decodes come mostly from alias expansion -- a billion laughs -- is no + * values; a few aliases still read. */ +TEST(helm_values_alias_expansion_limit) { + enum { LEVELS = 5, BUF = 4096 }; + char laughs[BUF]; + int o = snprintf(laughs, sizeof(laughs), "a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n"); + for (int k = 1; k < LEVELS; k++) { + o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "a%d: &a%d [", k, k); + for (int i = 0; i < 10; i++) { + o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "%s*a%d", i ? ", " : "", k - 1); + } + o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "]\n"); + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + ASSERT_NOT_NULL(doc); + static const char few[] = "base: &b {tag: \"1\"}\none: *b\ntwo: *b\n"; + yyjson_mut_val *ok = cbm_helm_values_parse(doc, few, strlen(few)); + yyjson_mut_val *bomb = cbm_helm_values_parse(doc, laughs, strlen(laughs)); + yyjson_mut_doc_free(doc); + ASSERT_NOT_NULL(ok); + ASSERT_NULL(bomb); + PASS(); +} + +/* --set as Helm's strvals reads it: a list index past MaxIndex (65536) is an + * error, so the set is no value (a release with it is unknown) -- never a + * list padded to it; a key longer than the parser holds is no value either, + * never split into two keys. */ +TEST(helm_values_set_index_cap_and_long_key) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + ASSERT_NOT_NULL(doc); + yyjson_mut_val *root = yyjson_mut_obj(doc); + ASSERT_TRUE(cbm_helm_values_set(doc, root, "list[3]", "x", false)); + ASSERT_FALSE(cbm_helm_values_set(doc, root, "big[65537]", "x", false)); + ASSERT_FALSE(cbm_helm_values_set(doc, root, "huge[9000000000000]", "x", false)); + char key[700]; + memset(key, 'k', sizeof(key) - 1); + key[sizeof(key) - 1] = '\0'; + ASSERT_FALSE(cbm_helm_values_set(doc, root, key, "x", false)); + yyjson_mut_doc_free(doc); + PASS(); +} + +/* A values key longer than 1 KiB read by an image is in its default_of + * mark whole -- a mark is never cut. */ +TEST(helm_render_long_key_mark_whole) { + enum { KEY = 1500 }; + char *key = malloc(KEY + 1); + ASSERT_NOT_NULL(key); + memset(key, 'k', KEY); + key[KEY] = '\0'; + size_t vn = KEY + CBM_SZ_64; + char *vals = malloc(vn); + char *tpl = malloc(vn); + ASSERT_NOT_NULL(vals); + ASSERT_NOT_NULL(tpl); + snprintf(vals, vn, "%s: \"1.0\"\n", key); + snprintf(tpl, vn, "image: \"ghcr.io/acme/app:{{ index .Values \"%s\" }}\"\n", key); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *v = cbm_helm_values_parse(doc, vals, strlen(vals)); + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probe = {.line = 1}; + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, NULL, &probe, 1); + char want[KEY + 8]; + snprintf(want, sizeof(want), "[\"%s\"]", key); + bool whole = probe.n == 1 && probe.keys[0] && strcmp(probe.keys[0], want) == 0; + cbm_arena_destroy(&a); + yyjson_mut_doc_free(doc); + free(key); + free(vals); + free(tpl); + ASSERT_TRUE(whole); + PASS(); +} + +/* A condition that is UNKNOWN runs no branch -- and what a branch would + * assign is UNKNOWN after it: `$tag = .Values.image.tagNew` under a + * .Capabilities test leaves no image naming the old tag. */ +TEST(helm_unknown_branch_spoils_what_it_assigns) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\", tagNew: \"2.0\"}\n"; + static const char tpl[] = + "{{- $tag := .Values.image.tag -}}\n" + "{{- if semverCompare \">=1.29-0\" .Capabilities.KubeVersion.Version }}" + "{{- $tag = .Values.image.tagNew -}}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ $tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* set / unset / merge change a map in place -- not modelled: what it holds + * after is UNKNOWN (an image read before the change keeps its value). */ +TEST(helm_mutator_spoils_the_map_it_changes) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" + "{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *before = helm_capture(vals, tpl, NULL, 1); + char *after = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(before, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(after, ""); + free(before); + free(after); + PASS(); +} + +/* A break under an UNKNOWN condition in a range: whether the loop goes on + * is unknown, so its later iterations are not rendered (Helm with a true + * condition renders no image here). */ +TEST(helm_unknown_break_leaves_the_loop) { + static const char tpl[] = "{{- range list \"a\" \"b\" }}\n" + "{{- if .Capabilities.APIVersions }}{{ break }}{{ end }}\n" + "image: \"ghcr.io/acme/{{ . }}:1\"\n" + "{{ end }}\n"; + char *got = helm_capture("{}\n", tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A change made to a new value -- fromYaml / deepCopy results, a part of one, + * an empty dict -- reaches nothing another part holds: the images after it + * stay known (the opentelemetry-ebpf-instrumentation configData pattern). */ +TEST(helm_change_of_a_new_value_spoils_nothing) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\ncfg: {a: 1}\n"; + static const char tpl[] = + "{{- $config := dict -}}\n" + "{{- $user := deepCopy (.Values.cfg | default dict) -}}\n" + "{{- if .Capabilities.APIVersions }}{{- $config = toYaml .Values.cfg | fromYaml -}}" + "{{- $config = mergeOverwrite $config $user -}}{{- end }}\n" + "{{- $config = mergeOverwrite $config $user -}}\n" + "{{- $_ := unset (deepCopy .Values.cfg) \"a\" }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 6); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + 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. */ @@ -24063,6 +24287,16 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(incremental_werf_values_edit_keeps_chart_edge); RUN_TEST(incremental_release_values_edit_rebuilds); RUN_TEST(helm_values_follow_helm_semantics); + RUN_TEST(helm_render_range_budget_ends_unknown); + RUN_TEST(helm_render_indent_budget_ends_unknown); + RUN_TEST(helm_render_define_fanout_ends_unknown); + RUN_TEST(helm_values_alias_expansion_limit); + RUN_TEST(helm_values_set_index_cap_and_long_key); + RUN_TEST(helm_render_long_key_mark_whole); + RUN_TEST(helm_unknown_branch_spoils_what_it_assigns); + 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(incremental_compose_dotenv_change_rebuilds); RUN_TEST(incremental_dockerfile_edit_keeps_docker_build_edges); RUN_TEST(incremental_second_build_declarer_keeps_the_edge);