Skip to content

Commit cf607e3

Browse files
andrinoffLeaWhoCodessteveevansdev
authored
feat: data URI and remote fetch (#4)
## What? Add support for `data:` URIs (base64) and `http(s)://` URLs as image sources, alongside existing local file paths. ## Why? Extends the ability of usage Signed-off-by: drew <me@andrinoff.com> Co-authored-by: Lea <lea@floatpane.com> Co-authored-by: Steve Evans <steve@floatpane.com>
1 parent a05bc4f commit cf607e3

11 files changed

Lines changed: 463 additions & 29 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
## Features
1818

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

59+
### Sources
60+
61+
The `src` argument accepts any of:
62+
63+
```go
64+
termimage.Display(os.Stdout, "/path/to/cat.png", opts)
65+
termimage.Display(os.Stdout, "https://example.com/cat.png", opts)
66+
termimage.Display(os.Stdout, "data:image/png;base64,iVBORw0KGgo...", opts)
67+
```
68+
69+
Remote URLs and data URIs are fetched/decoded in the parent process, then handed
70+
to the sandboxed worker over stdin — the worker still runs with Landlock denying
71+
all filesystem access. Remote payloads are capped at 64 MiB.
72+
73+
Use `DisplayContext` to pass a `context.Context` for cancellation of HTTP fetches
74+
and decoding.
75+
5876
### Options
5977

6078
| Field | Description |

docs/content/api.mdx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,28 @@ title: API Reference
77
## `termimage.Display`
88

99
```go
10-
func Display(w io.Writer, path string, opts Options) error
10+
func Display(w io.Writer, src string, opts Options) error
1111
```
1212

13-
Decodes the image at `path` and writes terminal graphics to `w`. Returns the
14-
first error encountered (file open, decode, or render failure).
13+
Decodes the image at `src` and writes terminal graphics to `w`. `src` may be:
14+
15+
- a local file path (`/path/to/cat.png`)
16+
- an `http(s)://` URL
17+
- a `data:` URI with a base64 payload (`data:image/png;base64,...`)
18+
19+
Remote URLs and data URIs are fetched/decoded in the parent process, then
20+
piped to the sandboxed worker over stdin. Payloads are capped at **64 MiB**.
21+
22+
Returns the first error encountered (fetch, decode, or render failure).
23+
24+
## `termimage.DisplayContext`
25+
26+
```go
27+
func DisplayContext(ctx context.Context, w io.Writer, src string, opts Options) error
28+
```
29+
30+
Same as `Display` but takes a `context.Context` for cancellation of HTTP
31+
fetches and sandboxed decoding.
1532

1633
## `termimage.Options`
1734

@@ -47,5 +64,6 @@ image, writes raw RGBA to stdout, and calls `os.Exit(0)`.
4764
| `decode` | CGo stb_image binding. `decode.File(path)` and `decode.Bytes(data)` |
4865
| `detect` | Protocol detection. `detect.Best()` |
4966
| `render` | `render.Kitty`, `render.Sixel`, `render.HalfBlock` |
50-
| `sandbox` | Subprocess worker. `sandbox.Decode(path)` |
67+
| `sandbox` | Subprocess worker. `sandbox.Decode(path)`, `sandbox.DecodeBytes(data)` |
68+
| `internal/source` | Source resolver. Branches file path / `data:` URI / `http(s)://` URL |
5169
| `internal/resize` | `resize.Fit(img, maxW, maxH)` — BiLinear scale |

docs/content/getting-started.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,30 @@ termimage.Display(os.Stdout, path, termimage.Options{
6161
MaxHeight: 1080,
6262
})
6363
```
64+
65+
## Remote images and data URIs
66+
67+
`Display` accepts any of these as `src`:
68+
69+
```go
70+
// Local file
71+
termimage.Display(os.Stdout, "/path/to/cat.png", opts)
72+
73+
// Remote URL
74+
termimage.Display(os.Stdout, "https://example.com/cat.png", opts)
75+
76+
// Data URI (base64 only)
77+
termimage.Display(os.Stdout, "data:image/png;base64,iVBORw0KGgo...", opts)
78+
```
79+
80+
Remote URLs are fetched in the parent process; payloads cap at 64 MiB. The
81+
sandboxed worker still runs — bytes are piped to it over stdin with Landlock
82+
denying all filesystem access.
83+
84+
For cancellable fetches, use `DisplayContext`:
85+
86+
```go
87+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
88+
defer cancel()
89+
termimage.DisplayContext(ctx, os.Stdout, url, opts)
90+
```

docs/content/introduction.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ the best protocol your terminal supports.
1212

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

docs/content/sandbox.mdx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ do.
1616

1717
When `Sandboxed: true`:
1818

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

2832
> [!CAUTION]
2933
> The sandboxed child re-execs **your binary**, not a dedicated helper. If your
@@ -33,15 +37,29 @@ When `Sandboxed: true`:
3337
3438
## Landlock (Linux ≥5.13)
3539

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

3843
```go
3944
landlock.V3.BestEffort().RestrictPaths(landlock.ROFiles(path))
4045
```
4146

47+
In **stdin mode** (remote URL / data URI), no file access is needed — the
48+
worker reads bytes from the pipe — so Landlock is configured with no granted
49+
paths, denying all filesystem access:
50+
51+
```go
52+
landlock.V3.BestEffort().RestrictPaths() // empty = deny-all
53+
```
54+
4255
`BestEffort()` silently degrades on older kernels — the binary still runs, just
4356
without Landlock protection.
4457

58+
> [!NOTE]
59+
> Network access for remote URLs happens **in the parent**, before the
60+
> sandboxed child is spawned. The child itself never gets to make network
61+
> calls — the bytes are already in memory by the time it starts.
62+
4563
## Seccomp (upcoming)
4664

4765
A syscall allowlist via BPF is in progress. The allowlist must accommodate the

internal/source/source.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Package source resolves image sources: file paths, data URIs, and remote URLs.
2+
package source
3+
4+
import (
5+
"context"
6+
"encoding/base64"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
)
13+
14+
// Kind tags how the source should be loaded.
15+
type Kind int
16+
17+
const (
18+
KindFile Kind = iota
19+
KindBytes
20+
)
21+
22+
// Resolved is the resolver result. Exactly one of Path or Bytes is set.
23+
type Resolved struct {
24+
Kind Kind
25+
Path string // when Kind == KindFile
26+
Bytes []byte // when Kind == KindBytes
27+
}
28+
29+
// MaxRemoteBytes caps remote/data URI payloads to prevent OOM on hostile servers.
30+
const MaxRemoteBytes = 64 * 1024 * 1024
31+
32+
// Resolve inspects src and returns either a file path or pre-loaded bytes.
33+
// HTTP(S) URLs and data: URIs are fetched/decoded here; everything else is
34+
// treated as a file path.
35+
func Resolve(ctx context.Context, src string) (*Resolved, error) {
36+
if strings.HasPrefix(src, "data:") {
37+
b, err := decodeDataURI(src)
38+
if err != nil {
39+
return nil, err
40+
}
41+
return &Resolved{Kind: KindBytes, Bytes: b}, nil
42+
}
43+
44+
if u, err := url.Parse(src); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
45+
b, err := fetch(ctx, src)
46+
if err != nil {
47+
return nil, err
48+
}
49+
return &Resolved{Kind: KindBytes, Bytes: b}, nil
50+
}
51+
52+
return &Resolved{Kind: KindFile, Path: src}, nil
53+
}
54+
55+
// decodeDataURI parses RFC 2397 data: URIs. Only base64-encoded payloads are
56+
// supported (the common form for images); plain percent-encoded payloads are
57+
// rejected — callers wanting that should pre-decode.
58+
func decodeDataURI(s string) ([]byte, error) {
59+
rest := strings.TrimPrefix(s, "data:")
60+
comma := strings.IndexByte(rest, ',')
61+
if comma < 0 {
62+
return nil, fmt.Errorf("data URI: missing comma")
63+
}
64+
meta, payload := rest[:comma], rest[comma+1:]
65+
if !strings.Contains(meta, "base64") {
66+
return nil, fmt.Errorf("data URI: only base64 payloads supported")
67+
}
68+
b, err := base64.StdEncoding.DecodeString(payload)
69+
if err != nil {
70+
return nil, fmt.Errorf("data URI: base64 decode: %w", err)
71+
}
72+
if len(b) > MaxRemoteBytes {
73+
return nil, fmt.Errorf("data URI: payload exceeds %d bytes", MaxRemoteBytes)
74+
}
75+
return b, nil
76+
}
77+
78+
func fetch(ctx context.Context, rawURL string) ([]byte, error) {
79+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
80+
if err != nil {
81+
return nil, fmt.Errorf("remote: build request: %w", err)
82+
}
83+
resp, err := http.DefaultClient.Do(req)
84+
if err != nil {
85+
return nil, fmt.Errorf("remote: fetch: %w", err)
86+
}
87+
defer func() { _ = resp.Body.Close() }()
88+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
89+
return nil, fmt.Errorf("remote: HTTP %s", resp.Status)
90+
}
91+
b, err := io.ReadAll(io.LimitReader(resp.Body, MaxRemoteBytes+1))
92+
if err != nil {
93+
return nil, fmt.Errorf("remote: read body: %w", err)
94+
}
95+
if len(b) > MaxRemoteBytes {
96+
return nil, fmt.Errorf("remote: response exceeds %d bytes", MaxRemoteBytes)
97+
}
98+
return b, nil
99+
}

internal/source/source_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package source
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestResolve_File(t *testing.T) {
13+
r, err := Resolve(context.Background(), "/tmp/cat.png")
14+
if err != nil {
15+
t.Fatalf("Resolve: %v", err)
16+
}
17+
if r.Kind != KindFile {
18+
t.Errorf("Kind = %v, want KindFile", r.Kind)
19+
}
20+
if r.Path != "/tmp/cat.png" {
21+
t.Errorf("Path = %q", r.Path)
22+
}
23+
}
24+
25+
func TestResolve_DataURI(t *testing.T) {
26+
payload := []byte("hello-bytes")
27+
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload)
28+
r, err := Resolve(context.Background(), uri)
29+
if err != nil {
30+
t.Fatalf("Resolve: %v", err)
31+
}
32+
if r.Kind != KindBytes {
33+
t.Fatalf("Kind = %v, want KindBytes", r.Kind)
34+
}
35+
if string(r.Bytes) != string(payload) {
36+
t.Errorf("Bytes mismatch: %q", r.Bytes)
37+
}
38+
}
39+
40+
func TestResolve_DataURI_RejectsNonBase64(t *testing.T) {
41+
_, err := Resolve(context.Background(), "data:text/plain,hello")
42+
if err == nil {
43+
t.Error("expected error for non-base64 data URI")
44+
}
45+
}
46+
47+
func TestResolve_DataURI_BadBase64(t *testing.T) {
48+
_, err := Resolve(context.Background(), "data:image/png;base64,!!!not-base64!!!")
49+
if err == nil {
50+
t.Error("expected error for invalid base64")
51+
}
52+
}
53+
54+
func TestResolve_HTTP(t *testing.T) {
55+
body := []byte("pretend-png-bytes")
56+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
57+
_, _ = w.Write(body)
58+
}))
59+
defer srv.Close()
60+
61+
r, err := Resolve(context.Background(), srv.URL+"/x.png")
62+
if err != nil {
63+
t.Fatalf("Resolve: %v", err)
64+
}
65+
if r.Kind != KindBytes {
66+
t.Fatalf("Kind = %v, want KindBytes", r.Kind)
67+
}
68+
if string(r.Bytes) != string(body) {
69+
t.Errorf("body mismatch: %q", r.Bytes)
70+
}
71+
}
72+
73+
func TestResolve_HTTP_NonOK(t *testing.T) {
74+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
75+
w.WriteHeader(http.StatusNotFound)
76+
}))
77+
defer srv.Close()
78+
79+
_, err := Resolve(context.Background(), srv.URL+"/missing.png")
80+
if err == nil {
81+
t.Error("expected error for HTTP 404")
82+
}
83+
if err != nil && !strings.Contains(err.Error(), "404") {
84+
t.Errorf("error should mention status: %v", err)
85+
}
86+
}

0 commit comments

Comments
 (0)