From 3cb66a10fc6841fafb968b8af153f69ec769ec5a Mon Sep 17 00:00:00 2001 From: Derek McGowan Date: Sun, 10 May 2026 15:19:46 -0700 Subject: [PATCH] runc: add CommandFile for fd-based binary execution Add Runc.CommandFile *os.File as a Linux-only alternative to Command. When set, every invocation executes the binary via /proc/self/fd/ rather than by filesystem path, which is semantically equivalent to execveat(fd, "", argv, env, AT_EMPTY_PATH): the binary is identified solely by its open file descriptor, so the original path need not remain accessible after the file is opened. The implementation adds finalizeCommand(), called from startCommand() just before cmd.Start(). It appends CommandFile to cmd.ExtraFiles (so Go's fork+exec inherits the fd) and sets cmd.Path to /proc/self/fd/. The file is always appended last, so any pre-existing ExtraFiles keep their fd positions and --preserve-fds / --status-fd accounting is unaffected. Tests cover basic execution, execution after the binary path is unlinked (the core guarantee), and the fd numbering invariants. Signed-off-by: Derek McGowan --- command_linux.go | 35 ++++++++ command_linux_test.go | 186 ++++++++++++++++++++++++++++++++++++++++++ command_other.go | 4 + runc.go | 15 +++- 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 command_linux_test.go diff --git a/command_linux.go b/command_linux.go index 8a30f67..302c64d 100644 --- a/command_linux.go +++ b/command_linux.go @@ -18,6 +18,7 @@ package runc import ( "context" + "fmt" "os" "os/exec" "strings" @@ -29,7 +30,21 @@ func (r *Runc) command(context context.Context, args ...string) *exec.Cmd { if command == "" { command = DefaultCommand } + if r.CommandFile != nil { + // Use the file's name as argv[0] and for the initial path; it may be + // a path that no longer exists on disk. finalizeCommand replaces + // cmd.Path with /proc/self/fd/ before the process is started. + if name := r.CommandFile.Name(); name != "" { + command = name + } + } cmd := exec.CommandContext(context, command, append(r.args(), args...)...) + if r.CommandFile != nil { + // Suppress any path-lookup error: the binary will be exec'd via + // /proc/self/fd/ (set by finalizeCommand) so accessibility of the + // original path at this point doesn't matter. + cmd.Err = nil + } cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: r.Setpgid, } @@ -41,6 +56,26 @@ func (r *Runc) command(context context.Context, args ...string) *exec.Cmd { return cmd } +// finalizeCommand sets cmd.Path to /proc/self/fd/ and appends +// r.CommandFile to cmd.ExtraFiles so that the runc binary is executed +// from the open file descriptor rather than by filesystem path. This is +// semantically equivalent to execveat(fd, "", argv, env, AT_EMPTY_PATH). +// +// Appending the file last preserves the FD positions of any ExtraFiles +// already present (e.g. for --preserve-fds or --status-fd), which are +// therefore unaffected by use of CommandFile. +// +// Must be called after all modifications to cmd.ExtraFiles are complete +// and before cmd.Start(). startCommand calls it automatically. +func (r *Runc) finalizeCommand(cmd *exec.Cmd) { + if r.CommandFile == nil { + return + } + fdNum := 3 + len(cmd.ExtraFiles) + cmd.ExtraFiles = append(cmd.ExtraFiles, r.CommandFile) + cmd.Path = fmt.Sprintf("/proc/self/fd/%d", fdNum) +} + func filterEnv(in []string, names ...string) []string { out := make([]string, 0, len(in)) loop0: diff --git a/command_linux_test.go b/command_linux_test.go new file mode 100644 index 0000000..8982f37 --- /dev/null +++ b/command_linux_test.go @@ -0,0 +1,186 @@ +/* + Copyright The containerd Authors. + + 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 runc + +import ( + "context" + "os" + "testing" +) + +// TestCommandFileRun verifies that CommandFile can be used in place of +// Command to execute a binary by open file descriptor. +func TestCommandFileRun(t *testing.T) { + ctx := context.Background() + + f, err := os.Open("/bin/true") + if err != nil { + t.Fatalf("open /bin/true: %v", err) + } + defer f.Close() + + r := &Runc{CommandFile: f} + status, err := r.Run(ctx, "fake-id", "fake-bundle", &CreateOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != 0 { + t.Fatalf("want exit 0, got %d", status) + } + + f2, err := os.Open("/bin/false") + if err != nil { + t.Fatalf("open /bin/false: %v", err) + } + defer f2.Close() + + r2 := &Runc{CommandFile: f2} + status, err = r2.Run(ctx, "fake-id", "fake-bundle", &CreateOpts{}) + if err == nil { + t.Fatal("expected non-nil error from /bin/false, got nil") + } + if status != 1 { + t.Fatalf("want exit 1, got %d", status) + } +} + +// TestCommandFileRunAfterUnlink is the key "path never needed" test: it copies +// a binary to a temporary file, opens it, removes the path, then verifies that +// execution still succeeds via the open file descriptor alone. This mirrors +// the execveat(fd, "", argv, env, AT_EMPTY_PATH) semantics. +func TestCommandFileRunAfterUnlink(t *testing.T) { + tmp, err := copyBinary(t, "/bin/true") + if err != nil { + t.Fatalf("copy /bin/true: %v", err) + } + + f, err := os.Open(tmp) + if err != nil { + t.Fatalf("open temp binary: %v", err) + } + defer f.Close() + + // Remove the path — the binary is now only reachable through the FD. + if err := os.Remove(tmp); err != nil { + t.Fatalf("remove temp binary: %v", err) + } + + ctx := context.Background() + r := &Runc{CommandFile: f} + status, err := r.Run(ctx, "fake-id", "fake-bundle", &CreateOpts{}) + if err != nil { + t.Fatalf("unexpected error after unlink: %v", err) + } + if status != 0 { + t.Fatalf("want exit 0 after unlink, got %d", status) + } +} + +// TestFinalizeCommandFDNumbering verifies that finalizeCommand appends the +// binary FD after any pre-existing ExtraFiles, keeping their FD positions +// intact. This ensures --preserve-fds and --status-fd accounting is correct. +func TestFinalizeCommandFDNumbering(t *testing.T) { + f, err := os.Open("/bin/true") + if err != nil { + t.Fatalf("open /bin/true: %v", err) + } + defer f.Close() + + r := &Runc{CommandFile: f} + cmd := r.command(context.Background(), "run") + + // Simulate two pre-existing ExtraFiles (e.g. from --preserve-fds). + d1, err := os.Open("/dev/null") + if err != nil { + t.Fatalf("open /dev/null: %v", err) + } + defer d1.Close() + d2, err := os.Open("/dev/null") + if err != nil { + t.Fatalf("open /dev/null: %v", err) + } + defer d2.Close() + cmd.ExtraFiles = []*os.File{d1, d2} + + r.finalizeCommand(cmd) + + // d1 → FD 3, d2 → FD 4, binary → FD 5. + const wantPath = "/proc/self/fd/5" + if cmd.Path != wantPath { + t.Errorf("cmd.Path = %q; want %q", cmd.Path, wantPath) + } + if n := len(cmd.ExtraFiles); n != 3 { + t.Errorf("len(ExtraFiles) = %d; want 3", n) + } + if cmd.ExtraFiles[2] != f { + t.Error("CommandFile not appended last to ExtraFiles") + } +} + +// TestFinalizeCommandNoExtraFiles verifies that finalizeCommand places the +// binary at FD 3 when there are no other ExtraFiles. +func TestFinalizeCommandNoExtraFiles(t *testing.T) { + f, err := os.Open("/bin/true") + if err != nil { + t.Fatalf("open /bin/true: %v", err) + } + defer f.Close() + + r := &Runc{CommandFile: f} + cmd := r.command(context.Background(), "run") + + r.finalizeCommand(cmd) + + const wantPath = "/proc/self/fd/3" + if cmd.Path != wantPath { + t.Errorf("cmd.Path = %q; want %q", cmd.Path, wantPath) + } + if n := len(cmd.ExtraFiles); n != 1 { + t.Errorf("len(ExtraFiles) = %d; want 1", n) + } + if cmd.ExtraFiles[0] != f { + t.Error("CommandFile not placed at ExtraFiles[0]") + } +} + +// copyBinary copies the ELF binary at src to a fresh temporary file and +// marks it executable. The caller is responsible for cleanup. +func copyBinary(t *testing.T, src string) (string, error) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + return "", err + } + tmp, err := os.CreateTemp("", "runc-cmdfile-test-*") + if err != nil { + return "", err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return "", err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return "", err + } + if err := os.Chmod(tmp.Name(), 0o755); err != nil { + os.Remove(tmp.Name()) + return "", err + } + return tmp.Name(), nil +} diff --git a/command_other.go b/command_other.go index a4adbe1..cbbd550 100644 --- a/command_other.go +++ b/command_other.go @@ -33,3 +33,7 @@ func (r *Runc) command(context context.Context, args ...string) *exec.Cmd { cmd.Env = os.Environ() return cmd } + +// finalizeCommand is a no-op on non-Linux platforms. +// CommandFile-based execution via /proc/self/fd/ is only supported on Linux. +func (r *Runc) finalizeCommand(_ *exec.Cmd) {} diff --git a/runc.go b/runc.go index 61646df..255db8e 100644 --- a/runc.go +++ b/runc.go @@ -61,7 +61,7 @@ var DefaultCommand = "runc" // Runc is the client to the runc cli type Runc struct { // Command overrides the name of the runc binary. If empty, DefaultCommand - // is used. + // is used. On Linux, CommandFile takes precedence over this field when set. Command string Root string Debug bool @@ -91,6 +91,18 @@ type Runc struct { SystemdCgroup bool Rootless *bool // nil stands for "auto" ExtraArgs []string + + // CommandFile is an open file for the runc binary. On Linux, when set, + // each invocation executes the binary via /proc/self/fd/ so the + // original file path need not remain accessible after the file is opened. + // This is semantically equivalent to execveat(fd, "", argv, env, + // AT_EMPTY_PATH): the binary is identified solely by its open file + // descriptor, not by any filesystem path. + // + // CommandFile takes precedence over Command and DefaultCommand on Linux. + // The caller is responsible for keeping the file open for the lifetime of + // the Runc instance. Ignored on non-Linux platforms. + CommandFile *os.File } // List returns all containers created inside the provided runc root directory @@ -170,6 +182,7 @@ func (o *CreateOpts) args() (out []string, err error) { } func (r *Runc) startCommand(cmd *exec.Cmd) (chan Exit, error) { + r.finalizeCommand(cmd) if r.PdeathSignal != 0 { return Monitor.StartLocked(cmd) }