diff --git a/pkg/util/exec/format.go b/pkg/util/exec/format.go new file mode 100644 index 000000000..643e5e9d7 --- /dev/null +++ b/pkg/util/exec/format.go @@ -0,0 +1,65 @@ +/* +Copyright 2026 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package exec + +import "strings" + +// safeArgChars are the non-alphanumeric characters a POSIX shell passes through +// literally, matching the set used by Python's shlex.quote. +const safeArgChars = "@%+=:,./-_" + +// FormatCommand renders a command and its arguments as a single line that can be +// pasted into a POSIX shell. Arguments are passed to the kernel as a vector, so +// joining them with plain spaces loses the boundaries between them: an argument +// containing whitespace or a shell metacharacter reads back as several arguments, +// or as several commands. +func FormatCommand(command string, arg ...string) string { + quoted := make([]string, 0, len(arg)+1) + quoted = append(quoted, quoteArg(command)) + for _, a := range arg { + quoted = append(quoted, quoteArg(a)) + } + + return strings.Join(quoted, " ") +} + +// quoteArg single-quotes arg if a shell would not read it back verbatim. +func quoteArg(arg string) string { + if arg == "" { + return "''" + } + if !needsQuoting(arg) { + return arg + } + + // A single quote cannot be escaped inside single quotes; the quoted run has to + // be closed, the quote emitted outside it, and a new run opened. + return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" +} + +func needsQuoting(arg string) bool { + for _, r := range arg { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case strings.ContainsRune(safeArgChars, r): + default: + return true + } + } + + return false +} diff --git a/pkg/util/exec/format_test.go b/pkg/util/exec/format_test.go new file mode 100644 index 000000000..ca0819ea0 --- /dev/null +++ b/pkg/util/exec/format_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2026 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package exec + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_quoteArg(t *testing.T) { + tests := []struct { + name string + arg string + want string + }{ + {"empty", "", "''"}, + {"plain word", "radosgw-admin", "radosgw-admin"}, + {"flag", "--rgw-realm=ceph-objectstore", "--rgw-realm=ceph-objectstore"}, + {"path", "/var/lib/rook/rook-ceph/rook-ceph.config", "/var/lib/rook/rook-ceph/rook-ceph.config"}, + {"space", "RGW Admin Ops User", "'RGW Admin Ops User'"}, + {"semicolon and glob", "buckets=*;users=*", "'buckets=*;users=*'"}, + {"command substitution", "$(id)", "'$(id)'"}, + {"backtick", "`id`", "'`id`'"}, + {"tilde", "~/keyring", "'~/keyring'"}, + {"pipe", "a|b", "'a|b'"}, + {"newline", "a\nb", "'a\nb'"}, + {"single quote", "it's", `'it'\''s'`}, + {"only a single quote", "'", `''\'''`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, quoteArg(tt.arg)) + }) + } +} + +func TestFormatCommand(t *testing.T) { + t.Run("no arguments", func(t *testing.T) { + assert.Equal(t, "ceph", FormatCommand("ceph")) + }) + + t.Run("empty argument is not swallowed", func(t *testing.T) { + assert.Equal(t, "ceph '' status", FormatCommand("ceph", "", "status")) + }) + + t.Run("rgw admin ops user", func(t *testing.T) { + got := FormatCommand("radosgw-admin", + "user", "create", + "--uid", "rgw-admin-ops-user", + "--display-name", "RGW Admin Ops User", + "--caps", "accounts=*;buckets=*;users=*;usage=read;metadata=read;zone=read", + "--rgw-realm=ceph-objectstore", + "--cluster=rook-ceph", + "--conf=/var/lib/rook/rook-ceph/rook-ceph.config", + "--name=client.admin", + "--keyring=/var/lib/rook/rook-ceph/client.admin.keyring", + ) + + assert.Equal(t, "radosgw-admin user create "+ + "--uid rgw-admin-ops-user "+ + "--display-name 'RGW Admin Ops User' "+ + "--caps 'accounts=*;buckets=*;users=*;usage=read;metadata=read;zone=read' "+ + "--rgw-realm=ceph-objectstore "+ + "--cluster=rook-ceph "+ + "--conf=/var/lib/rook/rook-ceph/rook-ceph.config "+ + "--name=client.admin "+ + "--keyring=/var/lib/rook/rook-ceph/client.admin.keyring", got) + }) +}