Skip to content

Commit 00923a9

Browse files
Address GitHub Copilot PR review comments on #672
- pkg/cargo/kilnfile.go: UpdateBOSHReleaseTarballLockWithName now forces lock.Name to match the name argument (and rejects an empty name), instead of trusting the caller's lock.Name — a mismatch could silently insert/rename the wrong entry. - internal/commands/carvel_helpers.go: downloadCarvelRelease reuses cargo.KilnfileLock.FindBOSHReleaseWithName instead of re-implementing the same linear scan. writeStandardKilnfileLock only tolerates a missing lockfile (os.IsNotExist) now — a permissions/IO error used to be silently treated the same as "no lockfile", risking overwriting one we simply failed to read. It also reuses UpdateBOSHReleaseTarballLockWithName instead of duplicating the upsert loop. - internal/commands/carvel_upload.go: no longer misattributes a Kilnfile-side load failure (e.g. an unresolved variable(...) call) to Kilnfile.lock — checks the Kilnfile in isolation first. - internal/commands/carvel_bake.go: corrected a log message that said "No Kilnfile/Kilnfile.lock found" when only the lockfile's absence is actually confirmed. - internal/carvel/baker.go: - generateRuntimeConfigs's hook validation error is now mode-specific (pre-install/post-install), matching generateBoshReleaseDir's error, via a shared hookModeGroups() helper. - The synthesized hook script no longer execs the raw base.yml command string unquoted. shellQuoteCommand splits it into words and single-quotes each one before rejoining, so shell metacharacters in a hook command can't be interpreted by the generated script. - internal/acceptance/carvel/carvel_workflow_test.go: Step 3 was missing --from-lockfile, so it silently baked from source instead of exercising the Artifactory-download path it's supposed to test; its GetCount assertion was commented out (referencing an undeclared variable) rather than fixed. Added --from-lockfile and restored a working assertion. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2cbbbbc commit 00923a9

10 files changed

Lines changed: 234 additions & 75 deletions

File tree

