From cd8bbcc2b0028301afca5436a26c3b90e1787125 Mon Sep 17 00:00:00 2001 From: Martin Zihlmann Date: Wed, 24 Jun 2026 22:25:48 +0100 Subject: [PATCH 1/5] mz817: integration test --- .../dockerfiles/Dockerfile_test_issue_mz817 | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 integration/dockerfiles/Dockerfile_test_issue_mz817 diff --git a/integration/dockerfiles/Dockerfile_test_issue_mz817 b/integration/dockerfiles/Dockerfile_test_issue_mz817 new file mode 100644 index 000000000..0360601be --- /dev/null +++ b/integration/dockerfiles/Dockerfile_test_issue_mz817 @@ -0,0 +1,19 @@ +# mz817: on v1.27.6 COPY heredoc does not expand build variables. +# Docker applies Dockerfile word-expansion to a COPY heredoc body when the +# delimiter is unquoted: ${VAR} expands and \${VAR} keeps the literal text with +# the backslash stripped. A quoted delimiter (<<'EOF') leaves the body verbatim. +# kaniko writes the body verbatim in every case, so the expanded files diverge. +FROM scratch + +ENV DO_EXPAND=expanded + +# unquoted delimiter expands ${DO_EXPAND}, keeps \${DO_EXPAND} literal +COPY < Date: Wed, 24 Jun 2026 22:32:39 +0100 Subject: [PATCH 2/5] mz817: expand variables in COPY/ADD heredoc content --- pkg/commands/copy.go | 11 ++++++++++- pkg/util/command_util.go | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg/commands/copy.go b/pkg/commands/copy.go index 6f871e49f..18fa041f3 100644 --- a/pkg/commands/copy.go +++ b/pkg/commands/copy.go @@ -157,7 +157,16 @@ func (c *CopyCommand) ExecuteCommand(config *v1.Config, buildArgs *dockerfile.Bu return nil } - srcFile := strings.NewReader(src.Data) + data := src.Data + if src.Expand { + expanded, err := util.ResolveEnvironmentReplacementRaw(src.Data, replacementEnvs) + if err != nil { + return fmt.Errorf("expanding heredoc content: %w", err) + } + data = expanded + } + + srcFile := strings.NewReader(data) err = util.CreateFile(destPath, srcFile, chmod.Apply(0o644), uint32(uid), uint32(gid)) if err != nil { return fmt.Errorf("creating file: %w", err) diff --git a/pkg/util/command_util.go b/pkg/util/command_util.go index 84157ff27..54ff0ba6c 100644 --- a/pkg/util/command_util.go +++ b/pkg/util/command_util.go @@ -97,6 +97,16 @@ func ResolveEnvironmentReplacement(value string, envs []string, isFilepath bool) return fp, nil } +// ResolveEnvironmentReplacementRaw resolves env variables in raw content such as a +// heredoc body. Unlike ResolveEnvironmentReplacement it leaves quotes intact +// (SkipProcessQuotes), matching how docker expands COPY/ADD heredoc contents. +func ResolveEnvironmentReplacementRaw(value string, envs []string) (string, error) { + shlex := shell.NewLex(parser.DefaultEscapeToken) + shlex.SkipProcessQuotes = true + fp, _, err := shlex.ProcessWord(value, shell.EnvsFromSlice(envs)) + return fp, err +} + func ResolveEnvAndWildcards(sd instructions.SourcesAndDest, fileContext FileContext, envs []string) ([]string, string, error) { // First, resolve any environment replacement resolvedEnvs, err := ResolveEnvironmentReplacementList(sd.SourcePaths, envs, true) From 737f5d67be7d74a7283b57ee5703e57922171dad Mon Sep 17 00:00:00 2001 From: Martin Zihlmann Date: Thu, 25 Jun 2026 11:25:38 +0100 Subject: [PATCH 3/5] mz817: gate COPY/ADD heredoc expansion behind FF_KANIKO_EXPAND_HEREDOC --- README.md | 8 ++++++++ integration/images.go | 1 + pkg/commands/copy.go | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b3b18867..daa64d8a7 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ expect - see [Known Issues](#known-issues). - [Flag `FF_KANIKO_SECUREJOIN_EXTRACTION`](#flag-ff_kaniko_securejoin_extraction) - [Flag `FF_KANIKO_RESOLVE_CACHE_KEY`](#flag-ff_kaniko_resolve_cache_key) - [Flag `FF_KANIKO_UNTAR_SKIP_ROOT`](#flag-ff_kaniko_untar_skip_root) + - [Flag `FF_KANIKO_EXPAND_HEREDOC`](#flag-ff_kaniko_expand_heredoc) - [Assertion Overrides](#assertion-overrides) - [Debug Image](#debug-image) - [Security](#security) @@ -1305,6 +1306,13 @@ Set this flag to `true` to skip the root `.` entry when untarring. Defaults to `false`. Becomes default in `v1.29.0`. +#### Flag `FF_KANIKO_EXPAND_HEREDOC` + +Docker applies Dockerfile word-expansion to a `COPY` or `ADD` heredoc body when the delimiter is unquoted, so `${VAR}` expands and `\${VAR}` keeps the literal text. A quoted delimiter (`<<'EOF'`) leaves the body verbatim. kaniko writes the body verbatim in every case, so the expanded files diverge from Docker. +Set this flag to `true` to expand build args and env in unquoted `COPY` and `ADD` heredoc bodies. +Defaults to `false`. +Becomes default in `v1.29.0`. + ### Assertion Overrides Kaniko checks internal invariants at runtime. If one is violated the build stops with a message like: diff --git a/integration/images.go b/integration/images.go index d562468ee..e9d3d2593 100644 --- a/integration/images.go +++ b/integration/images.go @@ -112,6 +112,7 @@ var KanikoEnv = []string{ "FF_KANIKO_RESOLVE_CACHE_KEY=1", "FF_KANIKO_UNTAR_SKIP_ROOT=1", "FF_KANIKO_REPRODUCIBLE_PRESERVE_BASE_LAYERS=1", + "FF_KANIKO_EXPAND_HEREDOC=1", "KANIKO_PRINT_PLAN=1", } diff --git a/pkg/commands/copy.go b/pkg/commands/copy.go index 18fa041f3..f6dce67a4 100644 --- a/pkg/commands/copy.go +++ b/pkg/commands/copy.go @@ -158,7 +158,7 @@ func (c *CopyCommand) ExecuteCommand(config *v1.Config, buildArgs *dockerfile.Bu } data := src.Data - if src.Expand { + if src.Expand && kConfig.EnvBool("FF_KANIKO_EXPAND_HEREDOC") { expanded, err := util.ResolveEnvironmentReplacementRaw(src.Data, replacementEnvs) if err != nil { return fmt.Errorf("expanding heredoc content: %w", err) From 18f37d5e951b2472da03416c8ba3c755e0c49002 Mon Sep 17 00:00:00 2001 From: Martin Zihlmann Date: Wed, 24 Jun 2026 22:54:00 +0100 Subject: [PATCH 4/5] mz822: fold resolved heredoc contents into COPY/ADD cache key --- golden/golden_test.go | 2 + golden/testdata/test_issue_mz822/Dockerfile | 10 +++++ .../test_issue_mz822/plans/resolved_one | 6 +++ .../test_issue_mz822/plans/resolved_two | 6 +++ .../test_issue_mz822/plans/unresolved_one | 6 +++ .../test_issue_mz822/plans/unresolved_two | 6 +++ golden/testdata/test_issue_mz822/test.go | 45 +++++++++++++++++++ pkg/commands/add.go | 4 +- pkg/commands/copy.go | 29 +++++++++++- 9 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 golden/testdata/test_issue_mz822/Dockerfile create mode 100644 golden/testdata/test_issue_mz822/plans/resolved_one create mode 100644 golden/testdata/test_issue_mz822/plans/resolved_two create mode 100644 golden/testdata/test_issue_mz822/plans/unresolved_one create mode 100644 golden/testdata/test_issue_mz822/plans/unresolved_two create mode 100644 golden/testdata/test_issue_mz822/test.go diff --git a/golden/golden_test.go b/golden/golden_test.go index 01dbb78a6..79e2cd35d 100644 --- a/golden/golden_test.go +++ b/golden/golden_test.go @@ -41,6 +41,7 @@ import ( testissuemz703 "github.com/osscontainertools/kaniko/golden/testdata/test_issue_mz703" testissuemz791 "github.com/osscontainertools/kaniko/golden/testdata/test_issue_mz791" testissuemz813 "github.com/osscontainertools/kaniko/golden/testdata/test_issue_mz813" + testissuemz822 "github.com/osscontainertools/kaniko/golden/testdata/test_issue_mz822" testunittests "github.com/osscontainertools/kaniko/golden/testdata/test_unittests" "github.com/osscontainertools/kaniko/golden/types" "github.com/osscontainertools/kaniko/pkg/cache" @@ -96,6 +97,7 @@ var allTests = map[string][]types.GoldenTests{ "test_issue_mz703": {testissuemz703.Tests}, "test_issue_mz791": {testissuemz791.Tests}, "test_issue_mz813": {testissuemz813.Tests}, + "test_issue_mz822": {testissuemz822.Tests}, "test_unittests": testunittests.Tests, } var update bool diff --git a/golden/testdata/test_issue_mz822/Dockerfile b/golden/testdata/test_issue_mz822/Dockerfile new file mode 100644 index 000000000..eee11c1fe --- /dev/null +++ b/golden/testdata/test_issue_mz822/Dockerfile @@ -0,0 +1,10 @@ +# mz822: a COPY heredoc whose body expands a build arg is not reflected in the +# layer cache key. The body is omitted from the instruction text the key is built +# from, so a build that only changes the arg reuses the layer cached for a +# different resolved content and serves a stale file. Same defect as mz791 for +# COPY/ADD paths and mz813 for WORKDIR. +FROM busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d +ARG A +COPY < Date: Thu, 25 Jun 2026 12:50:46 +0100 Subject: [PATCH 5/5] mz822: gate heredoc cache-key expansion behind FF_KANIKO_EXPAND_HEREDOC --- golden/testdata/test_issue_mz822/test.go | 6 +++++- pkg/commands/copy.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/golden/testdata/test_issue_mz822/test.go b/golden/testdata/test_issue_mz822/test.go index e8dc1e4a2..62ac8aa8a 100644 --- a/golden/testdata/test_issue_mz822/test.go +++ b/golden/testdata/test_issue_mz822/test.go @@ -24,12 +24,15 @@ var Tests = types.GoldenTests{ Plan: "unresolved_two", }, // With the flag the resolved heredoc body is folded into the key, so the - // COPY cache key tracks A. The two plans differ in the COPY key. + // COPY cache key tracks A. The unquoted delimiter expands the body, so + // FF_KANIKO_EXPAND_HEREDOC must be on for the key to match what the + // executor writes. The two plans differ in the COPY key. { Args: []string{"--no-push", "--cache", "--build-arg", "A=one"}, Env: map[string]string{ "FF_KANIKO_CACHE_LOOKAHEAD": "1", "FF_KANIKO_RESOLVE_CACHE_KEY": "1", + "FF_KANIKO_EXPAND_HEREDOC": "1", }, Plan: "resolved_one", }, @@ -38,6 +41,7 @@ var Tests = types.GoldenTests{ Env: map[string]string{ "FF_KANIKO_CACHE_LOOKAHEAD": "1", "FF_KANIKO_RESOLVE_CACHE_KEY": "1", + "FF_KANIKO_EXPAND_HEREDOC": "1", }, Plan: "resolved_two", }, diff --git a/pkg/commands/copy.go b/pkg/commands/copy.go index f1e7aede1..33c1161ff 100644 --- a/pkg/commands/copy.go +++ b/pkg/commands/copy.go @@ -204,7 +204,7 @@ func resolvedCacheKey(instruction string, contents []instructions.SourceContent, key.WriteString(resolved) for _, src := range contents { content := src.Data - if src.Expand { + if src.Expand && kConfig.EnvBool("FF_KANIKO_EXPAND_HEREDOC") { content, err = util.ResolveEnvironmentReplacementRaw(src.Data, replacementEnvs) if err != nil { return "", err