e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
479 lines
12 KiB
Go
479 lines
12 KiB
Go
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
|
|
//go:build !windows
|
|
|
|
// 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 runtime
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/isolation"
|
|
)
|
|
|
|
// stubIsolator returns Available=true but Wrap is a no-op (for happy-path tests).
|
|
type stubIsolator struct {
|
|
available bool
|
|
caps isolation.Capabilities
|
|
}
|
|
|
|
func (s *stubIsolator) Name() string { return "stub" }
|
|
func (s *stubIsolator) Available() bool { return s.available }
|
|
func (s *stubIsolator) Capabilities() isolation.Capabilities { return s.caps }
|
|
func (s *stubIsolator) Wrap(_ *exec.Cmd, _ isolation.WrapOptions) error { return nil }
|
|
|
|
func newStubIsolator() *stubIsolator {
|
|
return &stubIsolator{
|
|
available: true,
|
|
caps: isolation.Capabilities{
|
|
Available: true,
|
|
Isolator: "stub",
|
|
CommitSupported: false,
|
|
DiffSupported: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestRunner(t *testing.T) *IsolatedRunner {
|
|
t.Helper()
|
|
ctrl := NewController("", "")
|
|
mgr, err := isolation.NewUpperManager(t.TempDir(), 8<<30)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &IsolatedRunner{
|
|
ctrl: ctrl,
|
|
isolator: newStubIsolator(),
|
|
upperMgr: mgr,
|
|
}
|
|
}
|
|
|
|
func TestNewIsolatedRunner(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
if runner == nil {
|
|
t.Fatal("runner is nil")
|
|
}
|
|
if !runner.Available() {
|
|
t.Error("runner should be available with stub isolator")
|
|
}
|
|
}
|
|
|
|
func TestCreateIsolatedSession_HappyPath(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
Profile: "strict",
|
|
WorkspacePath: filepath.Join(t.TempDir(), "workspace"),
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatalf("CreateIsolatedSession: %v", err)
|
|
}
|
|
if id == "" {
|
|
t.Error("expected non-empty session ID")
|
|
}
|
|
|
|
// Verify session is tracked.
|
|
s := runner.lookup(id)
|
|
if s == nil {
|
|
t.Fatal("session not found after create")
|
|
}
|
|
if s.opts.Profile != "strict" {
|
|
t.Errorf("profile = %q, want strict", s.opts.Profile)
|
|
}
|
|
|
|
// Clean up.
|
|
if err := runner.DeleteIsolatedSession(id); err != nil {
|
|
t.Errorf("DeleteIsolatedSession: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetIsolatedSession_NotFound(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
_, err := runner.GetIsolatedSession("nonexistent")
|
|
if err != ErrContextNotFound {
|
|
t.Errorf("expected ErrContextNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetIsolatedSession_Found(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
Profile: "balanced",
|
|
WorkspacePath: filepath.Join(t.TempDir(), "ws"),
|
|
WorkspaceMode: "overlay",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
state, err := runner.GetIsolatedSession(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state.Status != "active" {
|
|
t.Errorf("status = %q, want active", state.Status)
|
|
}
|
|
if state.CreatedAt.IsZero() {
|
|
t.Error("CreatedAt is zero")
|
|
}
|
|
|
|
runner.DeleteIsolatedSession(id)
|
|
}
|
|
|
|
func TestDeleteIsolatedSession_NotFound(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
err := runner.DeleteIsolatedSession("nonexistent")
|
|
if err != ErrContextNotFound {
|
|
t.Errorf("expected ErrContextNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteIsolatedSession_Success(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/tmp",
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := runner.DeleteIsolatedSession(id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify removed.
|
|
if s := runner.lookup(id); s != nil {
|
|
t.Error("session should be removed after delete")
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_HappyPath(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
Profile: "strict",
|
|
WorkspacePath: filepath.Join(t.TempDir(), "workspace"),
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// echo should succeed (exit 0).
|
|
err = runner.RunInIsolatedSession(ctx, id, "echo hello", nil, nil)
|
|
if err != nil {
|
|
t.Errorf("RunInIsolatedSession: %v", err)
|
|
}
|
|
|
|
// Verify lastRunAt was updated.
|
|
s := runner.lookup(id)
|
|
if s == nil {
|
|
t.Fatal("session disappeared")
|
|
}
|
|
if s.lastRunAt.Before(s.createdAt) {
|
|
t.Error("lastRunAt should be >= createdAt after run")
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_NotFound(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
ctx := context.Background()
|
|
err := runner.RunInIsolatedSession(ctx, "nonexistent", "echo hi", nil, nil)
|
|
if err != ErrContextNotFound {
|
|
t.Errorf("expected ErrContextNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_ExitCode(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/tmp",
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// bash -c 'exit 42' produces exit code 42 without killing the session.
|
|
err = runner.RunInIsolatedSession(ctx, id, "bash -c 'exit 42'", nil, nil)
|
|
if err == nil {
|
|
t.Error("expected error for non-zero exit code")
|
|
}
|
|
}
|
|
|
|
func TestCapabilities(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
caps := runner.Capabilities()
|
|
if !caps.Available {
|
|
t.Error("caps.Available should be true")
|
|
}
|
|
if caps.Isolator != "stub" {
|
|
t.Errorf("Isolator = %q, want stub", caps.Isolator)
|
|
}
|
|
}
|
|
|
|
func TestIsolatedSessionOptions_Defaults(t *testing.T) {
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/ws",
|
|
}
|
|
if opts.Profile != "" {
|
|
t.Error("Profile should default to empty (controller sets strict)")
|
|
}
|
|
if opts.WorkspaceMode != "" {
|
|
t.Error("WorkspaceMode should default to empty (controller sets overlay)")
|
|
}
|
|
if opts.ShareNet != nil {
|
|
t.Error("ShareNet should default to nil (start defaults to true)")
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_StdoutCallback(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/tmp",
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var lines []string
|
|
onStdout := func(line string) {
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
err = runner.RunInIsolatedSession(ctx, id, "echo hello", nil, onStdout)
|
|
if err != nil {
|
|
t.Fatalf("RunInIsolatedSession: %v", err)
|
|
}
|
|
|
|
if len(lines) != 1 || lines[0] != "hello" {
|
|
t.Errorf("expected [hello], got %v", lines)
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_MultiLine(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/tmp",
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
var lines []string
|
|
onStdout := func(line string) {
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
code := "echo one\necho two\necho three"
|
|
err = runner.RunInIsolatedSession(ctx, id, code, nil, onStdout)
|
|
if err != nil {
|
|
t.Fatalf("RunInIsolatedSession: %v", err)
|
|
}
|
|
|
|
if len(lines) != 3 {
|
|
t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines)
|
|
}
|
|
for i, want := range []string{"one", "two", "three"} {
|
|
if lines[i] != want {
|
|
t.Errorf("line[%d] = %q, want %q", i, lines[i], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_EnvPersistence(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/tmp",
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Run 1: set env var in bash session.
|
|
err = runner.RunInIsolatedSession(ctx, id, "export MY_VAR=hello_from_session", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("run 1: %v", err)
|
|
}
|
|
|
|
// Run 2: echo the env var to verify persistence.
|
|
var lines []string
|
|
onStdout := func(line string) {
|
|
lines = append(lines, line)
|
|
}
|
|
err = runner.RunInIsolatedSession(ctx, id, "echo $MY_VAR", nil, onStdout)
|
|
if err != nil {
|
|
t.Fatalf("run 2: %v", err)
|
|
}
|
|
|
|
if len(lines) != 1 || lines[0] != "hello_from_session" {
|
|
t.Errorf("env not persisted: got %v", lines)
|
|
}
|
|
}
|
|
|
|
func TestRunInIsolatedSession_ConcurrentSessions(t *testing.T) {
|
|
runner := newTestRunner(t)
|
|
|
|
opts := &IsolatedSessionOptions{
|
|
WorkspacePath: "/tmp",
|
|
WorkspaceMode: "rw",
|
|
}
|
|
|
|
id1, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id1)
|
|
|
|
id2, err := runner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer runner.DeleteIsolatedSession(id2)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Set different env vars in each session.
|
|
runner.RunInIsolatedSession(ctx, id1, "export SESSION=one", nil, nil)
|
|
runner.RunInIsolatedSession(ctx, id2, "export SESSION=two", nil, nil)
|
|
|
|
// Read back — each session should have its own value.
|
|
var out1, out2 []string
|
|
runner.RunInIsolatedSession(ctx, id1, "echo $SESSION", nil, func(l string) { out1 = append(out1, l) })
|
|
runner.RunInIsolatedSession(ctx, id2, "echo $SESSION", nil, func(l string) { out2 = append(out2, l) })
|
|
|
|
if len(out1) != 1 || out1[0] != "one" {
|
|
t.Errorf("session 1: expected [one], got %v", out1)
|
|
}
|
|
if len(out2) != 1 || out2[0] != "two" {
|
|
t.Errorf("session 2: expected [two], got %v", out2)
|
|
}
|
|
}
|
|
|
|
// TestValidateBinds_SymlinkBypass verifies that a symlink placed inside an
|
|
// allowed directory cannot be used to smuggle a bind source whose real target
|
|
// lies outside the allowlist.
|
|
func TestValidateBinds_SymlinkBypass(t *testing.T) {
|
|
allowed := t.TempDir()
|
|
outside := t.TempDir()
|
|
|
|
// allowed/link -> outside (a directory outside the allowlist).
|
|
link := filepath.Join(allowed, "link")
|
|
if err := os.Symlink(outside, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := &IsolatedRunner{allowedWritable: []string{allowed}}
|
|
|
|
// A direct path under the allowlist is fine.
|
|
if err := r.validateBinds([]isolation.BindMount{{Source: allowed}}); err != nil {
|
|
t.Errorf("direct allowlisted source should be accepted: %v", err)
|
|
}
|
|
|
|
// The symlink resolves outside the allowlist and must be rejected.
|
|
err := r.validateBinds([]isolation.BindMount{{Source: link}})
|
|
if err == nil {
|
|
t.Fatal("expected symlinked source resolving outside allowlist to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "not in allowlist") {
|
|
t.Errorf("expected allowlist rejection, got: %v", err)
|
|
}
|
|
|
|
// A symlink whose target stays inside the allowlist is still accepted, and
|
|
// the source is rewritten to the resolved real path so bwrap mounts the
|
|
// resolved target (closing the TOCTOU window).
|
|
innerTarget := filepath.Join(allowed, "real")
|
|
if err := os.Mkdir(innerTarget, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
innerLink := filepath.Join(allowed, "inner")
|
|
if err := os.Symlink(innerTarget, innerLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
binds := []isolation.BindMount{{Source: innerLink}}
|
|
if err := r.validateBinds(binds); err != nil {
|
|
t.Errorf("symlink resolving inside allowlist should be accepted: %v", err)
|
|
}
|
|
wantResolved, _ := filepath.EvalSymlinks(innerLink)
|
|
if binds[0].Source != wantResolved {
|
|
t.Errorf("bind source should be rewritten to resolved path %q, got %q", wantResolved, binds[0].Source)
|
|
}
|
|
|
|
// A non-existent source under the allowlist must be rejected: a missing
|
|
// leaf could otherwise be swapped to an out-of-allowlist symlink between
|
|
// validation and bwrap start.
|
|
missing := filepath.Join(allowed, "does-not-exist-yet")
|
|
err = r.validateBinds([]isolation.BindMount{{Source: missing}})
|
|
if err == nil {
|
|
t.Fatal("expected non-existent bind source to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "must be an existing path") {
|
|
t.Errorf("expected existing-path rejection, got: %v", err)
|
|
}
|
|
}
|