refactor: internal package

This commit is contained in:
Anton
2023-12-04 23:40:41 +05:00
parent 05654bd6fa
commit ad79565a93
51 changed files with 39 additions and 39 deletions

297
internal/engine/docker.go Normal file
View File

@@ -0,0 +1,297 @@
// Execute commands using Docker.
package engine
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/internal/fileio"
"github.com/nalgeon/codapi/internal/logx"
)
var killTimeout = 5 * time.Second
const (
actionRun = "run"
actionExec = "exec"
)
// A Docker engine executes a specific sandbox command
// using Docker `run` or `exec` actions.
type Docker struct {
cfg *config.Config
cmd *config.Command
}
// NewDocker creates a new Docker engine for a specific command.
func NewDocker(cfg *config.Config, sandbox, command string) Engine {
cmd := cfg.Commands[sandbox][command]
return &Docker{cfg, cmd}
}
// Exec executes the command and returns the output.
func (e *Docker) Exec(req Request) Execution {
// all steps operate in the same temp directory
dir, err := os.MkdirTemp("", "")
if err != nil {
err = NewExecutionError("create temp dir", err)
return Fail(req.ID, err)
}
defer os.RemoveAll(dir)
// if the command entry point file is not defined,
// there is no need to store request files in the temp directory
if e.cmd.Entry != "" {
// write request files to the temp directory
err = e.writeFiles(dir, req.Files)
if err != nil {
err = NewExecutionError("write files to temp dir", err)
return Fail(req.ID, err)
}
}
// initialization step
if e.cmd.Before != nil {
out := e.execStep(e.cmd.Before, req.ID, dir, nil)
if !out.OK {
return out
}
}
// the first step is required
first, rest := e.cmd.Steps[0], e.cmd.Steps[1:]
out := e.execStep(first, req.ID, dir, req.Files)
// the rest are optional
if out.OK && len(rest) > 0 {
// each step operates on the results of the previous one,
// without using the source files - hence `nil` instead of `files`
for _, step := range rest {
out = e.execStep(step, req.ID, dir, nil)
if !out.OK {
break
}
}
}
// cleanup step
if e.cmd.After != nil {
afterOut := e.execStep(e.cmd.After, req.ID, dir, nil)
if out.OK && !afterOut.OK {
return afterOut
}
}
return out
}
// execStep executes a step using the docker container.
func (e *Docker) execStep(step *config.Step, reqID, dir string, files Files) Execution {
box := e.cfg.Boxes[step.Box]
err := e.copyFiles(box, dir)
if err != nil {
err = NewExecutionError("copy files to temp dir", err)
return Fail(reqID, err)
}
stdout, stderr, err := e.exec(box, step, reqID, dir, files)
if err != nil {
return Fail(reqID, err)
}
return Execution{
ID: reqID,
OK: true,
Stdout: stdout,
Stderr: stderr,
}
}
// copyFiles copies box files to the temporary directory.
func (e *Docker) copyFiles(box *config.Box, dir string) error {
if box == nil || len(box.Files) == 0 {
return nil
}
for _, pattern := range box.Files {
err := fileio.CopyFiles(pattern, dir)
if err != nil {
return err
}
}
return nil
}
// writeFiles writes request files to the temporary directory.
func (e *Docker) writeFiles(dir string, files Files) error {
var err error
files.Range(func(name, content string) bool {
if name == "" {
name = e.cmd.Entry
}
path := filepath.Join(dir, name)
err = os.WriteFile(path, []byte(content), 0444)
return err == nil
})
return err
}
// exec executes the step in the docker container
// using the files from in the temporary directory.
func (e *Docker) exec(box *config.Box, step *config.Step, reqID, dir string, files Files) (stdout string, stderr string, err error) {
// limit the stdout/stderr size
prog := NewProgram(step.Timeout, int64(step.NOutput))
args := e.buildArgs(box, step, reqID, dir)
if step.Stdin {
// pass files to container from stdin
stdin := filesReader(files)
stdout, stderr, err = prog.RunStdin(stdin, reqID, "docker", args...)
} else {
// pass files to container from temp directory
stdout, stderr, err = prog.Run(reqID, "docker", args...)
}
if err == nil {
// success
return
}
if err.Error() == "signal: killed" {
if step.Action == actionRun {
// we have to "docker kill" the container here, because the proccess
// inside the container is not related to the "docker run" process,
// and will hang forever after the "docker run" process is killed
go func() {
err = dockerKill(reqID)
if err == nil {
logx.Debug("%s: docker kill ok", reqID)
} else {
logx.Log("%s: docker kill failed: %v", reqID, err)
}
}()
}
// context timeout
err = ErrTimeout
return
}
exitErr := new(exec.ExitError)
if errors.As(err, &exitErr) {
// the problem (if any) is the code, not the execution
// so we return the error without wrapping into ExecutionError
stderr, stdout = stdout+stderr, ""
if stderr != "" {
err = fmt.Errorf("%s (%s)", stderr, err)
}
return
}
// other execution error
err = NewExecutionError("execute code", err)
return
}
// buildArgs prepares the arguments for the `docker` command.
func (e *Docker) buildArgs(box *config.Box, step *config.Step, name, dir string) []string {
var args []string
if step.Action == actionRun {
args = dockerRunArgs(box, step, name, dir)
} else if step.Action == actionExec {
args = dockerExecArgs(step)
} else {
// should never happen if the config is valid
args = []string{"version"}
}
command := expandVars(step.Command, name)
args = append(args, command...)
logx.Debug("%v", args)
return args
}
// buildArgs prepares the arguments for the `docker run` command.
func dockerRunArgs(box *config.Box, step *config.Step, name, dir string) []string {
args := []string{
actionRun, "--rm",
"--name", name,
"--runtime", box.Runtime,
"--cpus", strconv.Itoa(box.CPU),
"--memory", fmt.Sprintf("%dm", box.Memory),
"--network", box.Network,
"--pids-limit", strconv.Itoa(box.NProc),
"--user", step.User,
}
if !box.Writable {
args = append(args, "--read-only")
}
if step.Stdin {
args = append(args, "--interactive")
}
if box.Storage != "" {
args = append(args, "--storage-opt", fmt.Sprintf("size=%s", box.Storage))
}
if dir != "" {
args = append(args, "--volume", fmt.Sprintf(box.Volume, dir))
}
for _, fs := range box.Tmpfs {
args = append(args, "--tmpfs", fs)
}
for _, cap := range box.CapAdd {
args = append(args, "--cap-add", cap)
}
for _, cap := range box.CapDrop {
args = append(args, "--cap-drop", cap)
}
for _, lim := range box.Ulimit {
args = append(args, "--ulimit", lim)
}
args = append(args, box.Image)
return args
}
// dockerExecArgs prepares the arguments for the `docker exec` command.
func dockerExecArgs(step *config.Step) []string {
return []string{
actionExec, "--interactive",
"--user", step.User,
step.Box,
}
}
// filesReader creates a reader over an in-memory collection of files.
func filesReader(files Files) io.Reader {
var input strings.Builder
for _, content := range files {
input.WriteString(content)
}
return strings.NewReader(input.String())
}
// expandVars replaces variables in command arguments with values.
// The only supported variable is :name = container name.
func expandVars(command []string, name string) []string {
expanded := make([]string, len(command))
copy(expanded, command)
for i, cmd := range expanded {
expanded[i] = strings.Replace(cmd, ":name", name, 1)
}
return expanded
}
// dockerKill kills the container with the specified id/name.
func dockerKill(id string) error {
ctx, cancel := context.WithTimeout(context.Background(), killTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "kill", id)
return execy.Run(cmd)
}

