Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
## Features

- **Auto-detected protocols** — Kitty, Sixel, half-block fallback. Works on any modern terminal.
- **Multiple sources** — local files, `data:` URIs (base64), and `http(s)://` URLs.
- **Sandboxed decoder** — image bytes parsed in an isolated subprocess with Landlock + seccomp on Linux.
- **No CGO required for the consumer** — pure-Go API surface; the C decoder is contained in the worker subprocess.
- **Terminal pixel detection** — sizes output to the actual cell pixel dimensions when available.
Expand Down Expand Up @@ -55,6 +56,23 @@ func main() {
}
```

### Sources

The `src` argument accepts any of:

```go
termimage.Display(os.Stdout, "/path/to/cat.png", opts)
termimage.Display(os.Stdout, "https://example.com/cat.png", opts)
termimage.Display(os.Stdout, "data:image/png;base64,iVBORw0KGgo...", opts)
```

Remote URLs and data URIs are fetched/decoded in the parent process, then handed
to the sandboxed worker over stdin — the worker still runs with Landlock denying
all filesystem access. Remote payloads are capped at 64 MiB.

Use `DisplayContext` to pass a `context.Context` for cancellation of HTTP fetches
and decoding.

### Options

| Field | Description |
Expand Down
26 changes: 22 additions & 4 deletions docs/content/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,28 @@ title: API Reference
## `termimage.Display`

```go
func Display(w io.Writer, path string, opts Options) error
func Display(w io.Writer, src string, opts Options) error
```

Decodes the image at `path` and writes terminal graphics to `w`. Returns the
first error encountered (file open, decode, or render failure).
Decodes the image at `src` and writes terminal graphics to `w`. `src` may be:

- a local file path (`/path/to/cat.png`)
- an `http(s)://` URL
- a `data:` URI with a base64 payload (`data:image/png;base64,...`)

Remote URLs and data URIs are fetched/decoded in the parent process, then
piped to the sandboxed worker over stdin. Payloads are capped at **64 MiB**.

Returns the first error encountered (fetch, decode, or render failure).

## `termimage.DisplayContext`

```go
func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options) error
```

Same as `Display` but takes a `context.Context` for cancellation of HTTP
fetches and sandboxed decoding.

## `termimage.Options`

Expand Down Expand Up @@ -47,5 +64,6 @@ image, writes raw RGBA to stdout, and calls `os.Exit(0)`.
| `decode` | CGo stb_image binding. `decode.File(path)` and `decode.Bytes(data)` |
| `detect` | Protocol detection. `detect.Best()` |
| `render` | `render.Kitty`, `render.Sixel`, `render.HalfBlock` |
| `sandbox` | Subprocess worker. `sandbox.Decode(path)` |
| `sandbox` | Subprocess worker. `sandbox.Decode(path)`, `sandbox.DecodeBytes(data)` |
| `internal/source` | Source resolver. Branches file path / `data:` URI / `http(s)://` URL |
| `internal/resize` | `resize.Fit(img, maxW, maxH)` — BiLinear scale |
27 changes: 27 additions & 0 deletions docs/content/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,30 @@ termimage.Display(os.Stdout, path, termimage.Options{
MaxHeight: 1080,
})
```

## Remote images and data URIs

`Display` accepts any of these as `src`:

```go
// Local file
termimage.Display(os.Stdout, "/path/to/cat.png", opts)

// Remote URL
termimage.Display(os.Stdout, "https://example.com/cat.png", opts)

