diff --git a/pkg/unikontainers/unikernels/rumprun.go b/pkg/unikontainers/unikernels/rumprun.go index 403fdb501..07c4801a4 100644 --- a/pkg/unikontainers/unikernels/rumprun.go +++ b/pkg/unikontainers/unikernels/rumprun.go @@ -15,6 +15,7 @@ package unikernels import ( + "bytes" "encoding/json" "fmt" "strings" @@ -26,7 +27,7 @@ const RumprunUnikernel string = "rumprun" const SubnetMask125 = "128.0.0.0" type Rumprun struct { - Command string + CmdLine []string Monitor string Envs []string Net RumprunNet @@ -37,6 +38,27 @@ type RumprunCmd struct { CmdLine string `json:"cmdline"` } +// rumprunRc is one entry of the rumprun configuration's "rc" list: bin is +// passed to the program as argv[0] and every element of argv as its own +// argument, so argument boundaries survive (doc/config.md). +type rumprunRc struct { + Bin string `json:"bin"` + Argv []string `json:"argv"` +} + +// marshalRumprunJSON marshals without HTML escaping. The guest parses the +// configuration with jsmn and keeps string bytes as they appear, without +// decoding \uXXXX escapes, so &, < and > must stay raw. +func marshalRumprunJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + type RumprunEnv struct { Env string `json:"env"` } @@ -68,12 +90,28 @@ func (r *Rumprun) CommandString() (string, error) { envJSONString := "" netJSONString := "" blkJSONString := "" - cmd := RumprunCmd{ - CmdLine: r.Command, - } - cmdJSON, err := json.Marshal(cmd) - if err != nil { - return "", fmt.Errorf("could not marshal cmdline: %v", err) + var cmdJSON []byte + var err error + if len(r.CmdLine) == 0 { + // Without a command there is nothing to describe in rc. Keep the + // empty cmdline of the old format, which lets the guest run its + // first baked program without arguments. + cmdJSON, err = marshalRumprunJSON(RumprunCmd{CmdLine: ""}) + if err != nil { + return "", fmt.Errorf("could not marshal cmdline: %v", err) + } + } else { + // The old "cmdline" format is one whitespace-delimited string, which + // cannot tell an argument that contains a space from two arguments + // and drops empty arguments. "rc" keeps every argv element intact. + cmdJSON, err = marshalRumprunJSON(struct { + Rc []rumprunRc `json:"rc"` + }{ + Rc: []rumprunRc{{Bin: r.CmdLine[0], Argv: r.CmdLine[1:]}}, + }) + if err != nil { + return "", fmt.Errorf("could not marshal rc: %v", err) + } } cmdJSONString = string(cmdJSON) for i, eVar := range r.Envs { @@ -221,7 +259,7 @@ func (r *Rumprun) Init(data types.UnikernelParams) error { r.Blk.Source = "" } - r.Command = strings.Join(data.CmdLine, " ") + r.CmdLine = data.CmdLine r.Monitor = data.Monitor r.Envs = data.EnvVars diff --git a/pkg/unikontainers/unikernels/rumprun_test.go b/pkg/unikontainers/unikernels/rumprun_test.go new file mode 100644 index 000000000..d80fce4b9 --- /dev/null +++ b/pkg/unikontainers/unikernels/rumprun_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// 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 unikernels + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" +) + +// The guest splits a "cmdline" value on whitespace (config.c's makeargv and +// rumprun_parseargs), so arguments that contain spaces and empty arguments +// cannot be expressed in that form. The "rc" form keeps every argv element +// intact: config.c's addbin copies each array element as its own string. +func TestRumprunCommandStringPreservesArgumentBoundaries(t *testing.T) { + t.Parallel() + + rumprun := &Rumprun{ + CmdLine: []string{"redis-server", "--requirepass", "a b", "--logfile", ""}, + Envs: []string{"REDIS_CONF=/etc/redis.conf"}, + } + + commandString, err := rumprun.CommandString() + require.NoError(t, err) + + // The result is not valid JSON as a whole (the multiple "env" keys), so + // check the rc member against its exact serialization. + assert.Contains(t, commandString, + `"rc":[{"bin":"redis-server","argv":["--requirepass","a b","--logfile",""]}]`, + "every argument must stay one element, including the spaced value and the empty one") + + var parsed struct { + Rc []struct { + Bin string `json:"bin"` + Argv []string `json:"argv"` + } `json:"rc"` + } + require.NoError(t, json.Unmarshal([]byte(commandString), &parsed)) + require.Len(t, parsed.Rc, 1) + assert.Equal(t, "redis-server", parsed.Rc[0].Bin) + assert.Equal(t, []string{"--requirepass", "a b", "--logfile", ""}, parsed.Rc[0].Argv) +} + +// The guest's token2cstr keeps string bytes as they appear, without decoding +// \uXXXX escapes, so the configuration must not escape &, < or >. +func TestRumprunCommandStringKeepsHTMLEScapeCharactersRaw(t *testing.T) { + t.Parallel() + + rumprun := &Rumprun{ + CmdLine: []string{"sh", "-c", "echo ad"}, + } + + commandString, err := rumprun.CommandString() + require.NoError(t, err) + + assert.Contains(t, commandString, `"argv":["-c","echo ad"]`) + assert.NotContains(t, commandString, `u003`, "the guest cannot decode \\uXXXX escapes") +} + +// An empty command line has no rc entry to describe; it keeps the old empty +// cmdline, which lets the guest run its first baked program without arguments. +func TestRumprunCommandStringEmptyCmdLineKeepsEmptyCmdline(t *testing.T) { + t.Parallel() + + rumprun := &Rumprun{CmdLine: nil} + + commandString, err := rumprun.CommandString() + require.NoError(t, err) + + assert.Contains(t, commandString, `"cmdline":""`) + assert.NotContains(t, commandString, `"rc"`) +} + +// Init must hand the whole argument list to CommandString instead of +// collapsing it into one string first. +func TestRumprunInitKeepsCmdLineUnjoined(t *testing.T) { + t.Parallel() + + rumprun := newRumprun() + err := rumprun.Init(types.UnikernelParams{ + CmdLine: []string{"app", "one arg", "another"}, + Monitor: "hvt", + }) + require.NoError(t, err) + assert.Equal(t, []string{"app", "one arg", "another"}, rumprun.CmdLine) +}