View File

@@ -0,0 +1,164 @@
package engine
import (
"strings"
"testing"
"github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/internal/logx"
)
var dockerCfg = &config.Config{
Boxes: map[string]*config.Box{
"postgresql": {
Image: "codapi/postgresql",
Runtime: "runc",
Host: config.Host{
CPU: 1, Memory: 64, Network: "none",
Volume: "%s:/sandbox:ro",
NProc: 64,
},
},
"python": {
Image: "codapi/python",
Runtime: "runc",
Host: config.Host{
CPU: 1, Memory: 64, Network: "none",
Volume: "%s:/sandbox:ro",
NProc: 64,
},
},
},
Commands: map[string]config.SandboxCommands{
"postgresql": map[string]*config.Command{
"run": {
Engine: "docker",
Before: &config.Step{
Box: "postgres", User: "sandbox", Action: "exec",
Command: []string{"psql", "-f", "create.sql"},
NOutput: 4096,
},
Steps: []*config.Step{
{
Box: "postgres", User: "sandbox", Action: "exec", Stdin: true,
Command: []string{"psql", "--user=:name"},
NOutput: 4096,
},
},
After: &config.Step{
Box: "postgres", User: "sandbox", Action: "exec",
Command: []string{"psql", "-f", "drop.sql"},
NOutput: 4096,
},
},
},
"python": map[string]*config.Command{
"run": {
Engine: "docker",
Entry: "main.py",
Steps: []*config.Step{
{
Box: "python", User: "sandbox", Action: "run",
Command: []string{"python", "main.py"},
NOutput: 4096,
},
},
},
},
},
}
func TestDockerRun(t *testing.T) {
logx.Mock()
commands := map[string]execy.CmdOut{
"docker run": {Stdout: "hello world", Stderr: "", Err: nil},
}
mem := execy.Mock(commands)
engine := NewDocker(dockerCfg, "python", "run")
t.Run("success", func(t *testing.T) {
req := Request{
ID: "http_42",
Sandbox: "python",
Command: "run",
Files: map[string]string{
"": "print('hello world')",
},
}
out := engine.Exec(req)
if out.ID != req.ID {
t.Errorf("ID: expected %s, got %s", req.ID, out.ID)
}
if !out.OK {
t.Error("OK: expected true")
}
want := "hello world"
if out.Stdout != want {
t.Errorf("Stdout: expected %q, got %q", want, out.Stdout)
}
if out.Stderr != "" {
t.Errorf("Stderr: expected %q, got %q", "", out.Stdout)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %v", out.Err)
}
mem.MustHave(t, "python main.py")
})
}
func TestDockerExec(t *testing.T) {
logx.Mock()
commands := map[string]execy.CmdOut{
"docker exec": {Stdout: "hello world", Stderr: "", Err: nil},
}
mem := execy.Mock(commands)
engine := NewDocker(dockerCfg, "postgresql", "run")
t.Run("success", func(t *testing.T) {
req := Request{
ID: "http_42",
Sandbox: "postgresql",
Command: "run",
Files: map[string]string{
"": "select 'hello world'",
},
}
out := engine.Exec(req)
if out.ID != req.ID {
t.Errorf("ID: expected %s, got %s", req.ID, out.ID)
}
if !out.OK {
t.Error("OK: expected true")
}
want := "hello world"
if out.Stdout != want {
t.Errorf("Stdout: expected %q, got %q", want, out.Stdout)
}
if out.Stderr != "" {
t.Errorf("Stderr: expected %q, got %q", "", out.Stdout)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %v", out.Err)
}
mem.MustHave(t, "psql -f create.sql")
mem.MustHave(t, "psql --user=http_42")
mem.MustHave(t, "psql -f drop.sql")
})
}
func Test_expandVars(t *testing.T) {
const name = "codapi_01"
commands := map[string]string{
"python main.py": "python main.py",
"sh create.sh :name": "sh create.sh " + name,
}
for cmd, want := range commands {
src := strings.Fields(cmd)
exp := expandVars(src, name)
got := strings.Join(exp, " ")
if got != want {
t.Errorf("%q: expected %q, got %q", cmd, got, want)
}
}
}

