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) }