internal/acceptance/carvel/carvel_workflow_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,18 @@ var _ = Describe("carvel full workflow", Ordered, func() {
304304
It("Step 3: bakes a tile using Kilnfile.lock (CI path with Artifactory download)", func() {
305305
outputFile := filepath.Join(tmpDir, "step3-ci.pivotal")
306306

307-
_ = art.GetCount()
307+
getCountBefore := art.GetCount()
308308

309309
cmd := exec.Command(pathToMain,
310310
append([]string{
311311
"carvel", "bake",
312312
"--source-directory", inputPath,
313313
"--output-file", outputFile,
314+
// Without --from-lockfile, `carvel bake` regenerates the
315+
// tile's own release from source (same as Step 1) and never
316+
// touches Artifactory — it wouldn't exercise the lockfile/CI
317+
// path this step is meant to test.
318+
"--from-lockfile",
314319
"--verbose",
315320
}, variableFlags()...)...,
316321
)
@@ -320,9 +325,8 @@ var _ = Describe("carvel full workflow", Ordered, func() {
320325

321326
assertValidTile(outputFile)
322327

323-
// With the new local cache check, it might not download if it's already in the cache.
324-
// Expect(art.GetCount()).To(BeNumerically(">", getCountBefore),
325-
// "CI bake must download the cached BOSH release from Artifactory")
328+
Expect(art.GetCount()).To(BeNumerically(">", getCountBefore),
329+
"CI bake must download the cached BOSH release from Artifactory")
326330
})
327331

328332
// -----------------------------------------------------------------------

internal/carvel/baker.go

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,46 @@ func (b *baker) hookJobName(hookName string) string {
260260
return prefix + hookName
261261
}
262262

263+
// shellQuoteCommand splits a hook's declared command on whitespace and
264+
// individually single-quotes each word before rejoining them, so the
265+
// generated hook script's `exec <command>` line safely execs the intended
266+
// binary/args even if a word contains shell metacharacters (;, $(), `, etc.)
267+
// — accidental or otherwise. base.yml authors still write one plain string
268+
// (e.g. "/var/vcap/jobs/smoke_tests/bin/run --foo bar"); the shell just never
269+
// gets a chance to interpret any of it.
270+
func shellQuoteCommand(command string) string {
271+
words := strings.Fields(command)
272+
quoted := make([]string, len(words))
273+
for i, w := range words {
274+
quoted[i] = shellQuoteWord(w)
275+
}
276+
return strings.Join(quoted, " ")
277+
}
278+
279+
// shellQuoteWord wraps a single word in POSIX single quotes, escaping any
280+
// embedded single quote as '"'"' (end quoting, a literal single quote via a
281+
// double-quoted segment, then resume quoting).
282+
func shellQuoteWord(word string) string {
283+
return "'" + strings.ReplaceAll(word, "'", `'"'"'`) + "'"
284+
}
285+
286+
// hookModeGroup pairs a hook mode with its declared hooks. Shared by
287+
// generateBoshReleaseDir (job synthesis) and generateRuntimeConfigs
288+
// (validation + addon job references) so both report the same
289+
// mode-specific error for a malformed hook, regardless of which bake path
290+
// (Bake vs. BakeFromLockfile) is in use.
291+
type hookModeGroup struct {
292+
mode string
293+
hooks []models.HookDeclaration
294+
}
295+
296+
func (b *baker) hookModeGroups() []hookModeGroup {
297+
return []hookModeGroup{
298+
{mode: "pre-install", hooks: b.metadata.PreInstallHooks},
299+
{mode: "post-install", hooks: b.metadata.PostInstallHooks},
300+
}
301+
}
302+
263303
func (b *baker) GetReleaseVersion() string {
264304
return b.releaseVersion
265305
}
@@ -494,14 +534,7 @@ files:
494534
return err
495535
}
496536

497-
type hookModeGroup struct {
498-
mode string
499-
hooks []models.HookDeclaration
500-
}
501-
for _, group := range []hookModeGroup{
502-
{mode: "pre-install", hooks: b.metadata.PreInstallHooks},
503-
{mode: "post-install", hooks: b.metadata.PostInstallHooks},
504-
} {
537+
for _, group := range b.hookModeGroups() {
505538
for _, hook := range group.hooks {
506539
if hook.Name == "" || hook.Command == "" {
507540
return fmt.Errorf("%s hook declaration missing name or command", group.mode)
@@ -532,7 +565,7 @@ properties: {}
532565
if err = os.MkdirAll(path.Join(dirName, "jobs", jobName, "templates"), 0755); err != nil {
533566
return err
534567
}
535-
templateContent := fmt.Sprintf("#!/bin/bash\nset -euo pipefail\nexec %s\n", hook.Command)
568+
templateContent := fmt.Sprintf("#!/bin/bash\nset -euo pipefail\nexec %s\n", shellQuoteCommand(hook.Command))
536569
err = os.WriteFile(
537570
path.Join(dirName, "jobs", jobName, "templates", templateName),
538571
[]byte(templateContent),
@@ -825,14 +858,13 @@ func (b *baker) generateRuntimeConfigs() error {
825858
releases := []string{`$( release "` + b.metadata.Name + `" )`}
826859
addonJobs := []models.Job{registryDataJob}
827860

828-
for _, hook := range append(
829-
append([]models.HookDeclaration{}, b.metadata.PreInstallHooks...),
830-
b.metadata.PostInstallHooks...,
831-
) {
832-
if hook.Name == "" || hook.Command == "" {
833-
return fmt.Errorf("hook declaration missing name or command")
861+
for _, group := range b.hookModeGroups() {
862+
for _, hook := range group.hooks {
863+
if hook.Name == "" || hook.Command == "" {
864+
return fmt.Errorf("%s hook declaration missing name or command", group.mode)
865+
}
866+
addonJobs = append(addonJobs, models.Job{Name: b.hookJobName(hook.Name), Release: b.metadata.Name})
834867
}
835-
addonJobs = append(addonJobs, models.Job{Name: b.hookJobName(hook.Name), Release: b.metadata.Name})
836868
}
837869

838870
for _, ar := range b.metadata.AdditionalReleases {

internal/carvel/baker_test.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path"
1010
"path/filepath"
1111
"strings"
12+
"testing"
1213

1314
. "github.com/onsi/ginkgo/v2"
1415
. "github.com/onsi/gomega"
@@ -766,7 +767,7 @@ consumes:
766767
Expect(templatePath).To(BeAnExistingFile())
767768
templateData, err := os.ReadFile(templatePath)
768769
Expect(err).NotTo(HaveOccurred())
769-
Expect(string(templateData)).To(ContainSubstring("exec /var/vcap/jobs/smoke_tests/bin/run"))
770+
Expect(string(templateData)).To(ContainSubstring("exec '/var/vcap/jobs/smoke_tests/bin/run'"))
770771
})
771772

772773
It("extends the runtime-config addon with the synthesized job", func() {
@@ -993,7 +994,7 @@ releases:
993994

994995
subject := NewBaker()
995996
err = subject.BakeFromLockfile(inputPath, cargo.Kilnfile{}, cargo.KilnfileLock{}, releaseLock, "/nonexistent/tarball.tgz", BakeOptions{})
996-
Expect(err).To(MatchError(ContainSubstring("hook declaration missing name or command")))
997+
Expect(err).To(MatchError(ContainSubstring("post-install hook declaration missing name or command")))
997998
})
998999
})
9991000
})
@@ -1231,3 +1232,46 @@ releases:
12311232
})
12321233
})
12331234
})
1235+
1236+
func TestShellQuoteCommand(t *testing.T) {
1237+
for _, tt := range []struct {
1238+
name string
1239+
command string
1240+
want string
1241+
}{
1242+
{
1243+
name: "single word",
1244+
command: "/var/vcap/jobs/smoke_tests/bin/run",
1245+
want: `'/var/vcap/jobs/smoke_tests/bin/run'`,
1246+
},
1247+
{
1248+
name: "command with args",
1249+
command: "/var/vcap/jobs/smoke_tests/bin/run --foo bar",
1250+
want: `'/var/vcap/jobs/smoke_tests/bin/run' '--foo' 'bar'`,
1251+
},
1252+
{
1253+
name: "a hostile command is neutralized, not interpreted",
1254+
command: "/bin/true; rm -rf /",
1255+
// Everything after the first word becomes literal arguments to
1256+
// /bin/true — the shell never sees an unquoted ';'.
1257+
want: `'/bin/true;' 'rm' '-rf' '/'`,
1258+
},
1259+
{
1260+
name: "command substitution is neutralized",
1261+
command: "/bin/true $(whoami) `whoami`",
1262+
want: "'/bin/true' '$(whoami)' '`whoami`'",
1263+
},
1264+
{
1265+
name: "embedded single quote is escaped, not closed early",
1266+
command: `/bin/echo it's`,
1267+
want: `'/bin/echo' 'it'"'"'s'`,
1268+
},
1269+
} {
1270+
t.Run(tt.name, func(t *testing.T) {
1271+
got := shellQuoteCommand(tt.command)
1272+
if got != tt.want {
1273+
t.Errorf("shellQuoteCommand(%q) = %q, want %q", tt.command, got, tt.want)
1274+
}
1275+
})
1276+
}
1277+
}