// Data URI (base64 only)
termimage.Display(os.Stdout, "data:image/png;base64,iVBORw0KGgo...", opts)
```

Remote URLs are fetched in the parent process; payloads cap at 64 MiB. The
sandboxed worker still runs — bytes are piped to it over stdin with Landlock
denying all filesystem access.

For cancellable fetches, use `DisplayContext`:

```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
termimage.DisplayContext(ctx, os.Stdout, url, opts)
```
6 changes: 4 additions & 2 deletions docs/content/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ the best protocol your terminal supports.

- **Three render modes** — Kitty graphics, DEC Sixel, Unicode half-block
fallback. Auto-detected from environment.
- **Multiple sources** — local files, `data:` URIs (base64), and `http(s)://`
URLs. Remote payloads capped at 64 MiB.
- **CGo decode via stb_image** — single-header C library compiled with `-O3`.
Significantly faster than Go's stdlib `image` package for large JPEG/PNG.
- **Subprocess sandbox** — decode runs in an isolated child process with
Landlock filesystem restriction (read-only access to the target file only).
Seccomp allowlist is upcoming.
Landlock filesystem restriction (read-only access to the target file only,
or full lockdown for remote/data sources). Seccomp allowlist is upcoming.
- **Zero forced dependencies** — Kitty and half-block use only stdlib. Sixel
quantization is pure Go median-cut. The sandbox uses `golang.org/x/sys`.

Expand Down
34 changes: 26 additions & 8 deletions docs/content/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@ do.

When `Sandboxed: true`:

1. The parent process spawns `os.Executable()` as a subprocess with
`TERMIMAGE_WORKER=1` and `TERMIMAGE_WORKER_PATH=<path>` in the environment.
2. The child calls `MaybeRunWorker()`, which detects the env var and takes
1. The parent resolves the source (file / `data:` URI / `http(s)://` URL).
Remote fetches and base64 decode happen here, **outside** the sandbox.
2. The parent spawns `os.Executable()` as a subprocess with `TERMIMAGE_WORKER=1`
and `TERMIMAGE_WORKER_MODE=path|stdin` in the environment. For path mode
it also sets `TERMIMAGE_WORKER_PATH=<path>`; for stdin mode it pipes the
bytes to the child's stdin.
3. The child calls `MaybeRunWorker()`, which detects the env var and takes
over.
3. **Before opening the file**, the child applies OS restrictions.
4. The child reads, decodes, and writes raw RGBA pixels (`width[4] + height[4]
+ pixels`) to stdout.
5. The parent reads the pixel data over the pipe and renders it.
4. **Before touching any input**, the child applies OS restrictions.
5. The child decodes and writes raw RGBA pixels (`width[4] + height[4] +
pixels`) to stdout.
6. The parent reads the pixel data over the pipe and renders it.

> [!CAUTION]
> The sandboxed child re-execs **your binary**, not a dedicated helper. If your
Expand All @@ -33,15 +37,29 @@ When `Sandboxed: true`:

## Landlock (Linux ≥5.13)

Landlock restricts filesystem access to the target file only, read-only:
In **path mode** (local file), Landlock restricts filesystem access to the
target file only, read-only:

```go
landlock.V3.BestEffort().RestrictPaths(landlock.ROFiles(path))
```

In **stdin mode** (remote URL / data URI), no file access is needed — the
worker reads bytes from the pipe — so Landlock is configured with no granted
paths, denying all filesystem access:

```go
landlock.V3.BestEffort().RestrictPaths() // empty = deny-all
```

`BestEffort()` silently degrades on older kernels — the binary still runs, just
without Landlock protection.

> [!NOTE]
> Network access for remote URLs happens **in the parent**, before the
> sandboxed child is spawned. The child itself never gets to make network
> calls — the bytes are already in memory by the time it starts.

## Seccomp (upcoming)

A syscall allowlist via BPF is in progress. The allowlist must accommodate the
Expand Down
99 changes: 99 additions & 0 deletions internal/source/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Package source resolves image sources: file paths, data URIs, and remote URLs.
package source

import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

// Kind tags how the source should be loaded.
type Kind int

const (
KindFile Kind = iota
KindBytes
)

// Resolved is the resolver result. Exactly one of Path or Bytes is set.
type Resolved struct {
Kind Kind
Path string // when Kind == KindFile
Bytes []byte // when Kind == KindBytes
}

// MaxRemoteBytes caps remote/data URI payloads to prevent OOM on hostile servers.
const MaxRemoteBytes = 64 * 1024 * 1024

