Skip to content

Commit 45b6f23

Browse files
authored
refactor: replace generic ShellEscape with shell-aware functions (#690)
* refactor: replace generic ShellEscape with shell-aware functions * adapt tests * make generate
1 parent 6ed1976 commit 45b6f23

22 files changed

+498
-82
lines changed

docs/help/gardenctl_ssh.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Establish an SSH connection to a node of a Shoot cluster
44

55
### Synopsis
66

7-
Establish an SSH connection to a node of a Shoot cluster by specifying its name.
7+
Establish an SSH connection to a node of a Shoot cluster by specifying its name.
88

99
A bastion is created to access the node and is automatically cleaned up afterwards.
1010

@@ -59,6 +59,7 @@ gardenctl ssh --keep-bastion --bastion-name cli-xxxxxxxx --public-key-file /path
5959
--project string target the given project
6060
--public-key-file string Path to the file that contains a public SSH key. If not given, a temporary keypair will be generated.
6161
--seed string target the given seed cluster
62+
--shell string Shell to use for escaping arguments when printing out the SSH command. If not provided, it defaults to the GCTL_SHELL environment variable or bash.
6263
--shoot string target the given shoot cluster
6364
--skip-availability-check Skip checking for SSH bastion host availability.
6465
--user string user is the name of the Shoot cluster node ssh login username. (default "gardener")

internal/util/strings.go

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ SPDX-License-Identifier: Apache-2.0
77
package util
88

99
import (
10-
"fmt"
1110
"strings"
1211
"unicode"
1312
)
@@ -28,23 +27,6 @@ func FilterStringsByPrefix(prefix string, values []string) []string {
2827
return filtered
2928
}
3029

31-
// ShellEscape returns a shell-escaped version of the given string. It also removes non-printable characters.
32-
func ShellEscape(values ...interface{}) string {
33-
out := make([]string, 0, len(values))
34-
35-
for _, v := range values {
36-
if v != nil {
37-
s := fmt.Sprintf("%v", v)
38-
s = StripUnsafe(s)
39-
s = strings.ReplaceAll(s, "'", "'\"'\"'")
40-
s = "'" + s + "'"
41-
out = append(out, s)
42-
}
43-
}
44-
45-
return strings.Join(out, " ")
46-
}
47-
4830
// StripUnsafe remove non-printable characters from the string.
4931
func StripUnsafe(s string) string {
5032
return strings.Map(func(r rune) rune {

internal/util/strings_test.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,4 @@ var _ = Describe("String Utilities", func() {
2020
Expect(util.FilterStringsByPrefix("x", []string{"xa", "yb", "xc", "zx"})).To(Equal([]string{"xa", "xc"}))
2121
})
2222
})
23-
24-
Describe("escaping shell strings", func() {
25-
It("should escape a shell string", func() {
26-
Expect(util.ShellEscape("$TOKEN")).To(Equal("'$TOKEN'"))
27-
Expect(util.ShellEscape("'")).To(Equal("''\"'\"''"))
28-
Expect(util.ShellEscape("\u0081")).To(Equal("''"))
29-
})
30-
31-
It("should escape multiple shell strings", func() {
32-
Expect(util.ShellEscape("a", "b")).To(Equal("'a' 'b'"))
33-
})
34-
})
3523
})

pkg/cmd/kubectlenv/options.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ func (o *options) Complete(f util.Factory, cmd *cobra.Command, _ []string) error
4747
o.Shell = cmd.Name()
4848
o.CmdPath = cmd.Parent().CommandPath()
4949
o.GardenDir = f.GardenHomeDir()
50-
o.Template = env.NewTemplate("helpers")
50+
51+
tmpl, err := env.NewTemplate(o.Shell, "helpers")
52+
if err != nil {
53+
return err
54+
}
55+
56+
o.Template = tmpl
5157