internal/commands/carvel_bake.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ func (c CarvelBake) Execute(args []string) error {
7575
} else if c.Options.FromLockfile {
7676
return fmt.Errorf("failed to load Kilnfiles (required for --from-lockfile): %w", loadErr)
7777
} else {
78-
c.outLogger.Printf("No Kilnfile/Kilnfile.lock found — proceeding without additional_releases support")
78+
// lockfilePresent is false here, so the lockfile is confirmed absent
79+
// — that's what actually blocks additional_releases/--from-lockfile,
80+
// regardless of whether a Kilnfile exists on its own (LoadKilnfiles
81+
// always requires both, so we can't tell from loadErr alone).
82+
c.outLogger.Printf("No Kilnfile.lock found — proceeding without additional_releases support")
7983
}
8084

8185
if c.Options.FromLockfile {

internal/commands/carvel_helpers.go

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,8 @@ func downloadCarvelRelease(logger *log.Logger, kilnfile cargo.Kilnfile, lock car
4646
return "", fmt.Errorf("no releases found in Kilnfile.lock")
4747
}
4848

49-
var releaseLock cargo.BOSHReleaseTarballLock
50-
found := false
51-
for _, r := range lock.Releases {
52-
if r.Name == releaseName {
53-
releaseLock = r
54-
found = true
55-
break
56-
}
57-
}
58-
if !found {
49+
releaseLock, err := lock.FindBOSHReleaseWithName(releaseName)
50+
if err != nil {
5951
return "", fmt.Errorf("release %q not found in Kilnfile.lock", releaseName)
6052
}
6153

@@ -78,10 +70,19 @@ func downloadCarvelRelease(logger *log.Logger, kilnfile cargo.Kilnfile, lock car
7870
func writeStandardKilnfileLock(lockfilePath string, releaseName, releaseVersion, remotePath, remoteSourceID, sha1 string) error {
7971
var lock cargo.KilnfileLock
8072

81-
if data, err := os.ReadFile(lockfilePath); err == nil {
73+
data, err := os.ReadFile(lockfilePath)
74+
switch {
75+
case err == nil:
8276
if err := yaml.Unmarshal(data, &lock); err != nil {
8377
return fmt.Errorf("failed to parse existing Kilnfile.lock: %w", err)
8478
}
79+
case os.IsNotExist(err):
80+
// No existing lockfile to preserve — fine, we're creating one.
81+
default:
82+
// A permissions/IO error is not the same as "no lockfile exists yet";
83+
// treating it that way would silently overwrite a lockfile we simply
84+
// failed to read, losing whatever was in it.
85+
return fmt.Errorf("failed to read existing Kilnfile.lock: %w", err)
8586
}
8687

8788
newEntry := cargo.BOSHReleaseTarballLock{
@@ -91,17 +92,8 @@ func writeStandardKilnfileLock(lockfilePath string, releaseName, releaseVersion,
9192
RemoteSource: remoteSourceID,
9293
SHA1: sha1,
9394
}
94-
95-
found := false
96-
for i, r := range lock.Releases {
97-
if r.Name == releaseName {
98-
lock.Releases[i] = newEntry
99-
found = true
100-
break
101-
}
102-
}
103-
if !found {
104-
lock.Releases = append(lock.Releases, newEntry)
95+
if err := (&lock).UpdateBOSHReleaseTarballLockWithName(releaseName, newEntry); err != nil {
96+
return fmt.Errorf("failed to update Kilnfile.lock: %w", err)
10597
}
10698

10799
if lock.Stemcell.OS == "" {
@@ -111,7 +103,7 @@ func writeStandardKilnfileLock(lockfilePath string, releaseName, releaseVersion,
111103
}
112104
}
113105

114-
data, err := yaml.Marshal(&lock)
106+
data, err = yaml.Marshal(&lock)
115107
if err != nil {
116108
return fmt.Errorf("failed to marshal Kilnfile.lock: %w", err)
117109
}

internal/commands/carvel_helpers_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,33 @@ func TestWriteStandardKilnfileLock_PreservesExisting(t *testing.T) {
4747
require.Equal(t, "my-source", updatedLock.Releases[2].RemoteSource)
4848
require.Equal(t, "fake-sha1", updatedLock.Releases[2].SHA1)
4949
}
50+
51+
func TestWriteStandardKilnfileLock_FailsRatherThanOverwriteUnreadableFile(t *testing.T) {
52+
if os.Getuid() == 0 {
53+
t.Skip("running as root bypasses file permission checks")
54+
}
55+
56+
tmpDir := t.TempDir()
57+
lockfilePath := filepath.Join(tmpDir, "Kilnfile.lock")
58+
59+
initialLock := cargo.KilnfileLock{
60+
Releases: []cargo.BOSHReleaseTarballLock{
61+
{Name: "cf-cli", Version: "1.0.0"},
62+
},
63+
}
64+
data, err := yaml.Marshal(&initialLock)
65+
require.NoError(t, err)
66+
require.NoError(t, os.WriteFile(lockfilePath, data, 0644))
67+
require.NoError(t, os.Chmod(lockfilePath, 0000))
68+
defer func() { _ = os.Chmod(lockfilePath, 0644) }()
69+
70+
err = writeStandardKilnfileLock(lockfilePath, "ear-k8s-runtime", "3.0.0", "path/to/remote", "my-source", "fake-sha1")
71+
require.Error(t, err, "an unreadable (not missing) lockfile must not be silently treated as absent")
72+
73+
// The file on disk must be untouched — not overwritten with just the
74+
// new entry, losing the existing cf-cli entry we couldn't even read.
75+
require.NoError(t, os.Chmod(lockfilePath, 0644))
76+
onDisk, err := os.ReadFile(lockfilePath)
77+
require.NoError(t, err)
78+
require.Equal(t, data, onDisk)
79+
}

internal/commands/carvel_upload.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,21 @@ func (c CarvelUpload) Execute(args []string) error {
6262
c.Options.Kilnfile = kilnfilePath
6363
kilnfile, kilnfileLock, err := c.Options.LoadKilnfiles(nil, nil)
6464
if err != nil {
65+
// LoadKilnfiles doesn't say which file failed. Check the Kilnfile in
66+
// isolation first, so a Kilnfile-side failure (e.g. an unresolved
67+
// variable(...) call) isn't misattributed to the lockfile below.
68+
kf, kfErr := loadKilnfileOnly(c.Options.Standard)
69+
if kfErr != nil {
70+
return fmt.Errorf("failed to load Kilnfile: %w", kfErr)
71+
}
72+
kilnfile = kf
73+
6574
if _, lockStatErr := os.Stat(c.Options.KilnfileLockPath()); lockStatErr == nil {
75+
// The lockfile exists and the Kilnfile loads fine on its own, so
76+
// the lockfile itself must be what's failing to load/parse.
6677
return fmt.Errorf("failed to load Kilnfile.lock: %w", err)
6778
}
68-
kilnfile, err = loadKilnfileOnly(c.Options.Standard)
69-
if err != nil {
70-
return fmt.Errorf("failed to load Kilnfile: %w", err)
71-
}
79+
// Kilnfile.lock is simply absent — tolerate it, we might be creating it.
7280
}
7381

7482
artConfig, err := findArtifactorySource(kilnfile)

pkg/cargo/kilnfile.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ func (k KilnfileLock) FindBOSHReleaseWithName(name string) (BOSHReleaseTarballLo
4646
}
4747

4848
func (k *KilnfileLock) UpdateBOSHReleaseTarballLockWithName(name string, lock BOSHReleaseTarballLock) error {
49+
if name == "" {
50+
return errors.New("name must not be empty")
51+
}
52+
// Force the entry's Name to match name, so a caller can't silently
53+
// rename/insert a mismatched entry by passing a lock.Name that differs
54+
// from the name being updated.
55+
lock.Name = name
56+
4957
for i, r := range k.Releases {
5058
if r.Name == name {
5159
k.Releases[i] = lock

0 commit comments

Comments
 (0)