116
internal/engine/engine.go Normal file
View File

@@ -0,0 +1,116 @@
// Package engine provides code execution engines.
package engine
import (
"errors"
"fmt"
"github.com/nalgeon/codapi/internal/stringx"
)
// A Request initiates code execution.
type Request struct {
ID string `json:"id"`
Sandbox string `json:"sandbox"`
Command string `json:"command"`
Files Files `json:"files"`
}
// GenerateID() sets a unique ID for the request.
func (r *Request) GenerateID() {
r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8))
}
// An Execution is an output from the code execution engine.
type Execution struct {
ID string `json:"id"`
OK bool `json:"ok"`
Duration int `json:"duration"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Err error `json:"-"`
}
// An ErrTimeout is returned if code execution did not complete
// in the allowed timeframe.
var ErrTimeout = errors.New("code execution timeout")
// An ErrBusy is returned when there are no engines available.
var ErrBusy = errors.New("busy: try again later")
// An ExecutionError is returned if code execution failed
// due to the application problems, not due to the problems with the code.
type ExecutionError struct {
msg string
inner error
}
func NewExecutionError(msg string, err error) ExecutionError {
return ExecutionError{msg: msg, inner: err}
}
func (err ExecutionError) Error() string {
return err.msg + ": " + err.inner.Error()
}
func (err ExecutionError) Unwrap() error {
return err.inner
}
// Files are a collection of files to be executed by the engine.
type Files map[string]string
// First returns the contents of the first file.
func (f Files) First() string {
for _, content := range f {
return content
}
return ""
}
// Range iterates over the files, calling fn for each one.
func (f Files) Range(fn func(name, content string) bool) {
for name, content := range f {
ok := fn(name, content)
if !ok {
break
}
}
}
// Count returns the number of files.
func (f Files) Count() int {
return len(f)
}
// An Engine executes a specific sandbox command on the code.
// Engines must be concurrent-safe, since they can be accessed by multiple goroutines.
type Engine interface {
// Exec executes the command and returns the output.
Exec(req Request) Execution
}
// Fail creates an output from an error.
func Fail(id string, err error) Execution {
if _, ok := err.(ExecutionError); ok {
return Execution{
ID: id,
OK: false,
Stderr: "internal error",
Err: err,
}
}
if errors.Is(err, ErrBusy) {
return Execution{
ID: id,
OK: false,
Stderr: err.Error(),
Err: err,
}
}
return Execution{
ID: id,
OK: false,
Stderr: err.Error(),
}
}

View File

@@ -0,0 +1,137 @@
package engine
import (
"errors"
"reflect"
"sort"
"testing"
)
func TestExecutionError(t *testing.T) {
inner := errors.New("inner error")
err := NewExecutionError("failed", inner)
if err.Error() != "failed: inner error" {
t.Errorf("Error: expected %q, got %q", "failed: inner error", err.Error())
}
unwrapped := err.Unwrap()
if unwrapped != inner {
t.Errorf("Unwrap: expected %#v, got %#v", inner, unwrapped)
}
}
func TestFiles_Count(t *testing.T) {
var files Files = map[string]string{
"first": "alice",
"second": "bob",
"third": "cindy",
}
if files.Count() != 3 {
t.Errorf("Count: expected 3, got %d", files.Count())
}
}
func TestFiles_Range(t *testing.T) {
var files Files = map[string]string{
"first": "alice",
"second": "bob",
"third": "cindy",
}
t.Run("range", func(t *testing.T) {
names := []string{}
contents := []string{}
files.Range(func(name, content string) bool {
names = append(names, name)
contents = append(contents, content)
return true
})
sort.Strings(names)
if !reflect.DeepEqual(names, []string{"first", "second", "third"}) {
t.Errorf("unexpected names: %v", names)
}
sort.Strings(contents)
if !reflect.DeepEqual(contents, []string{"alice", "bob", "cindy"}) {
t.Errorf("unexpected contents: %v", contents)
}
})
t.Run("break", func(t *testing.T) {
names := []string{}
contents := []string{}
files.Range(func(name, content string) bool {
names = append(names, name)
contents = append(contents, content)
return false
})
if len(names) != 1 {
t.Fatalf("expected names len = 1, got %d", len(names))
}
if len(contents) != 1 {
t.Fatalf("expected contents len = 1, got %d", len(contents))
}
if files[names[0]] != contents[0] {
t.Fatalf("name does not match content: %v -> %v", names[0], contents[0])
}
})
}
func TestFail(t *testing.T) {
t.Run("ExecutionError", func(t *testing.T) {
err := NewExecutionError("failed", errors.New("inner error"))
out := Fail("42", err)
if out.ID != "42" {
t.Errorf("ID: expected 42, got %v", out.ID)
}
if out.OK {
t.Error("OK: expected false")
}
if out.Stderr != "internal error" {
t.Errorf("Stderr: expected %q, got %q", "internal error", out.Stderr)
}
if out.Stdout != "" {
t.Errorf("Stdout: expected empty, got %q", out.Stdout)
}
if out.Err != err {
t.Errorf("Err: expected %#v, got %#v", err, out.Err)
}
})
t.Run("ErrBusy", func(t *testing.T) {
err := ErrBusy
out := Fail("42", err)
if out.ID != "42" {
t.Errorf("ID: expected 42, got %v", out.ID)
}
if out.OK {
t.Error("OK: expected false")
}
if out.Stderr != err.Error() {
t.Errorf("Stderr: expected %q, got %q", err.Error(), out.Stderr)
}
if out.Stdout != "" {
t.Errorf("Stdout: expected empty, got %q", out.Stdout)
}
if out.Err != err {
t.Errorf("Err: expected %#v, got %#v", err, out.Err)
}
})
t.Run("Error", func(t *testing.T) {
err := errors.New("user error")
out := Fail("42", err)
if out.ID != "42" {
t.Errorf("ID: expected 42, got %v", out.ID)
}
if out.OK {
t.Error("OK: expected false")
}
if out.Stderr != err.Error() {
t.Errorf("Stderr: expected %q, got %q", err.Error(), out.Stderr)
}
if out.Stdout != "" {
t.Errorf("Stdout: expected empty, got %q", out.Stdout)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %#v", out.Err)
}
})
}

54
internal/engine/exec.go Normal file
View File

@@ -0,0 +1,54 @@
package engine
import (
"context"
"io"
"os/exec"
"strings"
"time"
"github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/internal/logx"
)
// A Program is an executable program.
type Program struct {
timeout time.Duration
nOutput int64
}
// NewProgram creates a new program.
func NewProgram(timeoutSec int, nOutput int64) *Program {
return &Program{
timeout: time.Duration(timeoutSec) * time.Second,
nOutput: nOutput,
}
}
// Run starts the program and waits for it to complete (or timeout).
func (p *Program) Run(id, name string, arg ...string) (stdout string, stderr string, err error) {
return p.RunStdin(nil, id, name, arg...)
}
// RunStdin starts the program with data from stdin
// and waits for it to complete (or timeout).
func (p *Program) RunStdin(stdin io.Reader, id, name string, arg ...string) (stdout string, stderr string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
var cmdout, cmderr strings.Builder
cmd := exec.CommandContext(ctx, name, arg...)
cmd.Cancel = func() error {
err := cmd.Process.Kill()
logx.Debug("%s: execution timeout, killed process=%d, err=%v", id, cmd.Process.Pid, err)
return err
}
cmd.Stdin = stdin
cmd.Stdout = LimitWriter(&cmdout, p.nOutput)
cmd.Stderr = LimitWriter(&cmderr, p.nOutput)
err = execy.Run(cmd)
stdout = cmdout.String()
stderr = cmderr.String()
return
}

View File

@@ -0,0 +1,74 @@
package engine
import (
"errors"
"strings"
"testing"
"github.com/nalgeon/codapi/internal/execy"
)
func TestProgram_Run(t *testing.T) {
commands := map[string]execy.CmdOut{
"mock stdout": {Stdout: "stdout", Stderr: "", Err: nil},
"mock stderr": {Stdout: "", Stderr: "stderr", Err: nil},
"mock outerr": {Stdout: "stdout", Stderr: "stderr", Err: nil},
"mock err": {Stdout: "", Stderr: "stderr", Err: errors.New("error")},
}
mem := execy.Mock(commands)
for key, want := range commands {
t.Run(key, func(t *testing.T) {
p := NewProgram(3, 100)
name, arg, _ := strings.Cut(key, " ")
stdout, stderr, err := p.Run("mock_42", name, arg)
if !mem.Has(key) {
t.Errorf("Run: command %q not run", key)
}
if stdout != want.Stdout {
t.Errorf("stdout: want %#v, got %#v", want.Stdout, stdout)
}
if stderr != want.Stderr {
t.Errorf("stderr: want %#v, got %#v", want.Stderr, stderr)
}
if err != want.Err {
t.Errorf("err: want %#v, got %#v", want.Err, err)
}
})
}
}
func TestProgram_LimitOutput(t *testing.T) {
commands := map[string]execy.CmdOut{
"mock stdout": {Stdout: "1234567890", Stderr: ""},
"mock stderr": {Stdout: "", Stderr: "1234567890"},
"mock outerr": {Stdout: "1234567890", Stderr: "1234567890"},
}
execy.Mock(commands)
const nOutput = 5
{
p := NewProgram(3, nOutput)
stdout, _, _ := p.Run("mock_42", "mock", "stdout")
if stdout != "12345" {
t.Errorf("stdout: want %#v, got %#v", "12345", stdout)
}
}
{
p := NewProgram(3, nOutput)
_, stderr, _ := p.Run("mock_42", "mock", "stderr")
if stderr != "12345" {
t.Errorf("stderr: want %#v, got %#v", "12345", stderr)
}
}
{
p := NewProgram(3, nOutput)
stdout, stderr, _ := p.Run("mock_42", "mock", "outerr")
if stdout != "12345" {
t.Errorf("stdout: want %#v, got %#v", "12345", stdout)
}
if stderr != "12345" {
t.Errorf("stderr: want %#v, got %#v", "12345", stderr)
}
}
}

166
internal/engine/http.go Normal file
View File

@@ -0,0 +1,166 @@
// Send HTTP request according to the specification.
package engine
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/internal/httpx"
"github.com/nalgeon/codapi/internal/logx"
)
// An HTTP engine sends HTTP requests.
type HTTP struct {
hosts map[string]string
}
// NewHTTP creates a new HTTP engine.
func NewHTTP(cfg *config.Config, sandbox, command string) Engine {
if len(cfg.HTTP.Hosts) == 0 {
msg := fmt.Sprintf("%s %s: http engine requires at least one allowed URL", sandbox, command)
panic(msg)
}
return &HTTP{hosts: cfg.HTTP.Hosts}
}
// Exec sends an HTTP request according to the spec
// and returns the response as text with status, headers and body.
func (e *HTTP) Exec(req Request) Execution {
// build request from spec
httpReq, err := e.parse(req.Files.First())
if err != nil {
err = fmt.Errorf("parse spec: %w", err)
return Fail(req.ID, err)
}
// send request and receive response
allowed := e.translateHost(httpReq)
if !allowed {
err = fmt.Errorf("host not allowed: %s", httpReq.Host)
return Fail(req.ID, err)
}
logx.Log("%s: %s %s", req.ID, httpReq.Method, httpReq.URL.String())
resp, err := httpx.Do(httpReq)
if err != nil {
err = fmt.Errorf("http request: %w", err)
return Fail(req.ID, err)
}
defer resp.Body.Close()
// read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
err = NewExecutionError("read response", err)
return Fail(req.ID, err)
}
// build text representation of request
stdout := e.responseText(resp, body)
return Execution{
ID: req.ID,
OK: true,
Stdout: stdout,
}
}
// parse parses the request specification.
func (e *HTTP) parse(text string) (*http.Request, error) {
lines := strings.Split(text, "\n")
if len(lines) == 0 {
return nil, errors.New("empty request")
}
lineIdx := 0
// parse method and URL
var method, url string
methodURL := strings.Fields(lines[0])
if len(methodURL) >= 2 {
method = methodURL[0]
url = methodURL[1]
} else {
method = http.MethodGet
url = methodURL[0]
}
lineIdx++
// parse URL parameters
var urlParams strings.Builder
for i := lineIdx; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "?") || strings.HasPrefix(line, "&") {
urlParams.WriteString(line)
lineIdx++
} else {
break
}
}
// parse headers
headers := make(http.Header)
for i := lineIdx; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
break
}
headerParts := strings.SplitN(line, ":", 2)
if len(headerParts) == 2 {
headers.Add(strings.TrimSpace(headerParts[0]), strings.TrimSpace(headerParts[1]))
lineIdx++
}
}
lineIdx += 1
// parse body
var bodyRdr io.Reader
if lineIdx < len(lines) {
body := strings.Join(lines[lineIdx:], "\n")
bodyRdr = strings.NewReader(body)
}
// create request
req, err := http.NewRequest(method, url+urlParams.String(), bodyRdr)
if err != nil {
return nil, err
}
req.Header = headers
return req, nil
}
// translateHost translates the requested host into the allowed one.
// Returns false if the requested host is not allowed.
func (e *HTTP) translateHost(req *http.Request) bool {
host := e.hosts[req.Host]
if host == "" {
return false
}
req.URL.Host = host
return true
}
// responseText returns the response as text with status, headers and body.
func (e *HTTP) responseText(resp *http.Response, body []byte) string {
var b bytes.Buffer
// status line
b.WriteString(
fmt.Sprintf("%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)),
)
// headers
for name := range resp.Header {
b.WriteString(fmt.Sprintf("%s: %s\n", name, resp.Header.Get(name)))
}
// body
if len(body) > 0 {
b.WriteByte('\n')
b.Write(body)
}
return b.String()
}

View File

@@ -0,0 +1,179 @@
package engine
import (
"io"
"net/http"
"testing"
"github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/internal/httpx"
"github.com/nalgeon/codapi/internal/logx"
)
var httpCfg = &config.Config{
HTTP: &config.HTTP{
Hosts: map[string]string{"codapi.org": "localhost"},
},
Commands: map[string]config.SandboxCommands{
"http": map[string]*config.Command{
"run": {Engine: "http"},
},
},
}
func TestHTTP_Exec(t *testing.T) {
logx.Mock()
httpx.Mock()
engine := NewHTTP(httpCfg, "http", "run")
t.Run("success", func(t *testing.T) {
req := Request{
ID: "http_42",
Sandbox: "http",
Command: "run",
Files: map[string]string{
"": "GET https://codapi.org/example.txt",
},
}
out := engine.Exec(req)
if out.ID != req.ID {
t.Errorf("ID: expected %s, got %s", req.ID, out.ID)
}
if !out.OK {
t.Error("OK: expected true")
}
want := `HTTP/1.1 200 OK
Content-Type: text/plain
hello`
if out.Stdout != want {
t.Errorf("Stdout: expected %q, got %q", want, out.Stdout)
}
if out.Stderr != "" {
t.Errorf("Stderr: expected %q, got %q", "", out.Stdout)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %#v", out.Err)
}
})
t.Run("hostname not allowed", func(t *testing.T) {
req := Request{
ID: "http_42",
Sandbox: "http",
Command: "run",
Files: map[string]string{
"": "GET https://example.com/get",
},
}
out := engine.Exec(req)
if out.Err != nil {
t.Errorf("Err: expected nil, got %#v", out.Err)
}
if out.Stderr != "host not allowed: example.com" {
t.Errorf("Stderr: unexpected value %q", out.Stderr)
}
})
}
func TestHTTP_parse(t *testing.T) {
logx.Mock()
httpx.Mock()
engine := NewHTTP(httpCfg, "http", "run").(*HTTP)
t.Run("request line", func(t *testing.T) {
const uri = "https://codapi.org/head"
text := "HEAD " + uri
req, err := engine.parse(text)
if err != nil {
t.Fatalf("unexpected error %#v", err)
}
if req.Method != http.MethodHead {
t.Errorf("Method: expected %s, got %s", http.MethodHead, req.Method)
}
if req.URL.String() != uri {
t.Errorf("URL: expected %q, got %q", uri, req.URL.String())
}
})
t.Run("headers", func(t *testing.T) {
const uri = "https://codapi.org/get"
text := "GET " + uri + "\naccept: text/plain\nx-secret: 42"
req, err := engine.parse(text)
if err != nil {
t.Fatalf("unexpected error %#v", err)
}
if req.Method != http.MethodGet {
t.Errorf("Method: expected %s, got %s", http.MethodGet, req.Method)
}
if req.URL.String() != uri {
t.Errorf("URL: expected %q, got %q", uri, req.URL.String())
}
if len(req.Header) != 2 {
t.Fatalf("Header: expected 2 headers, got %d", len(req.Header))
}
if req.Header.Get("accept") != "text/plain" {
t.Fatalf("Header: expected accept = %q, got %q", "text/plain", req.Header.Get("accept"))
}
if req.Header.Get("x-secret") != "42" {
t.Fatalf("Header: expected x-secret = %q, got %q", "42", req.Header.Get("x-secret"))
}
})
t.Run("body", func(t *testing.T) {
const uri = "https://codapi.org/post"
const body = "{\"name\":\"alice\"}"
text := "POST " + uri + "\ncontent-type: application/json\n\n" + body
req, err := engine.parse(text)
if err != nil {
t.Fatalf("unexpected error %#v", err)
}
if req.Method != http.MethodPost {
t.Errorf("Method: expected %s, got %s", http.MethodPost, req.Method)
}
if req.URL.String() != uri {
t.Errorf("URL: expected %q, got %q", uri, req.URL.String())
}
if req.Header.Get("content-type") != "application/json" {
t.Errorf("Header: expected content-type = %q, got %q",
"application/json", req.Header.Get("content-type"))
}
b, _ := io.ReadAll(req.Body)
got := string(b)
if got != body {
t.Errorf("Body: expected %q, got %q", body, got)
}
})
t.Run("invalid", func(t *testing.T) {
_, err := engine.parse("on,e two three")
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestHTTP_translateHost(t *testing.T) {
logx.Mock()
httpx.Mock()
engine := NewHTTP(httpCfg, "http", "run").(*HTTP)
t.Run("known url", func(t *testing.T) {
const uri = "http://codapi.org/get"
req, _ := http.NewRequest(http.MethodGet, uri, nil)
ok := engine.translateHost(req)
if !ok {
t.Errorf("%s: should be allowed", uri)
}
if req.URL.Hostname() != "localhost" {
t.Errorf("%s: expected %s, got %s", uri, "localhost", req.URL.Hostname())
}
})
t.Run("unknown url", func(t *testing.T) {
const uri = "http://example.com/get"
req, _ := http.NewRequest(http.MethodGet, uri, nil)
ok := engine.translateHost(req)
if ok {
t.Errorf("%s: should not be allowed", uri)
}
if req.URL.Hostname() != "example.com" {
t.Errorf("%s: expected %s, got %s", uri, "example.com", req.URL.Hostname())
}
})
}

31
internal/engine/io.go Normal file
View File

@@ -0,0 +1,31 @@
package engine
import "io"
// A LimitedWriter writes to w but limits the amount
// of data to only n bytes. After reaching the limit,
// silently discards the rest of the data without errors.
type LimitedWriter struct {
w io.Writer
n int64
}
// LimitWriter returns a writer that writes no more
// than n bytes and silently discards the rest.
func LimitWriter(w io.Writer, n int64) io.Writer {
return &LimitedWriter{w, n}
}
// Write implements the io.Writer interface.
func (w *LimitedWriter) Write(p []byte) (int, error) {
lenp := len(p)
if w.n <= 0 {
return lenp, nil
}
if int64(lenp) > w.n {
p = p[:w.n]
}
n, err := w.w.Write(p)
w.n -= int64(n)
return lenp, err
}

View File

@@ -0,0 +1,56 @@
package engine
import (
"bytes"
"reflect"
"testing"
)
func TestLimitedWriter(t *testing.T) {
var b bytes.Buffer
w := LimitWriter(&b, 5)
{
src := []byte{1, 2, 3}
n, err := w.Write(src)
if n != 3 {
t.Fatalf("write(1,2,3): expected n = 3, got %d", n)
}
if err != nil {
t.Fatalf("write(1,2,3): expected nil err, got %v", err)
}
if !reflect.DeepEqual(b.Bytes(), src) {
t.Fatalf("write(1,2,3): expected %v, got %v", src, b.Bytes())
}
}
{
src := []byte{4, 5}
n, err := w.Write(src)
if n != 2 {
t.Fatalf("+write(4,5): expected n = 2, got %d", n)
}
if err != nil {
t.Fatalf("+write(4,5): expected nil err, got %v", err)
}
want := []byte{1, 2, 3, 4, 5}
if !reflect.DeepEqual(b.Bytes(), want) {
t.Fatalf("+write(4,5): expected %v, got %v", want, b.Bytes())
}
}
{
src := []byte{6, 7, 8}
n, err := w.Write(src)
if n != 3 {
t.Fatalf("+write(6,7,8): expected n = 3, got %d", n)
}
if err != nil {
t.Fatalf("+write(6,7,8): expected nil err, got %v", err)
}
want := []byte{1, 2, 3, 4, 5}
if !reflect.DeepEqual(b.Bytes(), want) {
t.Fatalf("+write(6,7,8): expected %v, got %v", want, b.Bytes())
}
}
}

1
internal/engine/testdata/example.txt vendored Normal file
View File

@@ -0,0 +1 @@
hello