5258
filename := filepath.Join(o.GardenDir, "templates", "kubernetes.tmpl")
5359
if err := o.Template.ParseFiles(filename); err != nil {

pkg/cmd/kubectlenv/options_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ var _ = Describe("Env Commands - Options", func() {
5151
manager = targetmocks.NewMockManager(ctrl)
5252
options = kubectlenv.NewOptions()
5353
cmdPath = "gardenctl provider-env"
54-
baseTemplate = env.NewTemplate("helpers")
55-
shell = "default"
54+
shell = "bash"
55+
var err error
56+
baseTemplate, err = env.NewTemplate(shell, "helpers")
57+
Expect(err).NotTo(HaveOccurred())
5658
cfg = &config.Config{
5759
LinkKubeconfig: ptr.To(false),
5860
Gardens: []config.Garden{{Name: "test"}},
@@ -78,7 +80,7 @@ var _ = Describe("Env Commands - Options", func() {
7880
BeforeEach(func() {
7981
root = &cobra.Command{Use: "root"}
8082
parent = &cobra.Command{Use: "parent", Aliases: []string{"alias"}}
81-
child = &cobra.Command{Use: "child"}
83+
child = &cobra.Command{Use: "bash"}
8284
parent.AddCommand(child)
8385
root.AddCommand(parent)
8486
factory.EXPECT().GardenHomeDir().Return(gardenHomeDir)
@@ -230,7 +232,9 @@ var _ = Describe("Env Commands - Options", func() {
230232
JustBeforeEach(func() {
231233
meta = options.GenerateMetadata()
232234
targetFlags = kubectlenv.GetTargetFlags(t)
233-
Expect(env.NewTemplate("helpers").ExecuteTemplate(options.IOStreams.Out, "usage-hint", meta)).To(Succeed())
235+
tmpl, err := env.NewTemplate(shell, "helpers")
236+
Expect(err).NotTo(HaveOccurred())
237+
Expect(tmpl.ExecuteTemplate(options.IOStreams.Out, "usage-hint", meta)).To(Succeed())
234238
})
235239

236240
Context("when configuring the shell", func() {

pkg/cmd/providerenv/options.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,15 @@ func (o *options) Complete(f util.Factory, cmd *cobra.Command, _ []string) error
8888

8989
o.CmdPath = cmd.Parent().CommandPath()
9090
o.GardenDir = f.GardenHomeDir()
91-
o.Template = env.NewTemplate("helpers")
91+
92+
if o.Output == "" {
93+
tmpl, err := env.NewTemplate(o.Shell, "helpers")
94+
if err != nil {
95+
return err
96+
}
97+
98+
o.Template = tmpl
99+
}
92100

93101
sessionID, err := f.GetSessionID()
94102
if err != nil {
@@ -203,7 +211,13 @@ func (o *options) Validate() error {
203211
return pflag.ErrHelp
204212
}
205213

206-
// Usually, we would check and return an error if both shell and output are set (not empty). However, this is not required because the output flag is not set for the shell subcommands.
214+
if o.Shell != "" && o.Output != "" {
215+
return fmt.Errorf("internal error: both shell and output are set")
216+
}
217+
218+
if o.Output != "" && o.Template != nil {
219+
return fmt.Errorf("internal error: template is set when output is specified")
220+
}
207221

208222
if o.Shell != "" {
209223
s := env.Shell(o.Shell)

pkg/cmd/providerenv/options_test.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,11 @@ var _ = Describe("Env Commands - Options", func() {
6565
manager = targetmocks.NewMockManager(ctrl)
6666
options = providerenv.NewOptions()
6767
cmdPath = "gardenctl provider-env"
68-
baseTemplate = env.NewTemplate("helpers")
69-
shell = "default"
68+
// Use bash as default shell for tests; helpers template itself is shell-agnostic
69+
shell = "bash"
70+
var err error
71+
baseTemplate, err = env.NewTemplate(shell, "helpers")
72+
Expect(err).NotTo(HaveOccurred())
7073
output = ""
7174
providerType = "aws"
7275
cfg = &config.Config{
@@ -96,7 +99,7 @@ var _ = Describe("Env Commands - Options", func() {
9699
BeforeEach(func() {
97100
root = &cobra.Command{Use: "root"}
98101
parent = &cobra.Command{Use: "parent", Aliases: []string{"alias"}}
99-
child = &cobra.Command{Use: "child"}
102+
child = &cobra.Command{Use: "bash"}
100103
parent.AddCommand(child)
101104
root.AddCommand(parent)
102105
factory.EXPECT().GardenHomeDir().Return(gardenHomeDir)
@@ -159,6 +162,7 @@ var _ = Describe("Env Commands - Options", func() {
159162
Context("when output is set", func() {
160163
BeforeEach(func() {
161164
shell = ""
165+
baseTemplate = nil
162166
})
163167

164168
It("should successfully validate the options", func() {
@@ -172,6 +176,12 @@ var _ = Describe("Env Commands - Options", func() {
172176
})
173177
})
174178

179+
It("should detect invalid state when both shell and output are set", func() {
180+
options.Shell = "bash"
181+
options.Output = "json"
182+
Expect(options.Validate()).To(MatchError("internal error: both shell and output are set"))
183+
})
184+
175185
It("should successfully validate the options", func() {
176186
options.Shell = "bash"
177187
Expect(options.Validate()).To(Succeed())
@@ -243,8 +253,10 @@ var _ = Describe("Env Commands - Options", func() {
243253
factory.EXPECT().Context().Return(ctx).AnyTimes()
244254
// Create a proper command hierarchy for Complete() to work
245255
parentCmd := &cobra.Command{Use: "gardenctl"}
246-
mockCmd = &cobra.Command{Use: "provider-env"}
247-
parentCmd.AddCommand(mockCmd)
256+
providerEnvCmd := &cobra.Command{Use: "provider-env"}
257+
mockCmd = &cobra.Command{Use: "bash"}
258+
providerEnvCmd.AddCommand(mockCmd)
259+
parentCmd.AddCommand(providerEnvCmd)
248260
})
249261

250262
JustBeforeEach(func() {
@@ -526,8 +538,10 @@ var _ = Describe("Env Commands - Options", func() {
526538
options.Target = target.NewTarget("test", "project", "", shootName)
527539
// Create a proper command hierarchy for Complete() to work
528540
parentCmd := &cobra.Command{Use: "gardenctl"}
529-
mockCmd = &cobra.Command{Use: "provider-env"}
530-
parentCmd.AddCommand(mockCmd)
541+
providerEnvCmd := &cobra.Command{Use: "provider-env"}
542+
mockCmd = &cobra.Command{Use: "bash"}
543+
providerEnvCmd.AddCommand(mockCmd)
544+
parentCmd.AddCommand(providerEnvCmd)
531545
})
532546

533547
JustBeforeEach(func() {
@@ -666,6 +680,7 @@ var _ = Describe("Env Commands - Options", func() {
666680
manager.EXPECT().SessionDir().Return(sessionDir)
667681
manager.EXPECT().Configuration().Return(cfg)
668682
Expect(options.Complete(factory, mockCmd, nil)).To(Succeed())
683+
options.Shell = shell // monkey patch
669684
})
670685

671686
It("should fail to render the template with JSON parse error", func() {
@@ -1044,7 +1059,9 @@ var _ = Describe("Env Commands - Options", func() {
10441059
cli = providerenv.GetProviderCLI(providerType)
10451060
meta = options.GenerateMetadata(cli, "Secret")
10461061
targetFlags = providerenv.GetTargetFlags(t)
1047-
Expect(env.NewTemplate("helpers").ExecuteTemplate(options.IOStreams.Out, "usage-hint", meta)).To(Succeed())
1062+
tmpl, err := env.NewTemplate(shell, "helpers")
1063+
Expect(err).NotTo(HaveOccurred())
1064+
Expect(tmpl.ExecuteTemplate(options.IOStreams.Out, "usage-hint", meta)).To(Succeed())
10481065
})
10491066

10501067
Context("when configuring the shell", func() {

pkg/cmd/rc/options.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ type options struct {
4040
func (o *options) Complete(_ util.Factory, cmd *cobra.Command, _ []string) error {
4141
o.Shell = cmd.Name()
4242
o.CmdPath = cmd.Parent().CommandPath()
43-
o.Template = env.NewTemplate("rc")
43+
44+
tmpl, err := env.NewTemplate(o.Shell, "rc")
45+
if err != nil {
46+
return err
47+
}
48+
49+
o.Template = tmpl
4450

4551
return nil
4652
}
@@ -60,6 +66,10 @@ func (o *options) Validate() error {
6066
return fmt.Errorf("prefix must start with an alphabetic character may be followed by alphanumeric characters, underscore or dash")
6167
}
6268

69+
if len(o.Prefix) > 32 {
70+
return fmt.Errorf("prefix is too long, maximum length is 32 characters")
71+
}
72+
6373
return nil
6474
}
6575

pkg/cmd/rc/rc_test.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ var _ = Describe("Env Commands", func() {
6262
It("should execute the bash subcommand", func() {
6363
cmd.SetArgs([]string{"bash"})
6464
Expect(cmd.Execute()).To(Succeed())
65-
Expect(out.String()).To(Equal(`if [ -z "$GCTL_SESSION_ID" ] && [ -z "$TERM_SESSION_ID" ]; then
65+
Expect(out.String()).To(Equal(`export GCTL_SHELL=bash
66+
if [ -z "$GCTL_SESSION_ID" ] && [ -z "$TERM_SESSION_ID" ]; then
6667
export GCTL_SESSION_ID=$(uuidgen)
6768
fi
6869
alias g=gardenctl
@@ -81,7 +82,8 @@ gk
8182
It("should execute the zsh subcommand", func() {
8283
cmd.SetArgs([]string{"zsh"})
8384
Expect(cmd.Execute()).To(Succeed())
84-
Expect(out.String()).To(Equal(`if [ -z "$GCTL_SESSION_ID" ] && [ -z "$TERM_SESSION_ID" ]; then
85+
Expect(out.String()).To(Equal(`export GCTL_SHELL=zsh
86+
if [ -z "$GCTL_SESSION_ID" ] && [ -z "$TERM_SESSION_ID" ]; then
8587
export GCTL_SESSION_ID=$(uuidgen)
8688
fi
8789
alias g=gardenctl
@@ -112,7 +114,8 @@ gk
112114
It("should execute the fish subcommand", func() {
113115
cmd.SetArgs([]string{"fish"})
114116
Expect(cmd.Execute()).To(Succeed())
115-
Expect(out.String()).To(Equal(`if [ -z "$GCTL_SESSION_ID" ] && [ -z "$TERM_SESSION_ID" ];
117+
Expect(out.String()).To(Equal(`set -gx GCTL_SHELL fish
118+
if [ -z "$GCTL_SESSION_ID" ] && [ -z "$TERM_SESSION_ID" ];
116119
set -gx GCTL_SESSION_ID (uuidgen)
117120
end
118121
alias g=gardenctl
@@ -131,7 +134,8 @@ gk
131134
It("should execute the powershell subcommand", func() {
132135
cmd.SetArgs([]string{"powershell"})
133136
Expect(cmd.Execute()).To(Succeed())
134-
Expect(out.String()).To(Equal(`if ( !(Test-Path Env:GCTL_SESSION_ID) -and !(Test-Path Env:TERM_SESSION_ID) ) {
137+
Expect(out.String()).To(Equal(`$Env:GCTL_SHELL = "powershell"
138+
if ( !(Test-Path Env:GCTL_SESSION_ID) -and !(Test-Path Env:TERM_SESSION_ID) ) {
135139
$Env:GCTL_SESSION_ID = [guid]::NewGuid().ToString()
136140
}
137141
Set-Alias -Name g -Value (get-command gardenctl).Path -Option AllScope -Force
@@ -229,5 +233,39 @@ gk
229233
options.Prefix = "!"
230234
Expect(options.Validate()).To(MatchError("prefix must start with an alphabetic character may be followed by alphanumeric characters, underscore or dash"))
231235
})
236+
It("should return an error when the prefix is too long", func() {
237+
options.Prefix = "verylongprefixthatiswaymorethan32characters"
238+
Expect(options.Validate()).To(MatchError("prefix is too long, maximum length is 32 characters"))
239+
})
240+
241+
It("should allow valid prefix with alphanumeric characters", func() {
242+
options.Prefix = "gctl123"
243+
Expect(options.Validate()).To(Succeed())
244+
})
245+
246+
It("should allow valid prefix with underscore", func() {
247+
options.Prefix = "g_ctl"
248+
Expect(options.Validate()).To(Succeed())
249+
})
250+
251+
It("should allow valid prefix with dash", func() {
252+
options.Prefix = "g-ctl"
253+
Expect(options.Validate()).To(Succeed())
254+
})
255+
256+
It("should return an error when prefix starts with a number", func() {
257+
options.Prefix = "1gctl"
258+
Expect(options.Validate()).To(MatchError("prefix must start with an alphabetic character may be followed by alphanumeric characters, underscore or dash"))
259+
})
260+
261+
It("should return an error when prefix starts with underscore", func() {
262+
options.Prefix = "_gctl"
263+
Expect(options.Validate()).To(MatchError("prefix must start with an alphabetic character may be followed by alphanumeric characters, underscore or dash"))
264+
})
265+
266+
It("should return an error when prefix starts with dash", func() {
267+
options.Prefix = "-gctl"
268+
Expect(options.Validate()).To(MatchError("prefix must start with an alphabetic character may be followed by alphanumeric characters, underscore or dash"))
269+
})
232270
})
233271
})

0 commit comments

Comments
 (0)