// Resolve inspects src and returns either a file path or pre-loaded bytes.
// HTTP(S) URLs and data: URIs are fetched/decoded here; everything else is
// treated as a file path.
func Resolve(ctx context.Context, src string) (*Resolved, error) {
if strings.HasPrefix(src, "data:") {
b, err := decodeDataURI(src)
if err != nil {
return nil, err
}
return &Resolved{Kind: KindBytes, Bytes: b}, nil
}

if u, err := url.Parse(src); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
b, err := fetch(ctx, src)
if err != nil {
return nil, err
}
return &Resolved{Kind: KindBytes, Bytes: b}, nil
}

return &Resolved{Kind: KindFile, Path: src}, nil
}

// decodeDataURI parses RFC 2397 data: URIs. Only base64-encoded payloads are
// supported (the common form for images); plain percent-encoded payloads are
// rejected — callers wanting that should pre-decode.
func decodeDataURI(s string) ([]byte, error) {
rest := strings.TrimPrefix(s, "data:")
comma := strings.IndexByte(rest, ',')
if comma < 0 {
return nil, fmt.Errorf("data URI: missing comma")
}
meta, payload := rest[:comma], rest[comma+1:]
if !strings.Contains(meta, "base64") {
return nil, fmt.Errorf("data URI: only base64 payloads supported")
}
b, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, fmt.Errorf("data URI: base64 decode: %w", err)
}
if len(b) > MaxRemoteBytes {
return nil, fmt.Errorf("data URI: payload exceeds %d bytes", MaxRemoteBytes)
}
return b, nil
}

func fetch(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("remote: build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("remote: fetch: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("remote: HTTP %s", resp.Status)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, MaxRemoteBytes+1))
if err != nil {
return nil, fmt.Errorf("remote: read body: %w", err)
}
if len(b) > MaxRemoteBytes {
return nil, fmt.Errorf("remote: response exceeds %d bytes", MaxRemoteBytes)
}
return b, nil
}
86 changes: 86 additions & 0 deletions internal/source/source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package source

import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestResolve_File(t *testing.T) {
r, err := Resolve(context.Background(), "/tmp/cat.png")
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if r.Kind != KindFile {
t.Errorf("Kind = %v, want KindFile", r.Kind)
}
if r.Path != "/tmp/cat.png" {
t.Errorf("Path = %q", r.Path)
}
}

func TestResolve_DataURI(t *testing.T) {
payload := []byte("hello-bytes")
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload)
r, err := Resolve(context.Background(), uri)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if r.Kind != KindBytes {
t.Fatalf("Kind = %v, want KindBytes", r.Kind)
}
if string(r.Bytes) != string(payload) {
t.Errorf("Bytes mismatch: %q", r.Bytes)
}
}

func TestResolve_DataURI_RejectsNonBase64(t *testing.T) {
_, err := Resolve(context.Background(), "data:text/plain,hello")
if err == nil {
t.Error("expected error for non-base64 data URI")
}
}

func TestResolve_DataURI_BadBase64(t *testing.T) {
_, err := Resolve(context.Background(), "data:image/png;base64,!!!not-base64!!!")
if err == nil {
t.Error("expected error for invalid base64")
}
}

func TestResolve_HTTP(t *testing.T) {
body := []byte("pretend-png-bytes")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()

r, err := Resolve(context.Background(), srv.URL+"/x.png")
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if r.Kind != KindBytes {
t.Fatalf("Kind = %v, want KindBytes", r.Kind)
}
if string(r.Bytes) != string(body) {
t.Errorf("body mismatch: %q", r.Bytes)
}
}

func TestResolve_HTTP_NonOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

_, err := Resolve(context.Background(), srv.URL+"/missing.png")
if err == nil {
t.Error("expected error for HTTP 404")
}
if err != nil && !strings.Contains(err.Error(), "404") {
t.Errorf("error should mention status: %v", err)
}
}
Loading
Loading