From 750c239f46a56e6e781dc9202054d768b45619e4 Mon Sep 17 00:00:00 2001 From: Adwitiya Date: Fri, 10 Apr 2026 17:03:29 +0530 Subject: [PATCH] feat: add PRoot provider + ARM64 build support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRoot provider enables ForgeVM on Android, Raspberry Pi, Chromebooks, and restricted environments — no root, no kernel modules, no Docker. - PRoot provider: all 16 Provider interface methods, path traversal protection, max sandbox limits, 19 tests (unit + integration) - Environment auto-detection: Android/Docker/Firecracker/PRoot with "auto" default provider option - ARM64 static build: Makefile targets, GitHub Actions release pipeline, install.sh architecture support - ARM64 rootfs builder: Alpine aarch64 with Python/Node/Bash - Docker image: add NODE_PATH for global npm module resolution --- .github/workflows/release.yml | 16 +- Makefile | 18 +- cmd/forgevm/cmd_serve.go | 32 ++ images/python-sandbox/Dockerfile | 161 +++++++-- internal/api/routes/providers.go | 6 + internal/config/config.go | 23 ++ internal/config/detect.go | 75 ++++ internal/config/detect_test.go | 45 +++ internal/providers/proot.go | 552 +++++++++++++++++++++++++++++ internal/providers/proot_test.go | 573 +++++++++++++++++++++++++++++++ scripts/build-rootfs-arm64.sh | 72 ++++ scripts/install.sh | 27 +- 12 files changed, 1568 insertions(+), 32 deletions(-) create mode 100644 internal/config/detect.go create mode 100644 internal/config/detect_test.go create mode 100644 internal/providers/proot.go create mode 100644 internal/providers/proot_test.go create mode 100755 scripts/build-rootfs-arm64.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7469341..545d2e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,8 +45,20 @@ jobs: go build -ldflags="-s -w" -o forgevm-agent-linux-amd64 ./cmd/forgevm-agent + - name: Build forgevm (arm64) + run: > + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 + go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" + -o forgevm-linux-arm64 ./cmd/forgevm + + - name: Build forgevm-agent (arm64) + run: > + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 + go build -ldflags="-s -w" + -o forgevm-agent-linux-arm64 ./cmd/forgevm-agent + - name: Generate checksums - run: sha256sum forgevm-linux-amd64 forgevm-agent-linux-amd64 > checksums.txt + run: sha256sum forgevm-linux-amd64 forgevm-agent-linux-amd64 forgevm-linux-arm64 forgevm-agent-linux-arm64 > checksums.txt - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -56,4 +68,6 @@ jobs: files: | forgevm-linux-amd64 forgevm-agent-linux-amd64 + forgevm-linux-arm64 + forgevm-agent-linux-arm64 checksums.txt diff --git a/Makefile b/Makefile index fac0b8b..ae58d32 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-agent test lint clean serve dev release-build +.PHONY: build build-agent build-android build-agent-arm64 test lint clean serve dev release-build release-build-all VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) @@ -10,6 +10,14 @@ build: build-agent: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/forgevm-agent ./cmd/forgevm-agent +# Build for Android/ARM64 (static) +build-android: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o dist/forgevm-linux-arm64 ./cmd/forgevm + +# Build the guest agent (static, linux/arm64) +build-agent-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bin/forgevm-agent-arm64 ./cmd/forgevm-agent + # Build everything build-all: build build-agent @@ -38,8 +46,14 @@ clean: lint: go vet ./... -# Build static release binaries + checksums +# Build static release binaries + checksums (amd64 only) release-build: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o forgevm-linux-amd64 ./cmd/forgevm CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o forgevm-agent-linux-amd64 ./cmd/forgevm-agent sha256sum forgevm-linux-amd64 forgevm-agent-linux-amd64 > checksums.txt + +# Build release binaries for all architectures (amd64 + arm64) +release-build-all: release-build + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o forgevm-linux-arm64 ./cmd/forgevm + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o forgevm-agent-linux-arm64 ./cmd/forgevm-agent + sha256sum forgevm-linux-amd64 forgevm-agent-linux-amd64 forgevm-linux-arm64 forgevm-agent-linux-arm64 > checksums.txt diff --git a/cmd/forgevm/cmd_serve.go b/cmd/forgevm/cmd_serve.go index e5d92a0..c9bb24a 100644 --- a/cmd/forgevm/cmd_serve.go +++ b/cmd/forgevm/cmd_serve.go @@ -124,6 +124,38 @@ func runServe() error { Msg("docker provider registered") } } + if cfg.Providers.PRoot.Enabled { + timeout, _ := time.ParseDuration(cfg.Providers.PRoot.DefaultTimeout) + if timeout == 0 { + timeout = 60 * time.Second + } + proot := providers.NewPRootProvider(providers.PRootProviderConfig{ + RootfsPath: cfg.Providers.PRoot.RootfsPath, + PRootBinary: cfg.Providers.PRoot.PRootBinary, + WorkspaceBase: cfg.Providers.PRoot.WorkspaceBase, + DefaultTimeout: timeout, + MaxSandboxes: cfg.Providers.PRoot.MaxSandboxes, + MaxMemoryMB: cfg.Providers.PRoot.MaxMemoryMB, + MaxDiskMB: cfg.Providers.PRoot.MaxDiskMB, + Languages: cfg.Providers.PRoot.Languages, + }, logger) + registry.Register(proot) + logger.Info(). + Str("rootfs", cfg.Providers.PRoot.RootfsPath). + Str("binary", cfg.Providers.PRoot.PRootBinary). + Msg("proot provider registered") + } + + // Auto-detect provider if set to "auto" + if cfg.Providers.Default == "auto" { + env := config.DetectEnvironment() + cfg.Providers.Default = config.DefaultProviderForEnv(env) + logger.Info(). + Str("environment", env.String()). + Str("provider", cfg.Providers.Default). + Msg("auto-detected default provider") + } + if err := registry.SetDefault(cfg.Providers.Default); err != nil { logger.Warn().Err(err).Msg("setting default provider") } diff --git a/images/python-sandbox/Dockerfile b/images/python-sandbox/Dockerfile index 16ce93c..ff00575 100644 --- a/images/python-sandbox/Dockerfile +++ b/images/python-sandbox/Dockerfile @@ -1,28 +1,149 @@ FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive + +# ── System packages + CLI tools ── RUN apt-get update && apt-get install -y \ - python3 \ - python3-pip \ - python3-venv \ - curl \ - wget \ - git \ - zip \ - unzip \ - jq \ - vim \ - nano \ - htop \ - net-tools \ - iputils-ping \ - build-essential \ + # Core + python3 python3-pip python3-venv python3-dev \ + curl wget git zip unzip jq vim nano htop \ + net-tools iputils-ping build-essential pkg-config \ + # PDF CLI tools + poppler-utils qpdf pdftk-java pandoc \ + # OCR + tesseract-ocr tesseract-ocr-eng \ + # Image/Media + ffmpeg imagemagick inkscape \ + # LibreOffice + libreoffice-calc libreoffice-writer libreoffice-impress libreoffice-draw \ + # Java (for tabula-py, pdftk) + default-jre-headless \ + # C/C++ + gcc g++ \ + # SQLite + sqlite3 libsqlite3-dev \ + # Graphviz + graphviz libgraphviz-dev \ + # Image libs (for Pillow, Wand, sharp) + libmagickwand-dev libfreetype-dev libjpeg-dev libpng-dev libtiff-dev \ + libwebp-dev zlib1g-dev libffi-dev libssl-dev \ + # Chromium (for Playwright) + chromium-browser \ + # Sound + libasound2-dev portaudio19-dev \ + && rm -rf /var/lib/apt/lists/* + +# ── Rust/Cargo ── +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +# ── Node.js 22.x ── +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install --no-cache-dir --break-system-packages \ +# ── Python packages (--ignore-installed to override deb-managed packages) ── +RUN pip3 install --no-cache-dir --break-system-packages --ignore-installed \ + # Data / Science + pandas==3.0.1 \ + scipy \ + scikit-learn \ + scikit-image \ + # Visualization + matplotlib \ + seaborn \ + # Excel / Office + openpyxl==3.1.5 \ + xlsxwriter \ python-docx \ - python-pptx \ - reportlab \ - pandas \ - numpy + python-pptx==1.0.2 \ + odfpy \ + # PDF + pypdf==5.9.0 \ + pypdfium2==5.6.0 \ + pdfplumber==0.11.9 \ + pdfminer.six \ + pdf2image==1.17.0 \ + pdfkit \ + pikepdf \ + img2pdf \ + reportlab==4.4.10 \ + camelot-py[cv] \ + tabula-py \ + # Web / Scraping + flask \ + requests \ + beautifulsoup4 \ + playwright \ + grip \ + # CV / ML + opencv-python-headless \ + mediapipe \ + onnxruntime \ + flatbuffers \ + # Image + Pillow==12.1.1 \ + Wand \ + imageio \ + tifffile \ + freetype-py \ + # OCR + pytesseract==0.3.13 \ + # Markdown / Docs + markitdown==0.1.5 \ + markdown \ + mkdocs \ + mkdocs-material \ + mistune \ + # Utils + lxml \ + sympy \ + networkx \ + graphviz \ + psutil \ + cryptography \ + sounddevice \ + # Misc + Jinja2 \ + PyYAML \ + click \ + tabulate + +# Install Playwright browsers +RUN playwright install chromium + +# ── Node.js global packages ── +RUN npm install -g \ + # Documents + docx@9.6.1 \ + pptxgenjs@4.0.1 \ + pdf-lib \ + pdfjs-dist \ + # Mermaid + @mermaid-js/mermaid-cli \ + # Markdown + markdown-pdf \ + marked \ + markdown-toc \ + markdownlint-cli \ + markdownlint-cli2 \ + remark-cli \ + # React + react@19 \ + react-dom@19 \ + react-icons \ + # Image + sharp \ + # Browser automation + playwright \ + # TypeScript + typescript@6 \ + ts-node \ + tsx \ + # Graph + graphviz + +# Make global npm packages resolvable by require() +ENV NODE_PATH=/usr/lib/node_modules WORKDIR /workspace diff --git a/internal/api/routes/providers.go b/internal/api/routes/providers.go index 1de0936..bd031ca 100644 --- a/internal/api/routes/providers.go +++ b/internal/api/routes/providers.go @@ -135,6 +135,12 @@ func (p *ProviderRoutes) Detail(w http.ResponseWriter, r *http.Request) { cfg["type"] = "firecracker" case *providers.MockProvider: cfg["type"] = "mock" + case *providers.PRootProvider: + c := v.ProviderConfig() + cfg["rootfs_path"] = c.RootfsPath + cfg["proot_binary"] = c.PRootBinary + cfg["workspace_base"] = c.WorkspaceBase + cfg["type"] = "proot" default: cfg["type"] = "other" } diff --git a/internal/config/config.go b/internal/config/config.go index de433c9..e7e20e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,19 @@ type ProvidersConfig struct { Docker DockerConfig `mapstructure:"docker"` E2B E2BConfig `mapstructure:"e2b"` Custom CustomConfig `mapstructure:"custom"` + PRoot PRootConfig `mapstructure:"proot"` +} + +type PRootConfig struct { + Enabled bool `mapstructure:"enabled"` + RootfsPath string `mapstructure:"rootfs_path"` + PRootBinary string `mapstructure:"proot_binary"` + WorkspaceBase string `mapstructure:"workspace_base"` + DefaultTimeout string `mapstructure:"default_timeout"` + MaxSandboxes int `mapstructure:"max_sandboxes"` + MaxMemoryMB int `mapstructure:"max_memory_mb"` + MaxDiskMB int `mapstructure:"max_disk_mb"` + Languages []string `mapstructure:"languages"` } type DockerConfig struct { @@ -163,6 +176,16 @@ func setDefaults(v *viper.Viper) { v.SetDefault("providers.docker.pool_security.workspace_permissions", true) v.SetDefault("providers.docker.pool_security.hidepid", false) + v.SetDefault("providers.proot.enabled", false) + v.SetDefault("providers.proot.proot_binary", "proot") + v.SetDefault("providers.proot.rootfs_path", "/var/lib/forgevm/rootfs") + v.SetDefault("providers.proot.workspace_base", "/var/lib/forgevm/workspaces") + v.SetDefault("providers.proot.default_timeout", "60s") + v.SetDefault("providers.proot.max_sandboxes", 10) + v.SetDefault("providers.proot.max_memory_mb", 512) + v.SetDefault("providers.proot.max_disk_mb", 1024) + v.SetDefault("providers.proot.languages", []string{"python3", "node", "bash"}) + v.SetDefault("defaults.ttl", "30m") v.SetDefault("defaults.image", "alpine:latest") v.SetDefault("defaults.memory_mb", 1024) diff --git a/internal/config/detect.go b/internal/config/detect.go new file mode 100644 index 0000000..94610de --- /dev/null +++ b/internal/config/detect.go @@ -0,0 +1,75 @@ +package config + +import ( + "os" + "os/exec" +) + +// Environment represents the detected runtime environment. +type Environment int + +const ( + EnvUnknown Environment = iota + EnvAndroid // Android device (has /system/build.prop) + EnvDocker // Docker daemon available + EnvFirecracker // Firecracker binary available + EnvPRoot // PRoot binary available +) + +// String returns the environment name. +func (e Environment) String() string { + switch e { + case EnvAndroid: + return "android" + case EnvDocker: + return "docker" + case EnvFirecracker: + return "firecracker" + case EnvPRoot: + return "proot" + default: + return "unknown" + } +} + +// DetectEnvironment probes the runtime to determine the best provider. +// Detection order: Android > Firecracker > Docker > PRoot > Unknown. +func DetectEnvironment() Environment { + // Android: presence of /system/build.prop + if _, err := os.Stat("/system/build.prop"); err == nil { + return EnvAndroid + } + + // Firecracker: binary in PATH + if _, err := exec.LookPath("firecracker"); err == nil { + return EnvFirecracker + } + + // Docker: binary in PATH + if _, err := exec.LookPath("docker"); err == nil { + return EnvDocker + } + + // PRoot: binary in PATH + if _, err := exec.LookPath("proot"); err == nil { + return EnvPRoot + } + + return EnvUnknown +} + +// DefaultProviderForEnv returns the recommended default provider for an environment. +func DefaultProviderForEnv(env Environment) string { + switch env { + case EnvAndroid: + return "proot" + case EnvFirecracker: + return "firecracker" + case EnvDocker: + return "docker" + case EnvPRoot: + return "proot" + default: + return "mock" + } +} diff --git a/internal/config/detect_test.go b/internal/config/detect_test.go new file mode 100644 index 0000000..77e723b --- /dev/null +++ b/internal/config/detect_test.go @@ -0,0 +1,45 @@ +package config + +import "testing" + +func TestEnvironment_String(t *testing.T) { + tests := []struct { + env Environment + want string + }{ + {EnvUnknown, "unknown"}, + {EnvAndroid, "android"}, + {EnvDocker, "docker"}, + {EnvFirecracker, "firecracker"}, + {EnvPRoot, "proot"}, + } + for _, tt := range tests { + if got := tt.env.String(); got != tt.want { + t.Errorf("Environment(%d).String() = %q, want %q", tt.env, got, tt.want) + } + } +} + +func TestDefaultProviderForEnv(t *testing.T) { + tests := []struct { + env Environment + want string + }{ + {EnvAndroid, "proot"}, + {EnvFirecracker, "firecracker"}, + {EnvDocker, "docker"}, + {EnvPRoot, "proot"}, + {EnvUnknown, "mock"}, + } + for _, tt := range tests { + if got := DefaultProviderForEnv(tt.env); got != tt.want { + t.Errorf("DefaultProviderForEnv(%s) = %q, want %q", tt.env, got, tt.want) + } + } +} + +func TestDetectEnvironment_DoesNotPanic(t *testing.T) { + // Just verify it runs without panicking on the current system + env := DetectEnvironment() + _ = env.String() // ensure String() works on the detected value +} diff --git a/internal/providers/proot.go b/internal/providers/proot.go new file mode 100644 index 0000000..3879b44 --- /dev/null +++ b/internal/providers/proot.go @@ -0,0 +1,552 @@ +package providers + +import ( + "bytes" + "context" + "crypto/rand" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +// PRootProviderConfig holds configuration for the PRoot provider. +type PRootProviderConfig struct { + RootfsPath string + PRootBinary string + WorkspaceBase string + DefaultTimeout time.Duration + MaxSandboxes int + MaxMemoryMB int + MaxDiskMB int + Languages []string +} + +type prootSandbox struct { + id string + workspace string + state string + opts SpawnOptions + createdAt time.Time +} + +// PRootProvider implements Provider using PRoot for userspace Linux isolation. +// No root, no kernel modules, no Docker daemon required. +// Primary target: Android, also works on Raspberry Pi, Chromebooks, restricted VPS. +type PRootProvider struct { + mu sync.RWMutex + sandboxes map[string]*prootSandbox + config PRootProviderConfig + logger zerolog.Logger +} + +// NewPRootProvider creates a new PRoot provider. +func NewPRootProvider(cfg PRootProviderConfig, logger zerolog.Logger) *PRootProvider { + if cfg.PRootBinary == "" { + cfg.PRootBinary = "proot" + } + if cfg.DefaultTimeout == 0 { + cfg.DefaultTimeout = 60 * time.Second + } + if cfg.MaxSandboxes == 0 { + cfg.MaxSandboxes = 10 + } + return &PRootProvider{ + sandboxes: make(map[string]*prootSandbox), + config: cfg, + logger: logger.With().Str("provider", "proot").Logger(), + } +} + +func (p *PRootProvider) Name() string { return "proot" } + +// ProviderConfig returns the provider configuration for API exposure. +func (p *PRootProvider) ProviderConfig() PRootProviderConfig { + return p.config +} + +func (p *PRootProvider) Healthy(ctx context.Context) bool { + // Check proot binary is available + binary := p.config.PRootBinary + if !filepath.IsAbs(binary) { + if _, err := exec.LookPath(binary); err != nil { + p.logger.Debug().Str("binary", binary).Msg("proot binary not found in PATH") + return false + } + } else { + if _, err := os.Stat(binary); err != nil { + p.logger.Debug().Str("binary", binary).Msg("proot binary not found") + return false + } + } + + // Check rootfs directory exists + if p.config.RootfsPath != "" { + if info, err := os.Stat(p.config.RootfsPath); err != nil || !info.IsDir() { + p.logger.Debug().Str("rootfs", p.config.RootfsPath).Msg("rootfs directory not found") + return false + } + } + + return true +} + +func (p *PRootProvider) Spawn(ctx context.Context, opts SpawnOptions) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + // Enforce sandbox limit + activeCount := 0 + for _, sb := range p.sandboxes { + if sb.state != "destroyed" { + activeCount++ + } + } + if activeCount >= p.config.MaxSandboxes { + return "", fmt.Errorf("max sandboxes reached (%d)", p.config.MaxSandboxes) + } + + id := generatePRootSandboxID() + + // Create workspace directory + workspace := filepath.Join(p.config.WorkspaceBase, id) + if err := os.MkdirAll(filepath.Join(workspace, "workspace"), 0755); err != nil { + return "", fmt.Errorf("creating workspace dir: %w", err) + } + + sb := &prootSandbox{ + id: id, + workspace: workspace, + state: "running", + opts: opts, + createdAt: time.Now(), + } + p.sandboxes[id] = sb + + p.logger.Info().Str("sandbox", id).Str("workspace", workspace).Msg("sandbox spawned") + return id, nil +} + +func (p *PRootProvider) getSandbox(id string) (*prootSandbox, error) { + p.mu.RLock() + defer p.mu.RUnlock() + sb, ok := p.sandboxes[id] + if !ok { + return nil, fmt.Errorf("sandbox %q not found", id) + } + if sb.state == "destroyed" { + return nil, fmt.Errorf("sandbox %q is destroyed", id) + } + return sb, nil +} + +// safePath validates and resolves a path within the sandbox workspace, +// preventing path traversal attacks. +func (p *PRootProvider) safePath(sb *prootSandbox, path string) (string, error) { + cleaned := filepath.Clean(path) + full := filepath.Join(sb.workspace, cleaned) + // Ensure resolved path is within workspace + abs, err := filepath.Abs(full) + if err != nil { + return "", fmt.Errorf("resolving path: %w", err) + } + wsAbs, err := filepath.Abs(sb.workspace) + if err != nil { + return "", fmt.Errorf("resolving workspace: %w", err) + } + if !strings.HasPrefix(abs, wsAbs+string(filepath.Separator)) && abs != wsAbs { + return "", fmt.Errorf("path %q escapes sandbox workspace", path) + } + return full, nil +} + +// BuildCommand constructs the proot exec.Cmd for a sandbox and exec options. +// Exported for testing. +func (p *PRootProvider) BuildCommand(ctx context.Context, sb *prootSandbox, opts ExecOptions) *exec.Cmd { + args := []string{ + "-0", + "-r", p.config.RootfsPath, + "-b", filepath.Join(sb.workspace, "workspace") + ":/workspace", + "-b", "/dev/null:/dev/null", + "-b", "/dev/urandom:/dev/urandom", + "-w", "/workspace", + } + + // Set working directory inside proot + if opts.WorkDir != "" { + args[len(args)-1] = opts.WorkDir + } + + // Build the command: sh -c + args = append(args, "/bin/sh", "-c", opts.Command) + + cmd := exec.CommandContext(ctx, p.config.PRootBinary, args...) + + // Environment variables + env := []string{ + "HOME=/root", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "LANG=C.UTF-8", + "TERM=xterm-256color", + } + for k, v := range opts.Env { + env = append(env, k+"="+v) + } + cmd.Env = env + + return cmd +} + +func (p *PRootProvider) Exec(ctx context.Context, sandboxID string, opts ExecOptions) (*ExecResult, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + timeout := p.config.DefaultTimeout + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := p.BuildCommand(execCtx, sb, opts) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("proot exec: %w", err) + } + } + + return &ExecResult{ + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + }, nil +} + +func (p *PRootProvider) ExecStream(ctx context.Context, sandboxID string, opts ExecOptions) (<-chan StreamChunk, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + timeout := p.config.DefaultTimeout + execCtx, cancel := context.WithTimeout(ctx, timeout) + + cmd := p.BuildCommand(execCtx, sb, opts) + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + ch := make(chan StreamChunk, 64) + + if err := cmd.Start(); err != nil { + cancel() + close(ch) + return nil, fmt.Errorf("start: %w", err) + } + + go func() { + defer close(ch) + defer cancel() + var wg sync.WaitGroup + wg.Add(2) + + readStream := func(name string, r io.Reader) { + defer wg.Done() + buf := make([]byte, 4096) + for { + n, readErr := r.Read(buf) + if n > 0 { + select { + case ch <- StreamChunk{Stream: name, Data: string(buf[:n])}: + case <-execCtx.Done(): + return + } + } + if readErr != nil { + return + } + } + } + + go readStream("stdout", stdoutPipe) + go readStream("stderr", stderrPipe) + + wg.Wait() + cmd.Wait() + }() + + return ch, nil +} + +func (p *PRootProvider) WriteFile(ctx context.Context, sandboxID string, path string, content io.Reader, mode string) error { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return err + } + + fullPath, err := p.safePath(sb, path) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return fmt.Errorf("creating dirs: %w", err) + } + + data, err := io.ReadAll(content) + if err != nil { + return fmt.Errorf("reading content: %w", err) + } + + perm := os.FileMode(0644) + if mode == "0755" || mode == "executable" { + perm = 0755 + } + + return os.WriteFile(fullPath, data, perm) +} + +func (p *PRootProvider) ReadFile(ctx context.Context, sandboxID string, path string) (io.ReadCloser, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + fullPath, err := p.safePath(sb, path) + if err != nil { + return nil, err + } + + f, err := os.Open(fullPath) + if err != nil { + return nil, fmt.Errorf("opening file: %w", err) + } + return f, nil +} + +func (p *PRootProvider) ListFiles(ctx context.Context, sandboxID string, path string) ([]FileInfo, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + fullPath, err := p.safePath(sb, path) + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, fmt.Errorf("reading dir: %w", err) + } + + files := make([]FileInfo, 0, len(entries)) + for _, e := range entries { + info, infoErr := e.Info() + if infoErr != nil { + continue + } + relPath := strings.TrimPrefix(filepath.Join(path, e.Name()), "/") + files = append(files, FileInfo{ + Path: relPath, + Size: info.Size(), + Mode: info.Mode().String(), + IsDir: e.IsDir(), + ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"), + }) + } + return files, nil +} + +func (p *PRootProvider) DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return err + } + + fullPath, err := p.safePath(sb, path) + if err != nil { + return err + } + + if recursive { + return os.RemoveAll(fullPath) + } + return os.Remove(fullPath) +} + +func (p *PRootProvider) MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return err + } + + fullOld, err := p.safePath(sb, oldPath) + if err != nil { + return err + } + fullNew, err := p.safePath(sb, newPath) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(fullNew), 0755); err != nil { + return fmt.Errorf("creating dirs: %w", err) + } + + return os.Rename(fullOld, fullNew) +} + +func (p *PRootProvider) ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return err + } + + fullPath, err := p.safePath(sb, path) + if err != nil { + return err + } + + parsed, err := strconv.ParseUint(mode, 8, 32) + if err != nil { + return fmt.Errorf("parse mode: %w", err) + } + + return os.Chmod(fullPath, os.FileMode(parsed)) +} + +func (p *PRootProvider) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + fullPath, err := p.safePath(sb, path) + if err != nil { + return nil, err + } + + info, err := os.Stat(fullPath) + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + + return &FileInfo{ + Path: path, + Size: info.Size(), + Mode: fmt.Sprintf("%o", info.Mode()), + IsDir: info.IsDir(), + ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"), + }, nil +} + +func (p *PRootProvider) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + // Validate pattern doesn't escape workspace + cleanPattern := filepath.Clean(pattern) + fullPattern := filepath.Join(sb.workspace, cleanPattern) + + matches, err := filepath.Glob(fullPattern) + if err != nil { + return nil, fmt.Errorf("glob: %w", err) + } + + // Return workspace-relative paths + result := make([]string, 0, len(matches)) + wsAbs, _ := filepath.Abs(sb.workspace) + for _, match := range matches { + abs, _ := filepath.Abs(match) + if !strings.HasPrefix(abs, wsAbs) { + continue // skip paths that escaped workspace + } + rel, _ := filepath.Rel(sb.workspace, match) + result = append(result, "/"+rel) + } + + return result, nil +} + +func (p *PRootProvider) Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) { + p.mu.RLock() + defer p.mu.RUnlock() + sb, ok := p.sandboxes[sandboxID] + if !ok { + return nil, fmt.Errorf("sandbox %q not found", sandboxID) + } + return &SandboxStatus{ + ID: sb.id, + State: sb.state, + }, nil +} + +func (p *PRootProvider) Destroy(ctx context.Context, sandboxID string) error { + p.mu.Lock() + sb, ok := p.sandboxes[sandboxID] + if !ok { + p.mu.Unlock() + return fmt.Errorf("sandbox %q not found", sandboxID) + } + sb.state = "destroyed" + delete(p.sandboxes, sandboxID) + p.mu.Unlock() + + // Clean up workspace directory + if err := os.RemoveAll(sb.workspace); err != nil { + p.logger.Warn().Err(err).Str("sandbox", sandboxID).Msg("failed to remove workspace") + } + + p.logger.Info().Str("sandbox", sandboxID).Msg("sandbox destroyed") + return nil +} + +func (p *PRootProvider) ConsoleLog(ctx context.Context, sandboxID string, lines int) ([]string, error) { + sb, err := p.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + result := []string{ + "[INFO] proot sandbox " + sb.id + " started", + "[INFO] rootfs: " + p.config.RootfsPath, + "[INFO] workspace initialized at /workspace", + "[INFO] sandbox ready", + } + if lines > 0 && lines < len(result) { + result = result[len(result)-lines:] + } + return result, nil +} + +func generatePRootSandboxID() string { + b := make([]byte, 4) + rand.Read(b) + return fmt.Sprintf("sb-%08x", b) +} diff --git a/internal/providers/proot_test.go b/internal/providers/proot_test.go new file mode 100644 index 0000000..8ef590d --- /dev/null +++ b/internal/providers/proot_test.go @@ -0,0 +1,573 @@ +package providers + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" +) + +func testPRootLogger() zerolog.Logger { + return zerolog.New(zerolog.NewTestWriter(nil)).Level(zerolog.Disabled) +} + +func testPRootProvider(t *testing.T) *PRootProvider { + t.Helper() + tmpDir := t.TempDir() + return NewPRootProvider(PRootProviderConfig{ + RootfsPath: tmpDir, // dummy rootfs for unit tests + PRootBinary: "/nonexistent/proot", + WorkspaceBase: filepath.Join(tmpDir, "workspaces"), + DefaultTimeout: 30 * time.Second, + MaxSandboxes: 3, + }, testPRootLogger()) +} + +// skipIfNoPRoot skips integration tests when proot is not available. +func skipIfNoPRoot(t *testing.T) string { + t.Helper() + path, err := exec.LookPath("proot") + if err != nil { + t.Skip("proot binary not found, skipping integration test") + } + return path +} + +// --- Unit Tests (no proot binary needed) --- + +func TestPRootProvider_Name(t *testing.T) { + p := testPRootProvider(t) + if p.Name() != "proot" { + t.Fatalf("expected name 'proot', got %q", p.Name()) + } +} + +func TestPRootProvider_Healthy_NoBinary(t *testing.T) { + p := testPRootProvider(t) + // Binary is /nonexistent/proot, so should be unhealthy + if p.Healthy(context.Background()) { + t.Fatal("expected unhealthy when proot binary missing") + } +} + +func TestPRootProvider_BuildCommand(t *testing.T) { + p := testPRootProvider(t) + p.config.PRootBinary = "/usr/bin/proot" + p.config.RootfsPath = "/opt/rootfs" + + sb := &prootSandbox{ + id: "sb-test1234", + workspace: "/tmp/test-workspace", + } + + cmd := p.BuildCommand(context.Background(), sb, ExecOptions{ + Command: "echo hello", + Env: map[string]string{"FOO": "bar"}, + }) + + // Verify binary + if cmd.Path != "/usr/bin/proot" && !strings.HasSuffix(cmd.Path, "proot") { + t.Fatalf("expected proot binary, got %q", cmd.Path) + } + + args := strings.Join(cmd.Args, " ") + + // Verify key flags + if !strings.Contains(args, "-0") { + t.Fatal("expected -0 (fake root) flag") + } + if !strings.Contains(args, "-r /opt/rootfs") { + t.Fatal("expected -r rootfs flag") + } + if !strings.Contains(args, "-b /tmp/test-workspace/workspace:/workspace") { + t.Fatalf("expected workspace bind mount, got args: %s", args) + } + if !strings.Contains(args, "-w /workspace") { + t.Fatal("expected -w /workspace flag") + } + if !strings.Contains(args, "echo hello") { + t.Fatal("expected command in args") + } + + // Verify env includes custom var + found := false + for _, e := range cmd.Env { + if e == "FOO=bar" { + found = true + break + } + } + if !found { + t.Fatal("expected FOO=bar in environment") + } +} + +func TestPRootProvider_BuildCommand_WorkDir(t *testing.T) { + p := testPRootProvider(t) + sb := &prootSandbox{id: "sb-test", workspace: "/tmp/ws"} + + cmd := p.BuildCommand(context.Background(), sb, ExecOptions{ + Command: "ls", + WorkDir: "/app", + }) + + args := strings.Join(cmd.Args, " ") + if !strings.Contains(args, "-w /app") { + t.Fatalf("expected -w /app, got args: %s", args) + } +} + +func TestPRootProvider_MaxSandboxes(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + // Spawn up to max (3) + ids := make([]string, 0, 3) + for i := 0; i < 3; i++ { + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn %d: %v", i, err) + } + ids = append(ids, id) + } + + // 4th should fail + _, err := p.Spawn(ctx, SpawnOptions{}) + if err == nil { + t.Fatal("expected error when exceeding max sandboxes") + } + if !strings.Contains(err.Error(), "max sandboxes") { + t.Fatalf("unexpected error: %v", err) + } + + // Cleanup + for _, id := range ids { + p.Destroy(ctx, id) + } +} + +func TestPRootProvider_PathTraversal(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + // Try path traversal via WriteFile + err = p.WriteFile(ctx, id, "../../etc/passwd", bytes.NewReader([]byte("hacked")), "0644") + if err == nil { + t.Fatal("expected error for path traversal") + } + if !strings.Contains(err.Error(), "escapes sandbox") { + t.Fatalf("unexpected error: %v", err) + } + + // Try path traversal via ReadFile + _, err = p.ReadFile(ctx, id, "../../../etc/shadow") + if err == nil { + t.Fatal("expected error for path traversal") + } +} + +func TestPRootProvider_DestroyedSandboxRejects(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + + if err := p.Destroy(ctx, id); err != nil { + t.Fatalf("destroy: %v", err) + } + + // All operations should fail on destroyed sandbox + _, err = p.Exec(ctx, id, ExecOptions{Command: "echo"}) + if err == nil { + t.Fatal("expected error on destroyed sandbox exec") + } + + err = p.WriteFile(ctx, id, "/workspace/test.txt", bytes.NewReader([]byte("data")), "0644") + if err == nil { + t.Fatal("expected error on destroyed sandbox write") + } + + _, err = p.ReadFile(ctx, id, "/workspace/test.txt") + if err == nil { + t.Fatal("expected error on destroyed sandbox read") + } + + _, err = p.ListFiles(ctx, id, "/workspace") + if err == nil { + t.Fatal("expected error on destroyed sandbox list") + } +} + +func TestPRootProvider_SpawnDestroy(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{Image: "alpine:latest"}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + if !strings.HasPrefix(id, "sb-") { + t.Fatalf("expected id prefix 'sb-', got %q", id) + } + + // Workspace dir should exist + sb, _ := p.getSandbox(id) + if _, err := os.Stat(filepath.Join(sb.workspace, "workspace")); err != nil { + t.Fatalf("workspace dir should exist: %v", err) + } + + // Status should be running + status, err := p.Status(ctx, id) + if err != nil { + t.Fatalf("status: %v", err) + } + if status.State != "running" { + t.Fatalf("expected state 'running', got %q", status.State) + } + + // Destroy + if err := p.Destroy(ctx, id); err != nil { + t.Fatalf("destroy: %v", err) + } + + // Workspace dir should be cleaned up + if _, err := os.Stat(sb.workspace); !os.IsNotExist(err) { + t.Fatal("workspace should be removed after destroy") + } +} + +func TestPRootProvider_WriteReadFile(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + content := "hello from proot sandbox" + err = p.WriteFile(ctx, id, "/workspace/test.txt", bytes.NewReader([]byte(content)), "0644") + if err != nil { + t.Fatalf("write: %v", err) + } + + rc, err := p.ReadFile(ctx, id, "/workspace/test.txt") + if err != nil { + t.Fatalf("read: %v", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read all: %v", err) + } + if string(data) != content { + t.Fatalf("expected %q, got %q", content, string(data)) + } +} + +func TestPRootProvider_ListFiles(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + _ = p.WriteFile(ctx, id, "/workspace/a.txt", bytes.NewReader([]byte("a")), "0644") + _ = p.WriteFile(ctx, id, "/workspace/b.txt", bytes.NewReader([]byte("b")), "0644") + + files, err := p.ListFiles(ctx, id, "/workspace") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } +} + +func TestPRootProvider_DeleteFile(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + _ = p.WriteFile(ctx, id, "/workspace/delete-me.txt", bytes.NewReader([]byte("bye")), "0644") + + err = p.DeleteFile(ctx, id, "/workspace/delete-me.txt", false) + if err != nil { + t.Fatalf("delete: %v", err) + } + + _, err = p.ReadFile(ctx, id, "/workspace/delete-me.txt") + if err == nil { + t.Fatal("expected error reading deleted file") + } +} + +func TestPRootProvider_MoveFile(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + _ = p.WriteFile(ctx, id, "/workspace/old.txt", bytes.NewReader([]byte("data")), "0644") + + err = p.MoveFile(ctx, id, "/workspace/old.txt", "/workspace/new.txt") + if err != nil { + t.Fatalf("move: %v", err) + } + + // Old path should not exist + _, err = p.ReadFile(ctx, id, "/workspace/old.txt") + if err == nil { + t.Fatal("old path should not exist after move") + } + + // New path should exist with correct content + rc, err := p.ReadFile(ctx, id, "/workspace/new.txt") + if err != nil { + t.Fatalf("read new: %v", err) + } + defer rc.Close() + data, _ := io.ReadAll(rc) + if string(data) != "data" { + t.Fatalf("expected 'data', got %q", string(data)) + } +} + +func TestPRootProvider_ChmodFile(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + _ = p.WriteFile(ctx, id, "/workspace/script.sh", bytes.NewReader([]byte("#!/bin/sh")), "0644") + + err = p.ChmodFile(ctx, id, "/workspace/script.sh", "0755") + if err != nil { + t.Fatalf("chmod: %v", err) + } + + info, err := p.StatFile(ctx, id, "/workspace/script.sh") + if err != nil { + t.Fatalf("stat: %v", err) + } + // Mode should contain 755 + if !strings.Contains(info.Mode, "755") { + t.Fatalf("expected mode containing 755, got %q", info.Mode) + } +} + +func TestPRootProvider_StatFile(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + content := "stat test content" + _ = p.WriteFile(ctx, id, "/workspace/stat.txt", bytes.NewReader([]byte(content)), "0644") + + info, err := p.StatFile(ctx, id, "/workspace/stat.txt") + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Size != int64(len(content)) { + t.Fatalf("expected size %d, got %d", len(content), info.Size) + } + if info.IsDir { + t.Fatal("expected file, got dir") + } + if info.Path != "/workspace/stat.txt" { + t.Fatalf("expected path '/workspace/stat.txt', got %q", info.Path) + } +} + +func TestPRootProvider_GlobFiles(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + _ = p.WriteFile(ctx, id, "/workspace/foo.txt", bytes.NewReader([]byte("f")), "0644") + _ = p.WriteFile(ctx, id, "/workspace/bar.txt", bytes.NewReader([]byte("b")), "0644") + _ = p.WriteFile(ctx, id, "/workspace/baz.log", bytes.NewReader([]byte("l")), "0644") + + matches, err := p.GlobFiles(ctx, id, "/workspace/*.txt") + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(matches) != 2 { + t.Fatalf("expected 2 .txt matches, got %d: %v", len(matches), matches) + } +} + +func TestPRootProvider_ConsoleLog(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + logs, err := p.ConsoleLog(ctx, id, 0) + if err != nil { + t.Fatalf("console log: %v", err) + } + if len(logs) == 0 { + t.Fatal("expected non-empty console log") + } + if !strings.Contains(logs[0], "proot sandbox") { + t.Fatalf("expected proot sandbox reference, got %q", logs[0]) + } +} + +func TestPRootProvider_ConsoleLog_LimitLines(t *testing.T) { + p := testPRootProvider(t) + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + logs, err := p.ConsoleLog(ctx, id, 2) + if err != nil { + t.Fatalf("console log: %v", err) + } + if len(logs) != 2 { + t.Fatalf("expected 2 lines, got %d", len(logs)) + } +} + +// --- Integration Tests (require proot binary) --- + +func TestPRootProvider_Integration_Exec(t *testing.T) { + prootPath := skipIfNoPRoot(t) + tmpDir := t.TempDir() + + p := NewPRootProvider(PRootProviderConfig{ + RootfsPath: "/", // use host rootfs for integration tests + PRootBinary: prootPath, + WorkspaceBase: filepath.Join(tmpDir, "workspaces"), + DefaultTimeout: 30 * time.Second, + MaxSandboxes: 5, + }, testPRootLogger()) + + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + // Basic echo + result, err := p.Exec(ctx, id, ExecOptions{Command: "echo hello proot"}) + if err != nil { + t.Fatalf("exec: %v", err) + } + if result.ExitCode != 0 { + t.Fatalf("expected exit 0, got %d (stderr: %s)", result.ExitCode, result.Stderr) + } + if strings.TrimSpace(result.Stdout) != "hello proot" { + t.Fatalf("expected 'hello proot', got %q", result.Stdout) + } + + // Non-zero exit + result, err = p.Exec(ctx, id, ExecOptions{Command: "exit 42"}) + if err != nil { + t.Fatalf("exec non-zero: %v", err) + } + if result.ExitCode != 42 { + t.Fatalf("expected exit 42, got %d", result.ExitCode) + } + + // Environment variables + result, err = p.Exec(ctx, id, ExecOptions{ + Command: "echo $MY_VAR", + Env: map[string]string{"MY_VAR": "proot_test"}, + }) + if err != nil { + t.Fatalf("exec env: %v", err) + } + if strings.TrimSpace(result.Stdout) != "proot_test" { + t.Fatalf("expected 'proot_test', got %q", result.Stdout) + } +} + +func TestPRootProvider_Integration_ExecStream(t *testing.T) { + prootPath := skipIfNoPRoot(t) + tmpDir := t.TempDir() + + p := NewPRootProvider(PRootProviderConfig{ + RootfsPath: "/", + PRootBinary: prootPath, + WorkspaceBase: filepath.Join(tmpDir, "workspaces"), + DefaultTimeout: 30 * time.Second, + MaxSandboxes: 5, + }, testPRootLogger()) + + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer p.Destroy(ctx, id) + + ch, err := p.ExecStream(ctx, id, ExecOptions{Command: "echo streaming from proot"}) + if err != nil { + t.Fatalf("exec stream: %v", err) + } + + var output strings.Builder + for chunk := range ch { + output.WriteString(chunk.Data) + } + if !strings.Contains(output.String(), "streaming from proot") { + t.Fatalf("expected 'streaming from proot' in output, got %q", output.String()) + } +} diff --git a/scripts/build-rootfs-arm64.sh b/scripts/build-rootfs-arm64.sh new file mode 100755 index 0000000..354ed00 --- /dev/null +++ b/scripts/build-rootfs-arm64.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Builds a minimal Alpine aarch64 rootfs for the PRoot provider. +# Contains Python 3, Node.js, Bash, and common dev tools. +# +# Usage: ./scripts/build-rootfs-arm64.sh [output-path] +# Example: ./scripts/build-rootfs-arm64.sh ./alpine-rootfs-aarch64.tar.gz +# +# Requirements: +# - qemu-user-static (for cross-arch chroot on x86_64 host) +# Install: sudo apt install qemu-user-static binfmt-support +# - Root access (sudo) for chroot +# +# On a native aarch64 host (RPi, Graviton), qemu is not needed. +set -euo pipefail + +ALPINE_VERSION="3.21" +ARCH="aarch64" +OUTPUT="${1:-./alpine-rootfs-${ARCH}.tar.gz}" +ROOTFS_DIR="$(mktemp -d)/rootfs-${ARCH}" + +trap 'rm -rf "$(dirname "${ROOTFS_DIR}")"' EXIT + +echo "==> Bootstrapping Alpine ${ALPINE_VERSION} ${ARCH}" +mkdir -p "${ROOTFS_DIR}" +MINIROOTFS_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/releases/${ARCH}/alpine-minirootfs-${ALPINE_VERSION}.3-${ARCH}.tar.gz" +wget -q "${MINIROOTFS_URL}" -O /tmp/minirootfs.tar.gz +tar xzf /tmp/minirootfs.tar.gz -C "${ROOTFS_DIR}" +rm /tmp/minirootfs.tar.gz + +# Copy qemu binary if cross-compiling +if [[ "$(uname -m)" != "aarch64" ]]; then + echo " Cross-arch detected, using qemu-aarch64-static" + QEMU_BIN=$(command -v qemu-aarch64-static 2>/dev/null || true) + if [[ -z "$QEMU_BIN" ]]; then + echo "ERROR: qemu-aarch64-static not found." + echo "Install: sudo apt install qemu-user-static binfmt-support" + exit 1 + fi + cp "$QEMU_BIN" "${ROOTFS_DIR}/usr/bin/" +fi + +echo "==> Installing core packages" +sudo chroot "${ROOTFS_DIR}" /bin/sh -c ' + apk update && apk add --no-cache \ + python3 py3-pip \ + nodejs npm \ + bash coreutils \ + curl wget jq \ + git openssh-client \ + sqlite \ + gcc musl-dev python3-dev \ + && pip install --break-system-packages --no-cache-dir \ + requests beautifulsoup4 pandas \ + pdfplumber flask fastapi uvicorn \ + && npm install -g typescript tsx \ + && rm -rf /var/cache/apk/* /root/.cache /tmp/* +' + +# Clean up qemu binary if we copied it +if [[ "$(uname -m)" != "aarch64" ]]; then + rm -f "${ROOTFS_DIR}/usr/bin/qemu-aarch64-static" +fi + +# Create workspace mount point +mkdir -p "${ROOTFS_DIR}/workspace" + +echo "==> Packaging rootfs" +tar czf "${OUTPUT}" -C "${ROOTFS_DIR}" . + +echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))" +echo " Python: $(sudo chroot "${ROOTFS_DIR}" python3 --version 2>/dev/null || echo 'check in rootfs')" +echo " Node: $(sudo chroot "${ROOTFS_DIR}" node --version 2>/dev/null || echo 'check in rootfs')" diff --git a/scripts/install.sh b/scripts/install.sh index ec4a073..af6fbd5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -37,9 +37,12 @@ if [[ "$(uname -s)" != "Linux" ]]; then fi ARCH=$(uname -m) -if [[ "$ARCH" != "x86_64" ]]; then - fail "ForgeVM requires x86_64. You're on $ARCH." -fi +case "$ARCH" in + x86_64) ARCH_SUFFIX="amd64" ;; + aarch64|arm64) ARCH_SUFFIX="arm64" ;; + *) fail "ForgeVM supports x86_64 and aarch64. You're on $ARCH." ;; +esac +info "Detected architecture: $ARCH ($ARCH_SUFFIX)" # ── Detect version ─────────────────────────────────────── if [[ -n "${FORGEVM_VERSION:-}" ]]; then @@ -60,9 +63,9 @@ RELEASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT -info "Downloading forgevm ${VERSION}..." -curl -fSL -o "$TMPDIR/forgevm" "${RELEASE_URL}/forgevm-linux-amd64" -curl -fSL -o "$TMPDIR/forgevm-agent" "${RELEASE_URL}/forgevm-agent-linux-amd64" +info "Downloading forgevm ${VERSION} (${ARCH_SUFFIX})..." +curl -fSL -o "$TMPDIR/forgevm" "${RELEASE_URL}/forgevm-linux-${ARCH_SUFFIX}" +curl -fSL -o "$TMPDIR/forgevm-agent" "${RELEASE_URL}/forgevm-agent-linux-${ARCH_SUFFIX}" curl -fSL -o "$TMPDIR/checksums.txt" "${RELEASE_URL}/checksums.txt" # ── Verify checksums ──────────────────────────────────── @@ -71,8 +74,8 @@ cd "$TMPDIR" # checksums.txt has names like forgevm-linux-amd64, rename for verification sha256sum -c checksums.txt --ignore-missing 2>/dev/null || { # Try manual verification with renamed files - EXPECTED_VM=$(grep "forgevm-linux-amd64" checksums.txt | awk '{print $1}') - EXPECTED_AGENT=$(grep "forgevm-agent-linux-amd64" checksums.txt | awk '{print $1}') + EXPECTED_VM=$(grep "forgevm-linux-${ARCH_SUFFIX}" checksums.txt | grep -v agent | awk '{print $1}') + EXPECTED_AGENT=$(grep "forgevm-agent-linux-${ARCH_SUFFIX}" checksums.txt | awk '{print $1}') ACTUAL_VM=$(sha256sum forgevm | awk '{print $1}') ACTUAL_AGENT=$(sha256sum forgevm-agent | awk '{print $1}') @@ -103,6 +106,10 @@ info "Installed forgevm and forgevm-agent to ${INSTALL_DIR}" if command -v firecracker &>/dev/null; then FC_VER=$(firecracker --version 2>&1 | head -1) info "Firecracker already installed: $FC_VER" +elif [[ "$ARCH_SUFFIX" == "arm64" ]]; then + warn "Firecracker auto-install not supported on ARM64." + warn "On ARM64, consider using the PRoot or Docker provider instead." + warn "Set FORGEVM_PROVIDERS_DEFAULT=proot or FORGEVM_PROVIDERS_DEFAULT=docker" else warn "Firecracker not found. Installing latest release..." FC_VERSION=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest | grep '"tag_name"' | cut -d'"' -f4) @@ -133,7 +140,9 @@ if [[ ! -d "$DATA_DIR" ]]; then sudo chown "$(whoami)" "$DATA_DIR" fi -if [[ -f "$DATA_DIR/vmlinux.bin" ]]; then +if [[ "$ARCH_SUFFIX" == "arm64" ]]; then + info "Skipping kernel download on ARM64 (Firecracker kernel not needed for PRoot/Docker)" +elif [[ -f "$DATA_DIR/vmlinux.bin" ]]; then info "Kernel already exists at $DATA_DIR/vmlinux.bin" else info "Downloading Firecracker kernel..."