23 Commits
0.5.0 ... 0.7.1

Author SHA1 Message Date
Anton
bca91d71e5 fix: prevent directory traversal attack when writing request files 2024-01-19 20:47:09 +05:00
Anton
02473a2b61 impr: goreleaser-compatible version, commit and date 2024-01-18 00:34:49 +05:00
Anton
8178918c6e fix: engine - set 777 permissions for temp dir (#6) 2024-01-18 00:15:33 +05:00
Anton
314987ae43 impr: engine - copy files with 444 permissions 2024-01-17 23:27:59 +05:00
Anton
3fd59120f5 impr: explicit versioned boxes 2023-12-21 16:46:31 +05:00
Anton
ade821ff61 feat: support different versions of the same box 2023-12-20 22:43:07 +05:00
Anton
162ca55092 fix: access-control-allow-methods header naming and value 2023-12-20 11:12:06 +05:00
Anton
69d022c061 doc: readme 2023-12-18 00:59:53 +05:00
Anton
0385eadc08 impr: support binary files 2023-12-12 01:56:43 +05:00
Anton
81240dd80f doc: readme 2023-12-11 02:49:51 +05:00
Anton
c2ed6f1bcb doc: codapi in action 2023-12-05 22:15:05 +05:00
Anton
db61c053b1 doc: bump version 2023-12-05 13:47:15 +05:00
Anton
99cef70dab doc: modular sandbox configs 2023-12-05 01:03:07 +05:00
Anton
50dc6a71d1 build: barebones dev build 2023-12-05 00:59:17 +05:00
Anton
cfe8970ebf impr: modular sandbox configs 2023-12-05 00:53:50 +05:00
Anton
07b523cd4d refactor: configs dir 2023-12-04 23:57:03 +05:00
Anton
ad79565a93 refactor: internal package 2023-12-04 23:40:41 +05:00
Anton
05654bd6fa doc: install on separate machine 2023-12-04 23:00:19 +05:00
Anton
e337bbd0e9 doc: readme 2023-12-04 22:35:09 +05:00
Anton
cb0fb3b361 doc: contributing 2023-12-04 22:30:07 +05:00
Anton
362cf8ea23 doc: highlights 2023-12-01 10:21:35 +05:00
Anton
98c31bf00a doc: adding a sandbox 2023-12-01 01:35:58 +05:00
Anton
da007ccc13 doc: install 0.5.0 2023-12-01 00:59:33 +05:00
68 changed files with 1050 additions and 351 deletions

View File

@@ -32,10 +32,5 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: codapi name: codapi
path: | path: build/codapi
build/codapi
images/
*.json
codapi.service
Makefile
retention-days: 7 retention-days: 7

View File

@@ -11,7 +11,7 @@ builds:
archives: archives:
- files: - files:
- "*.json" - configs/*
- images/* - images/*
- codapi.service - codapi.service
- LICENSE - LICENSE

View File

@@ -6,9 +6,7 @@ build_rev := "main"
ifneq ($(wildcard .git),) ifneq ($(wildcard .git),)
build_rev := $(shell git rev-parse --short HEAD) build_rev := $(shell git rev-parse --short HEAD)
endif endif
build_date := $(shell date -u '+%Y-%m-%dT%H:%M:%S')
build_date := $(shell date -u '+%Y%m%d')
version := $(build_date):$(build_rev)
setup: setup:
@go mod download @go mod download
@@ -24,7 +22,7 @@ test:
build: build:
@go build -ldflags "-X main.Version=$(version)" -o build/codapi -v cmd/main.go @go build -ldflags "-X main.commit=$(build_rev) -X main.date=$(build_date)" -o build/codapi -v cmd/main.go
run: run:
@./build/codapi @./build/codapi

View File

@@ -1,19 +1,17 @@
# Embeddable code playgrounds # Interactive code examples
_for education, documentation, and fun_ 🎉 _for documentation, education and fun_ 🎉
Codapi is a platform for embedding interactive code snippets directly into your product documentation, online course, or blog post. Codapi is a platform for embedding interactive code snippets directly into your product documentation, online course or blog post.
``` ```
python
┌───────────────────────────────┐ ┌───────────────────────────────┐
msg = "Hello, World!" def greet(name):
│ print(msg) print(f"Hello, {name}!")
│ │ │ │
greet("World")
│ run ► │
└───────────────────────────────┘ └───────────────────────────────┘
✓ Done Run ► Edit ✓ Done
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ Hello, World! │ │ Hello, World! │
└───────────────────────────────┘ └───────────────────────────────┘
@@ -23,12 +21,12 @@ Codapi manages sandboxes (isolated execution environments) and provides an API t
Highlights: Highlights:
- Supports dozens of playgrounds out of the box, plus custom sandboxes if you need them. - Automatically converts static code examples into mini-playgrounds.
- Available as a cloud service and as a self-hosted version.
- Open source. Uses the permissive Apache-2.0 license.
- Lightweight and easy to integrate. - Lightweight and easy to integrate.
- Sandboxes for any programming language, database, or software.
- Open source. Uses the permissive Apache-2.0 license.
Learn more at [**codapi.org**](https://codapi.org/) For an introduction to Codapi, see this post: [Interactive code examples for fun and profit](https://antonz.org/code-examples/).
## Installation ## Installation
@@ -51,7 +49,7 @@ content-type: application/json
} }
``` ```
`sandbox` is the name of the pre-configured sandbox, and `command` is the name of a command supported by that sandbox. See [Configuration](docs/config.md) for details. `sandbox` is the name of the pre-configured sandbox, and `command` is the name of a command supported by that sandbox. See [Adding a sandbox](docs/add-sandbox.md) for details on how to add a new sandbox.
`files` is a map, where the key is a filename and the value is its contents. When executing a single file, it should either be named as the `command` expects, or be an empty string (as in the example above). `files` is a map, where the key is a filename and the value is its contents. When executing a single file, it should either be named as the `command` expects, or be an empty string (as in the example above).
@@ -82,7 +80,7 @@ See [codapi-js](https://github.com/nalgeon/codapi-js) to embed the JavaScript wi
## License ## License
Copyright 2023 [Anton Zhiyanov](https://antonz.org/). Copyright 2023 [Anton Zhiyanov](https://antonz.org/).
The software is available under the Apache-2.0 license. The software is available under the Apache-2.0 license.
## Stay tuned ## Stay tuned
@@ -94,4 +92,4 @@ The software is available under the Apache-2.0 license.
## Stay tuned ## Stay tuned
★ [**Subscribe**](https://antonz.org/subscribe/) to stay on top of new features. ★ [Subscribe](https://antonz.org/subscribe/) to stay on top of new features.

View File

@@ -7,17 +7,22 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
"github.com/nalgeon/codapi/sandbox" "github.com/nalgeon/codapi/internal/sandbox"
"github.com/nalgeon/codapi/server" "github.com/nalgeon/codapi/internal/server"
) )
var Version string = "main" // set by the build process
var (
version = "main"
commit = "none"
date = "unknown"
)
// startServer starts the HTTP API sandbox server. // startServer starts the HTTP API sandbox server.
func startServer(port int) *server.Server { func startServer(port int) *server.Server {
logx.Log("codapi %s", Version) logx.Log("codapi %s, commit %s, built at %s", version, commit, date)
logx.Log("listening on port %d...", port) logx.Log("listening on port %d...", port)
router := server.NewRouter() router := server.NewRouter()
srv := server.NewServer(port, router) srv := server.NewServer(port, router)
@@ -42,7 +47,7 @@ func main() {
port := flag.Int("port", 1313, "server port") port := flag.Int("port", 1313, "server port")
flag.Parse() flag.Parse()
cfg, err := config.Read("config.json", "boxes.json", "commands.json") cfg, err := config.Read("configs")
if err != nil { if err != nil {
logx.Log("missing config file") logx.Log("missing config file")
os.Exit(1) os.Exit(1)

View File

@@ -1,14 +0,0 @@
{
"sh": {
"run": {
"engine": "docker",
"entry": "main.sh",
"steps": [
{
"box": "alpine",
"command": ["sh", "main.sh"]
}
]
}
}
}

View File

@@ -1,94 +0,0 @@
package config
import (
"encoding/json"
"os"
)
// Read reads application config from JSON files.
func Read(cfgPath, boxPath, cmdPath string) (*Config, error) {
cfg, err := ReadConfig(cfgPath)
if err != nil {
return nil, err
}
cfg, err = ReadBoxes(cfg, boxPath)
if err != nil {
return nil, err
}
cfg, err = ReadCommands(cfg, cmdPath)
if err != nil {
return nil, err
}
return cfg, err
}
// ReadConfig reads application config from a JSON file.
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := &Config{}
err = json.Unmarshal(data, cfg)
if err != nil {
return nil, err
}
return cfg, err
}
// ReadBoxes reads boxes config from a JSON file.
func ReadBoxes(cfg *Config, path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
boxes := make(map[string]*Box)
err = json.Unmarshal(data, &boxes)
if err != nil {
return nil, err
}
for _, box := range boxes {
setBoxDefaults(box, cfg.Box)
}
cfg.Boxes = boxes
return cfg, err
}
// ReadCommands reads commands config from a JSON file.
func ReadCommands(cfg *Config, path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
commands := make(map[string]SandboxCommands)
err = json.Unmarshal(data, &commands)
if err != nil {
return nil, err
}
for _, playCmds := range commands {
for _, cmd := range playCmds {
if cmd.Before != nil {
setStepDefaults(cmd.Before, cfg.Step)
}
for _, step := range cmd.Steps {
setStepDefaults(step, cfg.Step)
}
if cmd.After != nil {
setStepDefaults(cmd.After, cfg.Step)
}
}
}
cfg.Commands = commands
return cfg, err
}

View File

@@ -1,25 +0,0 @@
{
"python": {
"run": {
"engine": "docker",
"entry": "main.py",
"steps": [
{
"box": "python",
"command": ["python", "main.py"]
}
]
},
"test": {
"engine": "docker",
"entry": "test_main.py",
"steps": [
{
"box": "python",
"command": ["python", "-m", "unittest"],
"noutput": 8192
}
]
}
}
}

12
configs/commands/sh.json Normal file
View File

@@ -0,0 +1,12 @@
{
"run": {
"engine": "docker",
"entry": "main.sh",
"steps": [
{
"box": "alpine",
"command": ["sh", "main.sh"]
}
]
}
}

125
docs/add-sandbox.md Normal file
View File

@@ -0,0 +1,125 @@
# Adding a sandbox
A _sandbox_ is an isolated execution environment for running code snippets. A sandbox is typically implemented as one or more Docker containers. A sandbox supports at least one _command_ (usually the `run` one), but it can support more (like `test` or any other).
Codapi comes with a single `sh` sandbox preinstalled, but you can easily add others. Let's see some examples.
## Python
First, let's create a Docker image capable of running Python with some third-party packages:
```sh
cd /opt/codapi
mkdir images/python
touch images/python/Dockerfile
touch images/python/requirements.txt
```
Fill the `Dockerfile`:
```Dockerfile
FROM python:3.11-alpine
RUN adduser --home /sandbox --disabled-password sandbox
COPY requirements.txt /tmp
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm -f /tmp/requirements.txt
USER sandbox
WORKDIR /sandbox
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
```
And the `requirements.txt`:
```
numpy==1.26.2
pandas==2.1.3
```
Build the image:
```sh
docker build --file images/python/Dockerfile --tag codapi/python:latest images/python/
```
And register the image as a Codapi _box_ in `configs/boxes.json`:
```js
{
// ...
"python": {
"image": "codapi/python"
}
}
```
Finally, let's configure what happens when the client executes the `run` command in the `python` sandbox. To do this, we create `configs/commands/python.json`:
```js
{
"run": {
"engine": "docker",
"entry": "main.py",
"steps": [
{
"box": "python",
"command": ["python", "main.py"]
}
]
}
}
```
This is essentially what it says:
> When the client executes the `run` command in the `python` sandbox, save their code to the `main.py` file, then run it in the `python` box (Docker container) using the `python main.py` shell command.
What if we want to add another command (say, `test`) to the same sandbox? Let's edit `configs/commands/python.json` again:
```js
{
"run": {
// ...
},
"test": {
"engine": "docker",
"entry": "test_main.py",
"steps": [
{
"box": "python",
"command": ["python", "-m", "unittest"],
"noutput": 8192
}
]
}
}
```
Besides configuring a different shell command, here we increased the maximum output size to 8Kb, as tests tend to be quite chatty (you can see the default value in `configs/config.json`).
To apply the changed configuration, restart Codapi (as root):
```sh
systemctl restart codapi.service
```
And try running some Python code:
```sh
curl -H "content-type: application/json" -d '{ "sandbox": "python", "command": "run", "files": {"": "print(42)" }}' http://localhost:1313/v1/exec
```
Which produces the following output:
```json
{
"id": "python_run_7683de5a",
"ok": true,
"duration": 252,
"stdout": "42\n",
"stderr": ""
}
```

View File

@@ -1,5 +1,7 @@
# Installing Codapi # Installing Codapi
Make sure you install Codapi on a separate machine — this is a must for security reasons. Do not store any sensitive data or credentials on this machine. This way, even if someone runs malicious code that somehow escapes the isolated environment, they won't have access to your other machines and data.
Steps for Debian (11/12) or Ubuntu (20.04/22.04). Steps for Debian (11/12) or Ubuntu (20.04/22.04).
1. Install necessary packages (as root): 1. Install necessary packages (as root):
@@ -26,24 +28,24 @@ docker run hello-world
```sh ```sh
cd /opt/codapi cd /opt/codapi
curl -L -o codapi.zip "https://api.github.com/repos/nalgeon/codapi/actions/artifacts/926428361/zip" curl -L -O "https://github.com/nalgeon/codapi/releases/download/0.6.0/codapi_0.6.0_linux_amd64.tar.gz"
unzip -u codapi.zip tar xvzf codapi_0.6.0_linux_amd64.tar.gz
chmod +x build/codapi chmod +x codapi
rm -f codapi.zip rm -f codapi_0.6.0_linux_amd64.tar.gz
``` ```
6. Build Docker images (as codapi): 5. Build Docker images (as codapi):
```sh ```sh
cd /opt/codapi cd /opt/codapi
make images docker build --file images/alpine/Dockerfile --tag codapi/alpine:latest images/alpine/
``` ```
7. Verify that Codapi starts without errors (as codapi): 6. Verify that Codapi starts without errors (as codapi):
```sh ```sh
cd /opt/codapi cd /opt/codapi
./build/codapi ./codapi
``` ```
Should print the `alpine` box and the `sh` command: Should print the `alpine` box and the `sh` command:
@@ -58,7 +60,7 @@ Should print the `alpine` box and the `sh` command:
Stop it with Ctrl+C. Stop it with Ctrl+C.
8. Configure Codapi as systemd service (as root): 7. Configure Codapi as systemd service (as root):
```sh ```sh
mv /opt/codapi/codapi.service /etc/systemd/system/ mv /opt/codapi/codapi.service /etc/systemd/system/
@@ -82,7 +84,7 @@ codapi.service - Code playgrounds
... ...
``` ```
9. Verify that Codapi is working: 8. Verify that Codapi is working:
```sh ```sh
curl -H "content-type: application/json" -d '{ "sandbox": "sh", "command": "run", "files": {"": "echo hello" }}' http://localhost:1313/v1/exec curl -H "content-type: application/json" -d '{ "sandbox": "sh", "command": "run", "files": {"": "echo hello" }}' http://localhost:1313/v1/exec

View File

@@ -1,39 +0,0 @@
// Package fileio provides high-level file operations.
package fileio
import (
"io"
"os"
"path/filepath"
)
// CopyFile copies all files matching the pattern
// to the destination directory.
func CopyFiles(pattern string, dstDir string) error {
matches, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, match := range matches {
src, err := os.Open(match)
if err != nil {
return err
}
defer src.Close()
dstFile := filepath.Join(dstDir, filepath.Base(match))
dst, err := os.Create(dstFile)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,56 +0,0 @@
package fileio
import (
"os"
"path/filepath"
"testing"
)
func TestCopyFiles(t *testing.T) {
// Create a temporary directory for testing
srcDir, err := os.MkdirTemp("", "src")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(srcDir)
// Create a source file
srcFile := filepath.Join(srcDir, "source.txt")
err = os.WriteFile(srcFile, []byte("test data"), 0644)
if err != nil {
t.Fatal(err)
}
// Specify the destination directory
dstDir, err := os.MkdirTemp("", "dst")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dstDir)
// Call the CopyFiles function
pattern := filepath.Join(srcDir, "*.txt")
err = CopyFiles(pattern, dstDir)
if err != nil {
t.Fatal(err)
}
// Verify that the file was copied correctly
dstFile := filepath.Join(dstDir, "source.txt")
_, err = os.Stat(dstFile)
if err != nil {
t.Fatalf("file not copied: %s", err)
}
// Read the contents of the copied file
data, err := os.ReadFile(dstFile)
if err != nil {
t.Fatal(err)
}
// Verify the contents of the copied file
expected := []byte("test data")
if string(data) != string(expected) {
t.Errorf("unexpected file content: got %q, want %q", data, expected)
}
}

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/nalgeon/codapi module github.com/nalgeon/codapi
go 1.20 go 1.21

View File

@@ -96,6 +96,7 @@ type Command struct {
// A Step describes a single step of a command. // A Step describes a single step of a command.
type Step struct { type Step struct {
Box string `json:"box"` Box string `json:"box"`
Version string `json:"version"`
User string `json:"user"` User string `json:"user"`
Action string `json:"action"` Action string `json:"action"`
Stdin bool `json:"stdin"` Stdin bool `json:"stdin"`

109
internal/config/load.go Normal file
View File

@@ -0,0 +1,109 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/nalgeon/codapi/internal/fileio"
)
const (
configFilename = "config.json"
boxesFilename = "boxes.json"
commandsDirname = "commands"
)
// Read reads application config from JSON files.
func Read(path string) (*Config, error) {
cfg, err := ReadConfig(filepath.Join(path, configFilename))
if err != nil {
return nil, err
}
cfg, err = ReadBoxes(cfg, filepath.Join(path, boxesFilename))
if err != nil {
return nil, err
}
cfg, err = ReadCommands(cfg, filepath.Join(path, commandsDirname))
if err != nil {
return nil, err
}
return cfg, err
}
// ReadConfig reads application config from a JSON file.
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := &Config{}
err = json.Unmarshal(data, cfg)
if err != nil {
return nil, err
}
return cfg, err
}
// ReadBoxes reads boxes config from a JSON file.
func ReadBoxes(cfg *Config, path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
boxes := make(map[string]*Box)
err = json.Unmarshal(data, &boxes)
if err != nil {
return nil, err
}
for _, box := range boxes {
setBoxDefaults(box, cfg.Box)
}
cfg.Boxes = boxes
return cfg, err
}
// ReadCommands reads commands config from a JSON file.
func ReadCommands(cfg *Config, path string) (*Config, error) {
fnames, err := filepath.Glob(filepath.Join(path, "*.json"))
if err != nil {
return nil, err
}
cfg.Commands = make(map[string]SandboxCommands, len(fnames))
for _, fname := range fnames {
sandbox := strings.TrimSuffix(filepath.Base(fname), ".json")
commands, err := fileio.ReadJson[SandboxCommands](fname)
if err != nil {
break
}
setCommandDefaults(commands, cfg)
cfg.Commands[sandbox] = commands
}
return cfg, err
}
// setCommandDefaults applies global defaults to sandbox commands.
func setCommandDefaults(commands SandboxCommands, cfg *Config) {
for _, cmd := range commands {
if cmd.Before != nil {
setStepDefaults(cmd.Before, cfg.Step)
}
for _, step := range cmd.Steps {
setStepDefaults(step, cfg.Step)
}
if cmd.After != nil {
setStepDefaults(cmd.After, cfg.Step)
}
}
}

View File

@@ -1,15 +1,11 @@
package config package config
import ( import (
"path/filepath"
"testing" "testing"
) )
func TestRead(t *testing.T) { func TestRead(t *testing.T) {
cfgPath := filepath.Join("testdata", "config.json") cfg, err := Read("testdata")
boxPath := filepath.Join("testdata", "boxes.json")
cmdPath := filepath.Join("testdata", "commands.json")
cfg, err := Read(cfgPath, boxPath, cmdPath)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }

View File

@@ -0,0 +1,23 @@
{
"run": {
"engine": "docker",
"entry": "main.py",
"steps": [
{
"box": "python",
"command": ["python", "main.py"]
}
]
},
"test": {
"engine": "docker",
"entry": "test_main.py",
"steps": [
{
"box": "python",
"command": ["python", "-m", "unittest"],
"noutput": 8192
}
]
}
}

View File

@@ -8,15 +8,14 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/execy" "github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/fileio" "github.com/nalgeon/codapi/internal/fileio"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
var killTimeout = 5 * time.Second var killTimeout = 5 * time.Second
@@ -42,7 +41,7 @@ func NewDocker(cfg *config.Config, sandbox, command string) Engine {
// Exec executes the command and returns the output. // Exec executes the command and returns the output.
func (e *Docker) Exec(req Request) Execution { func (e *Docker) Exec(req Request) Execution {
// all steps operate in the same temp directory // all steps operate in the same temp directory
dir, err := os.MkdirTemp("", "") dir, err := fileio.MkdirTemp(0777)
if err != nil { if err != nil {
err = NewExecutionError("create temp dir", err) err = NewExecutionError("create temp dir", err)
return Fail(req.ID, err) return Fail(req.ID, err)
@@ -54,7 +53,10 @@ func (e *Docker) Exec(req Request) Execution {
if e.cmd.Entry != "" { if e.cmd.Entry != "" {
// write request files to the temp directory // write request files to the temp directory
err = e.writeFiles(dir, req.Files) err = e.writeFiles(dir, req.Files)
if err != nil { var argErr ArgumentError
if errors.As(err, &argErr) {
return Fail(req.ID, err)
} else if err != nil {
err = NewExecutionError("write files to temp dir", err) err = NewExecutionError("write files to temp dir", err)
return Fail(req.ID, err) return Fail(req.ID, err)
} }
@@ -62,7 +64,7 @@ func (e *Docker) Exec(req Request) Execution {
// initialization step // initialization step
if e.cmd.Before != nil { if e.cmd.Before != nil {
out := e.execStep(e.cmd.Before, req.ID, dir, nil) out := e.execStep(e.cmd.Before, req, dir, nil)
if !out.OK { if !out.OK {
return out return out
} }
@@ -70,14 +72,14 @@ func (e *Docker) Exec(req Request) Execution {
// the first step is required // the first step is required
first, rest := e.cmd.Steps[0], e.cmd.Steps[1:] first, rest := e.cmd.Steps[0], e.cmd.Steps[1:]
out := e.execStep(first, req.ID, dir, req.Files) out := e.execStep(first, req, dir, req.Files)
// the rest are optional // the rest are optional
if out.OK && len(rest) > 0 { if out.OK && len(rest) > 0 {
// each step operates on the results of the previous one, // each step operates on the results of the previous one,
// without using the source files - hence `nil` instead of `files` // without using the source files - hence `nil` instead of `files`
for _, step := range rest { for _, step := range rest {
out = e.execStep(step, req.ID, dir, nil) out = e.execStep(step, req, dir, nil)
if !out.OK { if !out.OK {
break break
} }
@@ -86,7 +88,7 @@ func (e *Docker) Exec(req Request) Execution {
// cleanup step // cleanup step
if e.cmd.After != nil { if e.cmd.After != nil {
afterOut := e.execStep(e.cmd.After, req.ID, dir, nil) afterOut := e.execStep(e.cmd.After, req, dir, nil)
if out.OK && !afterOut.OK { if out.OK && !afterOut.OK {
return afterOut return afterOut
} }
@@ -96,34 +98,67 @@ func (e *Docker) Exec(req Request) Execution {
} }
// execStep executes a step using the docker container. // execStep executes a step using the docker container.
func (e *Docker) execStep(step *config.Step, reqID, dir string, files Files) Execution { func (e *Docker) execStep(step *config.Step, req Request, dir string, files Files) Execution {
box := e.cfg.Boxes[step.Box] box, err := e.getBox(step, req)
err := e.copyFiles(box, dir)
if err != nil { if err != nil {
err = NewExecutionError("copy files to temp dir", err) return Fail(req.ID, err)
return Fail(reqID, err)
} }
stdout, stderr, err := e.exec(box, step, reqID, dir, files) err = e.copyFiles(box, dir)
if err != nil { if err != nil {
return Fail(reqID, err) err = NewExecutionError("copy files to temp dir", err)
return Fail(req.ID, err)
}
stdout, stderr, err := e.exec(box, step, req, dir, files)
if err != nil {
return Fail(req.ID, err)
} }
return Execution{ return Execution{
ID: reqID, ID: req.ID,
OK: true, OK: true,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
} }
} }
// getBox selects an appropriate box for the step (if any).
func (e *Docker) getBox(step *config.Step, req Request) (*config.Box, error) {
if step.Action == actionExec {
// exec steps use existing instances
// and do not spin up new boxes
return nil, nil
}
var boxName string
// If the version is set in the step config, use it.
if step.Version != "" {
if step.Version == "latest" {
boxName = step.Box
} else {
boxName = step.Box + ":" + step.Version
}
} else if req.Version != "" {
// If the version is set in the request, use it.
boxName = step.Box + ":" + req.Version
} else {
// otherwise, use the latest version
boxName = step.Box
}
box, found := e.cfg.Boxes[boxName]
if !found {
return nil, fmt.Errorf("unknown box %s", boxName)
}
return box, nil
}
// copyFiles copies box files to the temporary directory. // copyFiles copies box files to the temporary directory.
func (e *Docker) copyFiles(box *config.Box, dir string) error { func (e *Docker) copyFiles(box *config.Box, dir string) error {
if box == nil || len(box.Files) == 0 { if box == nil || len(box.Files) == 0 {
return nil return nil
} }
for _, pattern := range box.Files { for _, pattern := range box.Files {
err := fileio.CopyFiles(pattern, dir) err := fileio.CopyFiles(pattern, dir, 0444)
if err != nil { if err != nil {
return err return err
} }
@@ -138,8 +173,13 @@ func (e *Docker) writeFiles(dir string, files Files) error {
if name == "" { if name == "" {
name = e.cmd.Entry name = e.cmd.Entry
} }
path := filepath.Join(dir, name) var path string
err = os.WriteFile(path, []byte(content), 0444) path, err = fileio.JoinDir(dir, name)
if err != nil {
err = NewArgumentError(fmt.Sprintf("files[%s]", name), err)
return false
}
err = fileio.WriteFile(path, content, 0444)
return err == nil return err == nil
}) })
return err return err
@@ -147,18 +187,18 @@ func (e *Docker) writeFiles(dir string, files Files) error {
// exec executes the step in the docker container // exec executes the step in the docker container
// using the files from in the temporary directory. // 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) { func (e *Docker) exec(box *config.Box, step *config.Step, req Request, dir string, files Files) (stdout string, stderr string, err error) {
// limit the stdout/stderr size // limit the stdout/stderr size
prog := NewProgram(step.Timeout, int64(step.NOutput)) prog := NewProgram(step.Timeout, int64(step.NOutput))
args := e.buildArgs(box, step, reqID, dir) args := e.buildArgs(box, step, req, dir)
if step.Stdin { if step.Stdin {
// pass files to container from stdin // pass files to container from stdin
stdin := filesReader(files) stdin := filesReader(files)
stdout, stderr, err = prog.RunStdin(stdin, reqID, "docker", args...) stdout, stderr, err = prog.RunStdin(stdin, req.ID, "docker", args...)
} else { } else {
// pass files to container from temp directory // pass files to container from temp directory
stdout, stderr, err = prog.Run(reqID, "docker", args...) stdout, stderr, err = prog.Run(req.ID, "docker", args...)
} }
if err == nil { if err == nil {
@@ -172,11 +212,11 @@ func (e *Docker) exec(box *config.Box, step *config.Step, reqID, dir string, fil
// inside the container is not related to the "docker run" process, // inside the container is not related to the "docker run" process,
// and will hang forever after the "docker run" process is killed // and will hang forever after the "docker run" process is killed
go func() { go func() {
err = dockerKill(reqID) err = dockerKill(req.ID)
if err == nil { if err == nil {
logx.Debug("%s: docker kill ok", reqID) logx.Debug("%s: docker kill ok", req.ID)
} else { } else {
logx.Log("%s: docker kill failed: %v", reqID, err) logx.Log("%s: docker kill failed: %v", req.ID, err)
} }
}() }()
} }
@@ -202,10 +242,10 @@ func (e *Docker) exec(box *config.Box, step *config.Step, reqID, dir string, fil
} }
// buildArgs prepares the arguments for the `docker` command. // buildArgs prepares the arguments for the `docker` command.
func (e *Docker) buildArgs(box *config.Box, step *config.Step, name, dir string) []string { func (e *Docker) buildArgs(box *config.Box, step *config.Step, req Request, dir string) []string {
var args []string var args []string
if step.Action == actionRun { if step.Action == actionRun {
args = dockerRunArgs(box, step, name, dir) args = dockerRunArgs(box, step, req, dir)
} else if step.Action == actionExec { } else if step.Action == actionExec {
args = dockerExecArgs(step) args = dockerExecArgs(step)
} else { } else {
@@ -213,17 +253,17 @@ func (e *Docker) buildArgs(box *config.Box, step *config.Step, name, dir string)
args = []string{"version"} args = []string{"version"}
} }
command := expandVars(step.Command, name) command := expandVars(step.Command, req.ID)
args = append(args, command...) args = append(args, command...)
logx.Debug("%v", args) logx.Debug("%v", args)
return args return args
} }
// buildArgs prepares the arguments for the `docker run` command. // buildArgs prepares the arguments for the `docker run` command.
func dockerRunArgs(box *config.Box, step *config.Step, name, dir string) []string { func dockerRunArgs(box *config.Box, step *config.Step, req Request, dir string) []string {
args := []string{ args := []string{
actionRun, "--rm", actionRun, "--rm",
"--name", name, "--name", req.ID,
"--runtime", box.Runtime, "--runtime", box.Runtime,
"--cpus", strconv.Itoa(box.CPU), "--cpus", strconv.Itoa(box.CPU),
"--memory", fmt.Sprintf("%dm", box.Memory), "--memory", fmt.Sprintf("%dm", box.Memory),

View File

@@ -1,18 +1,37 @@
package engine package engine
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/execy" "github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
var dockerCfg = &config.Config{ var dockerCfg = &config.Config{
Boxes: map[string]*config.Box{ Boxes: map[string]*config.Box{
"postgresql": { "alpine": {
Image: "codapi/postgresql", Image: "codapi/alpine",
Runtime: "runc",
Host: config.Host{
CPU: 1, Memory: 64, Network: "none",
Volume: "%s:/sandbox:ro",
NProc: 64,
},
},
"go": {
Image: "codapi/go",
Runtime: "runc",
Host: config.Host{
CPU: 1, Memory: 64, Network: "none",
Volume: "%s:/sandbox:ro",
NProc: 64,
},
},
"go:dev": {
Image: "codapi/go:dev",
Runtime: "runc", Runtime: "runc",
Host: config.Host{ Host: config.Host{
CPU: 1, Memory: 64, Network: "none", CPU: 1, Memory: 64, Network: "none",
@@ -29,8 +48,35 @@ var dockerCfg = &config.Config{
NProc: 64, NProc: 64,
}, },
}, },
"python:dev": {
Image: "codapi/python:dev",
Runtime: "runc",
Host: config.Host{
CPU: 1, Memory: 64, Network: "none",
Volume: "%s:/sandbox:ro",
NProc: 64,
},
},
}, },
Commands: map[string]config.SandboxCommands{ Commands: map[string]config.SandboxCommands{
"go": map[string]*config.Command{
"run": {
Engine: "docker",
Steps: []*config.Step{
{
Box: "go", User: "sandbox", Action: "run",
Command: []string{"go", "build"},
NOutput: 4096,
},
{
Box: "alpine", Version: "latest",
User: "sandbox", Action: "run",
Command: []string{"./main"},
NOutput: 4096,
},
},
},
},
"postgresql": map[string]*config.Command{ "postgresql": map[string]*config.Command{
"run": { "run": {
Engine: "docker", Engine: "docker",
@@ -75,9 +121,10 @@ func TestDockerRun(t *testing.T) {
"docker run": {Stdout: "hello world", Stderr: "", Err: nil}, "docker run": {Stdout: "hello world", Stderr: "", Err: nil},
} }
mem := execy.Mock(commands) mem := execy.Mock(commands)
engine := NewDocker(dockerCfg, "python", "run")
t.Run("success", func(t *testing.T) { t.Run("success", func(t *testing.T) {
mem.Clear()
engine := NewDocker(dockerCfg, "python", "run")
req := Request{ req := Request{
ID: "http_42", ID: "http_42",
Sandbox: "python", Sandbox: "python",
@@ -103,8 +150,111 @@ func TestDockerRun(t *testing.T) {
if out.Err != nil { if out.Err != nil {
t.Errorf("Err: expected nil, got %v", out.Err) t.Errorf("Err: expected nil, got %v", out.Err)
} }
mem.MustHave(t, "codapi/python")
mem.MustHave(t, "python main.py") mem.MustHave(t, "python main.py")
}) })
t.Run("latest version", func(t *testing.T) {
mem.Clear()
engine := NewDocker(dockerCfg, "python", "run")
req := Request{
ID: "http_42",
Sandbox: "python",
Command: "run",
Files: map[string]string{
"": "print('hello world')",
},
}
out := engine.Exec(req)
if !out.OK {
t.Error("OK: expected true")
}
mem.MustHave(t, "codapi/python")
})
t.Run("custom version", func(t *testing.T) {
mem.Clear()
engine := NewDocker(dockerCfg, "python", "run")
req := Request{
ID: "http_42",
Sandbox: "python",
Version: "dev",
Command: "run",
Files: map[string]string{
"": "print('hello world')",
},
}
out := engine.Exec(req)
if !out.OK {
t.Error("OK: expected true")
}
mem.MustHave(t, "codapi/python:dev")
})
t.Run("step version", func(t *testing.T) {
mem.Clear()
engine := NewDocker(dockerCfg, "go", "run")
req := Request{
ID: "http_42",
Sandbox: "go",
Version: "dev",
Command: "run",
Files: map[string]string{
"": "var n = 42",
},
}
out := engine.Exec(req)
if !out.OK {
t.Error("OK: expected true")
}
mem.MustHave(t, "codapi/go:dev")
mem.MustHave(t, "codapi/alpine")
})
t.Run("unsupported version", func(t *testing.T) {
mem.Clear()
engine := NewDocker(dockerCfg, "python", "run")
req := Request{
ID: "http_42",
Sandbox: "python",
Version: "42",
Command: "run",
Files: map[string]string{
"": "print('hello world')",
},
}
out := engine.Exec(req)
if out.OK {
t.Error("OK: expected false")
}
want := "unknown box python:42"
if out.Stderr != want {
t.Errorf("Stderr: unexpected value: %s", out.Stderr)
}
})
t.Run("directory traversal attack", func(t *testing.T) {
mem.Clear()
const fileName = "../../opt/codapi/codapi"
engine := NewDocker(dockerCfg, "python", "run")
req := Request{
ID: "http_42",
Sandbox: "python",
Command: "run",
Files: map[string]string{
"": "print('hello world')",
fileName: "hehe",
},
}
out := engine.Exec(req)
if out.OK {
t.Error("OK: expected false")
}
want := fmt.Sprintf("files[%s]: invalid name", fileName)
if out.Stderr != want {
t.Errorf("Stderr: unexpected value: %s", out.Stderr)
}
})
} }
func TestDockerExec(t *testing.T) { func TestDockerExec(t *testing.T) {

View File

@@ -5,20 +5,25 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/nalgeon/codapi/stringx" "github.com/nalgeon/codapi/internal/stringx"
) )
// A Request initiates code execution. // A Request initiates code execution.
type Request struct { type Request struct {
ID string `json:"id"` ID string `json:"id"`
Sandbox string `json:"sandbox"` Sandbox string `json:"sandbox"`
Version string `json:"version,omitempty"`
Command string `json:"command"` Command string `json:"command"`
Files Files `json:"files"` Files Files `json:"files"`
} }
// GenerateID() sets a unique ID for the request. // GenerateID() sets a unique ID for the request.
func (r *Request) GenerateID() { func (r *Request) GenerateID() {
if r.Version != "" {
r.ID = fmt.Sprintf("%s.%s_%s_%s", r.Sandbox, r.Version, r.Command, stringx.RandString(8))
} else {
r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8)) r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8))
}
} }
// An Execution is an output from the code execution engine. // An Execution is an output from the code execution engine.
@@ -57,6 +62,25 @@ func (err ExecutionError) Unwrap() error {
return err.inner return err.inner
} }
// An ArgumentError is returned if code execution failed
// due to the invalid value of the request agrument.
type ArgumentError struct {
name string
reason error
}
func NewArgumentError(name string, reason error) ArgumentError {
return ArgumentError{name: name, reason: reason}
}
func (err ArgumentError) Error() string {
return err.name + ": " + err.reason.Error()
}
func (err ArgumentError) Unwrap() error {
return err.reason
}
// Files are a collection of files to be executed by the engine. // Files are a collection of files to be executed by the engine.
type Files map[string]string type Files map[string]string

View File

@@ -4,9 +4,34 @@ import (
"errors" "errors"
"reflect" "reflect"
"sort" "sort"
"strings"
"testing" "testing"
) )
func TestGenerateID(t *testing.T) {
t.Run("with version", func(t *testing.T) {
req := Request{
Sandbox: "python",
Version: "dev",
Command: "run",
}
req.GenerateID()
if !strings.HasPrefix(req.ID, "python.dev_run_") {
t.Errorf("ID: unexpected prefix %s", req.ID)
}
})
t.Run("without version", func(t *testing.T) {
req := Request{
Sandbox: "python",
Command: "run",
}
req.GenerateID()
if !strings.HasPrefix(req.ID, "python_run_") {
t.Errorf("ID: unexpected prefix %s", req.ID)
}
})
}
func TestExecutionError(t *testing.T) { func TestExecutionError(t *testing.T) {
inner := errors.New("inner error") inner := errors.New("inner error")
err := NewExecutionError("failed", inner) err := NewExecutionError("failed", inner)

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/nalgeon/codapi/execy" "github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
// A Program is an executable program. // A Program is an executable program.

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/nalgeon/codapi/execy" "github.com/nalgeon/codapi/internal/execy"
) )
func TestProgram_Run(t *testing.T) { func TestProgram_Run(t *testing.T) {

View File

@@ -9,9 +9,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/httpx" "github.com/nalgeon/codapi/internal/httpx"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
// An HTTP engine sends HTTP requests. // An HTTP engine sends HTTP requests.

View File

@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/httpx" "github.com/nalgeon/codapi/internal/httpx"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
var httpCfg = &config.Config{ var httpCfg = &config.Config{

View File

@@ -4,7 +4,7 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
// Mock installs mock outputs for given commands. // Mock installs mock outputs for given commands.

124
internal/fileio/fileio.go Normal file
View File

@@ -0,0 +1,124 @@
// Package fileio provides high-level file operations.
package fileio
import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// CopyFile copies all files matching the pattern
// to the destination directory.
func CopyFiles(pattern string, dstDir string, perm fs.FileMode) error {
matches, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, match := range matches {
src, err := os.Open(match)
if err != nil {
return err
}
defer src.Close()
dstFile := filepath.Join(dstDir, filepath.Base(match))
dst, err := os.OpenFile(dstFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
if err != nil {
return err
}
}
return nil
}
// ReadJson reads the file and decodes it from JSON.
func ReadJson[T any](path string) (T, error) {
var obj T
data, err := os.ReadFile(path)
if err != nil {
return obj, err
}
err = json.Unmarshal(data, &obj)
if err != nil {
return obj, err
}
return obj, err
}
// WriteFile writes the file to disk.
// The content can be text or binary (encoded as a data URL),
// e.g. data:application/octet-stream;base64,MTIz
func WriteFile(path, content string, perm fs.FileMode) (err error) {
var data []byte
if strings.HasPrefix(content, "data:") {
// data-url encoded file
_, encoded, found := strings.Cut(content, ",")
if !found {
return errors.New("invalid data-url encoding")
}
data, err = base64.StdEncoding.DecodeString(encoded)
if err != nil {
return err
}
} else {
// text file
data = []byte(content)
}
return os.WriteFile(path, data, perm)
}
// JoinDir joins a directory path with a relative file path,
// making sure that the resulting path is still inside the directory.
// Returns an error otherwise.
func JoinDir(dir string, name string) (string, error) {
if dir == "" {
return "", errors.New("invalid dir")
}
cleanName := filepath.Clean(name)
if cleanName == "" {
return "", errors.New("invalid name")
}
if cleanName == "." || cleanName == "/" || filepath.IsAbs(cleanName) {
return "", errors.New("invalid name")
}
path := filepath.Join(dir, cleanName)
dirPrefix := filepath.Clean(dir)
if dirPrefix != "/" {
dirPrefix += string(os.PathSeparator)
}
if !strings.HasPrefix(path, dirPrefix) {
return "", errors.New("invalid name")
}
return path, nil
}
// MkdirTemp creates a new temporary directory with given permissions
// and returns the pathname of the new directory.
func MkdirTemp(perm fs.FileMode) (string, error) {
dir, err := os.MkdirTemp("", "")
if err != nil {
return "", err
}
err = os.Chmod(dir, perm)
if err != nil {
os.Remove(dir)
return "", err
}
return dir, nil
}

View File

@@ -0,0 +1,291 @@
package fileio
import (
"io/fs"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestCopyFiles(t *testing.T) {
// Create a temporary directory for testing
srcDir, err := os.MkdirTemp("", "src")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(srcDir)
// Create a source file
srcFile := filepath.Join(srcDir, "source.txt")
err = os.WriteFile(srcFile, []byte("test data"), 0644)
if err != nil {
t.Fatal(err)
}
// Specify the destination directory
dstDir, err := os.MkdirTemp("", "dst")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dstDir)
// Call the CopyFiles function
const perm = fs.FileMode(0444)
pattern := filepath.Join(srcDir, "*.txt")
err = CopyFiles(pattern, dstDir, perm)
if err != nil {
t.Fatal(err)
}
// Verify that the file was copied correctly
dstFile := filepath.Join(dstDir, "source.txt")
fileInfo, err := os.Stat(dstFile)
if err != nil {
t.Fatalf("file not copied: %s", err)
}
if fileInfo.Mode() != perm {
t.Errorf("unexpected file permissions: got %v, want %v", fileInfo.Mode(), perm)
}
// Read the contents of the copied file
data, err := os.ReadFile(dstFile)
if err != nil {
t.Fatal(err)
}
// Verify the contents of the copied file
expected := []byte("test data")
if string(data) != string(expected) {
t.Errorf("unexpected file content: got %q, want %q", data, expected)
}
}
func TestReadJson(t *testing.T) {
type Person struct{ Name string }
t.Run("valid", func(t *testing.T) {
got, err := ReadJson[Person](filepath.Join("testdata", "valid.json"))
if err != nil {
t.Fatalf("unexpected error %v", err)
}
want := Person{"alice"}
if !reflect.DeepEqual(got, want) {
t.Errorf("expected %v, got %v", want, got)
}
})
t.Run("invalid", func(t *testing.T) {
_, err := ReadJson[Person](filepath.Join("testdata", "invalid.json"))
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("does not exist", func(t *testing.T) {
_, err := ReadJson[Person](filepath.Join("testdata", "missing.json"))
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestWriteFile(t *testing.T) {
dir, err := os.MkdirTemp("", "files")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
t.Run("text", func(t *testing.T) {
path := filepath.Join(dir, "data.txt")
err = WriteFile(path, "hello", 0444)
if err != nil {
t.Fatalf("expected nil err, got %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: expected nil err, got %v", err)
}
want := []byte("hello")
if !reflect.DeepEqual(got, want) {
t.Errorf("read file: expected %v, got %v", want, got)
}
})
t.Run("binary", func(t *testing.T) {
path := filepath.Join(dir, "data.bin")
err = WriteFile(path, "data:application/octet-stream;base64,MTIz", 0444)
if err != nil {
t.Fatalf("expected nil err, got %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: expected nil err, got %v", err)
}
want := []byte("123")
if !reflect.DeepEqual(got, want) {
t.Errorf("read file: expected %v, got %v", want, got)
}
})
t.Run("perm", func(t *testing.T) {
const perm = 0444
path := filepath.Join(dir, "perm.txt")
err = WriteFile(path, "hello", perm)
if err != nil {
t.Fatalf("expected nil err, got %v", err)
}
fileInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("file not created: %s", err)
}
if fileInfo.Mode().Perm() != perm {
t.Errorf("unexpected file permissions: expected %o, got %o", perm, fileInfo.Mode().Perm())
}
})
t.Run("missing data-url separator", func(t *testing.T) {
path := filepath.Join(dir, "data.bin")
err = WriteFile(path, "data:application/octet-stream:MTIz", 0444)
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("invalid binary value", func(t *testing.T) {
path := filepath.Join(dir, "data.bin")
err = WriteFile(path, "data:application/octet-stream;base64,12345", 0444)
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestJoinDir(t *testing.T) {
tests := []struct {
name string
dir string
filename string
want string
wantErr bool
}{
{
name: "regular join",
dir: "/home/user",
filename: "docs/report.txt",
want: "/home/user/docs/report.txt",
wantErr: false,
},
{
name: "join with dot",
dir: "/home/user",
filename: ".",
want: "",
wantErr: true,
},
{
name: "join with absolute path",
dir: "/home/user",
filename: "/etc/passwd",
want: "",
wantErr: true,
},
{
name: "join with parent directory",
dir: "/home/user",
filename: "../user2/docs/report.txt",
want: "",
wantErr: true,
},
{
name: "empty directory",
dir: "",
filename: "report.txt",
want: "",
wantErr: true,
},
{
name: "empty filename",
dir: "/home/user",
filename: "",
want: "",
wantErr: true,
},
{
name: "directory with trailing slash",
dir: "/home/user/",
filename: "docs/report.txt",
want: "/home/user/docs/report.txt",
wantErr: false,
},
{
name: "filename with leading slash",
dir: "/home/user",
filename: "/docs/report.txt",
want: "",
wantErr: true,
},
{
name: "root directory",
dir: "/",
filename: "report.txt",
want: "/report.txt",
wantErr: false,
},
{
name: "dot dot slash filename",
dir: "/home/user",
filename: "..",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := JoinDir(tt.dir, tt.filename)
if (err != nil) != tt.wantErr {
t.Errorf("JoinDir() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("JoinDir() = %v, want %v", got, tt.want)
}
})
}
}
func TestMkdirTemp(t *testing.T) {
t.Run("default permissions", func(t *testing.T) {
const perm = 0755
dir, err := MkdirTemp(perm)
if err != nil {
t.Fatalf("failed to create temp directory: %v", err)
}
defer os.Remove(dir)
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("failed to stat temp directory: %v", err)
}
if info.Mode().Perm() != perm {
t.Errorf("unexpected permissions: expected %o, got %o", perm, info.Mode().Perm())
}
})
t.Run("non-default permissions", func(t *testing.T) {
const perm = 0777
dir, err := MkdirTemp(perm)
if err != nil {
t.Fatalf("failed to create temp directory: %v", err)
}
defer os.Remove(dir)
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("failed to stat temp directory: %v", err)
}
if info.Mode().Perm() != perm {
t.Errorf("unexpected permissions: expected %o, got %o", perm, info.Mode().Perm())
}
})
}

1
internal/fileio/testdata/invalid.json vendored Normal file
View File

@@ -0,0 +1 @@
name: alice

3
internal/fileio/testdata/valid.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"name": "alice"
}

View File

@@ -53,9 +53,14 @@ func (m *Memory) MustNotHave(t *testing.T, msg string) {
} }
} }
// Clear cleares the memory.
func (m *Memory) Clear() {
m.Lines = []string{}
}
// Print prints memory lines to stdout. // Print prints memory lines to stdout.
func (m *Memory) Print() { func (m *Memory) Print() {
for _, line := range m.Lines { for _, line := range m.Lines {
fmt.Print(line) fmt.Println(line)
} }
} }

View File

@@ -4,8 +4,8 @@ package sandbox
import ( import (
"fmt" "fmt"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
) )
// A semaphore represents available concurrent workers // A semaphore represents available concurrent workers

View File

@@ -3,8 +3,8 @@ package sandbox
import ( import (
"testing" "testing"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
) )
var cfg = &config.Config{ var cfg = &config.Config{

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
) )
var ErrUnknownSandbox = errors.New("unknown sandbox") var ErrUnknownSandbox = errors.New("unknown sandbox")

View File

@@ -4,8 +4,8 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
"github.com/nalgeon/codapi/execy" "github.com/nalgeon/codapi/internal/execy"
) )
func TestValidate(t *testing.T) { func TestValidate(t *testing.T) {

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
) )
func Test_readJson(t *testing.T) { func Test_readJson(t *testing.T) {

View File

@@ -7,7 +7,7 @@ import "net/http"
func enableCORS(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { func enableCORS(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("access-control-allow-origin", "*") w.Header().Set("access-control-allow-origin", "*")
w.Header().Set("access-control-allow-method", "post") w.Header().Set("access-control-allow-methods", "options, post")
w.Header().Set("access-control-allow-headers", "authorization, content-type") w.Header().Set("access-control-allow-headers", "authorization, content-type")
w.Header().Set("access-control-max-age", "3600") w.Header().Set("access-control-max-age", "3600")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {

View File

@@ -31,8 +31,8 @@ func Test_enableCORS(t *testing.T) {
if w.Header().Get("access-control-allow-origin") != "*" { if w.Header().Get("access-control-allow-origin") != "*" {
t.Errorf("invalid access-control-allow-origin") t.Errorf("invalid access-control-allow-origin")
} }
if w.Header().Get("access-control-allow-method") != "post" { if w.Header().Get("access-control-allow-methods") != "options, post" {
t.Errorf("invalid access-control-allow-method") t.Errorf("invalid access-control-allow-methods")
} }
if w.Header().Get("access-control-allow-headers") != "authorization, content-type" { if w.Header().Get("access-control-allow-headers") != "authorization, content-type" {
t.Errorf("invalid access-control-allow-headers") t.Errorf("invalid access-control-allow-headers")

View File

@@ -6,10 +6,10 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
"github.com/nalgeon/codapi/sandbox" "github.com/nalgeon/codapi/internal/sandbox"
"github.com/nalgeon/codapi/stringx" "github.com/nalgeon/codapi/internal/stringx"
) )
// NewRouter creates HTTP routes and handlers for them. // NewRouter creates HTTP routes and handlers for them.

View File

@@ -7,10 +7,10 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/nalgeon/codapi/config" "github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/engine" "github.com/nalgeon/codapi/internal/engine"
"github.com/nalgeon/codapi/execy" "github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/sandbox" "github.com/nalgeon/codapi/internal/sandbox"
) )
var cfg = &config.Config{ var cfg = &config.Config{

View File

@@ -8,7 +8,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/nalgeon/codapi/logx" "github.com/nalgeon/codapi/internal/logx"
) )
// The maximum duration of the server graceful shutdown. // The maximum duration of the server graceful shutdown.