lsp: a jailed language-server daemon, so a definition can leave the repository

A static symbol index knows what a repository says about itself. It cannot say
where dep.Greet is DEFINED, because the definition is in a module the repository
only names — and finding it means downloading that module and type-checking both
halves, which means running a third-party binary over untrusted bytes. That does
not belong in the API process. It belongs here, behind a jail.

The invariant is the separation: the network and untrusted bytes are never in the
same process. FETCH runs `go mod download all` with a socket, first-party
toolchain only, writing the tree and the dependency cache. SERVE runs gopls with
seccomp denying socket/socketpair/io_uring and every bind read-only. The phase is
not a field anyone sets — it is jail.Fetch or jail.Serve, so there is no value to
get wrong, and the only difference between their filters is two syscall numbers.

Roots are (org, repo, resolved-sha). Refusing a branch name at the door is the
entire cache-invalidation design: a sha names one tree for all time, a push is a
different key, and nothing decides when anything is stale. The daemon holds no
git credential and makes no git request; the caller sends the tree, and /ask
answers 409 {"need":"tree"} when it has not.

Fail closed. On Linux the jail is required: at boot a canary is built in each
phase and asked the kernel for a socket — serving must be refused one, fetching
must get one — and if that does not hold, /readyz is 503 and every request that
would run a language server is refused. There is no environment variable that
turns the jail off, so there is none that can be set by accident.

REUSED, not rewritten:
  internal/lsp/conn.go, langs.go — ported from hanzoai/cloud branch `lsp`
    (apps/lsp, 67975187) with their tests, including the two regressions that
    build pinned: Close hanging on a wedged server, and a frame reader that
    would allocate whatever Content-Length it was told.
  internal/jail/ — a copy of hanzoai/code-exec's jailer (d2794ce), generalized
    to two phases. It SHOULD be github.com/hanzoai/jail imported by both; a
    boundary that exists twice drifts. Noted in LLM.md, not decided here.

Three bugs found and fixed on the way to green, all of which made the service
answer null with no error anywhere:
  - the jail child now locks its OS thread before installing seccomp. The filter
    and no_new_privs are per-THREAD and syscall.Exec execs the calling thread; a
    goroutine migration between them would leave the exec'd program unfiltered.
    This is a fix over the code-exec original and belongs upstream there too.
  - clean() returns the root's own spelling, not the symlink-resolved one. The
    proof of containment still resolves, but a server rooted at /var/x and handed
    a document at /private/var/x does not consider it to be in its workspace.
  - didOpen is sent once per document. It is not idempotent in LSP, and a root is
    immutable, so once is also all that is ever needed.
  - the fetch is `go mod download all`: the bare form records only each
    dependency's go.mod hash, so the read-only serve phase then failed on a
    missing go.sum entry.

Phase 1 serves Go. The table carries five complete languages and Available()
decides, so adding one is installing its server in the Dockerfile.

Acceptance is hermetic: a module is published into a directory laid out as a Go
proxy and fetched with GOPROXY=file://, so the real `go mod download` runs with
no network, and locate/definition on dep.Greet must return external:true at the
module coordinate. Passes in 0.08s once warm.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-06 03:58:01 -07:00
co-authored by Hanzo Dev
commit 82e0507e34
24 changed files with 4952 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.github
*.md
Dockerfile
.dockerignore
.gitignore
/dist
/lsp
*.test
+14
View File
@@ -0,0 +1,14 @@
# build output
/lsp
/dist/
*.test
*.out
# local root / dependency storage
/var/
/tmp/
# editor / OS
.DS_Store
.idea/
.vscode/
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
+92
View File
@@ -0,0 +1,92 @@
# syntax=docker/dockerfile:1
# lsp — the jailed language-server daemon.
#
# Three stages:
# 1. build — compiles the static Go binary.
# 2. tools — compiles the language servers the runtime will run.
# 3. runtime — lean Debian with the toolchains + tini, non-root.
#
# The isolation is the Go-NATIVE jailer built into the binary (namespaces +
# minimal chroot + seccomp) running under a gVisor RuntimeClass — NOT nsjail.
# nsjail is incompatible with gVisor (it calls prctl(SECUREBITS), which gVisor
# does not implement, and needs a cgroup ns plus a netlink net-ns gVisor
# rejects), so this image ships no jailer binary at all: the daemon re-execs
# ITSELF as the jail child.
#
# Built in CI on the self-hosted amd64 scale set — never on GitHub's builders,
# never on a developer's laptop. The cluster is amd64; the image targets
# linux/amd64.
# ── 1. The daemon ────────────────────────────────────────────────────────────
FROM golang:1.26.4-bookworm AS build
WORKDIR /src
# Module graph first for layer caching. There are no dependencies today — stdlib
# only, by design — but this keeps the shape right if one is ever added.
COPY go.mod ./
RUN go mod download
COPY . .
# CGO off ⇒ a fully static binary with no shared-library surface of its own. The
# jail child re-execs this exact binary.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /out/lsp .
# ── 2. The language servers ──────────────────────────────────────────────────
#
# PINNED, not @latest. A floating server version means two builds of the same
# commit answer differently, and the thing that changed is a binary nobody
# reviewed. Bumping is one line here.
#
# Phase 1 ships Go only. Adding a language is: install its server below, and
# internal/lsp/langs.go already carries the entry — Lang.Available() lights it up
# at runtime with no code change.
FROM golang:1.26.4-bookworm AS tools
ARG GOPLS_VERSION=v0.23.0
RUN GOBIN=/out CGO_ENABLED=0 go install golang.org/x/tools/gopls@${GOPLS_VERSION}
# ── 3. Runtime ───────────────────────────────────────────────────────────────
FROM debian:bookworm-slim AS runtime
# ca-certificates for the FETCH phase's TLS to the module proxy; tini to reap the
# jail children (each root forks a language server, which forks `go list`).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tini curl \
&& rm -rf /var/lib/apt/lists/*
# The Go toolchain, which gopls shells out to for `go list` and which the fetch
# phase runs as `go mod download`. A pinned tarball rather than apt: Debian's
# golang lags, and the toolchain version decides which repositories resolve.
ARG GO_VERSION=1.26.4
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in amd64) goarch=amd64 ;; arm64) goarch=arm64 ;; *) echo "unsupported arch $arch"; exit 1 ;; esac; \
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${goarch}.tar.gz" -o /tmp/go.tgz; \
tar -C /usr/local -xzf /tmp/go.tgz; \
rm /tmp/go.tgz
ENV PATH="/usr/local/go/bin:${PATH}"
COPY --from=tools /out/gopls /usr/local/bin/gopls
COPY --from=build /out/lsp /usr/local/bin/lsp
# Roots and dependency caches live on a PVC mounted at /var/lib/lsp. The staging
# directory each jail chroots into is /tmp, an emptyDir — it holds nothing but
# mountpoints and must be writable by the non-root uid.
RUN groupadd -r lsp && useradd -r -g lsp -d /home/lsp -m lsp \
&& mkdir -p /var/lib/lsp/roots /var/lib/lsp/deps \
&& chown -R lsp:lsp /var/lib/lsp
ENV LSP_LISTEN=":8000" \
LSP_ROOTS="/var/lib/lsp/roots" \
LSP_DEPS="/var/lib/lsp/deps" \
LSP_STAGE="/tmp/jail" \
LSP_PROXY="https://proxy.golang.org,direct" \
LSP_SUMDB="sum.golang.org"
# LSP_KEY is NOT set here. It comes from KMS through a KMSSecret; an unset key
# makes the daemon refuse every request rather than serve whoever asks.
USER lsp
EXPOSE 8000
# tini as PID 1 so orphaned language-server processes are reaped.
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/lsp"]
+278
View File
@@ -0,0 +1,278 @@
# lsp — the jailed language-server daemon
`ghcr.io/hanzoai/lsp` · module `github.com/hanzoai/lsp` · Go, stdlib only · linux/amd64
Holds immutable per-`(org, repo, commit)` working trees, runs a language server
over each inside a jail with no network, and answers position questions about
them. It is the backend `/v1/code/lsp/*` resolves to.
## Why it is a separate daemon
A static symbol index knows what a repository says about itself. It cannot tell
you where `dep.Greet` is DEFINED, because the definition is in a module the
repository only names. Finding it means downloading that module and
type-checking both halves — running a third-party binary over untrusted bytes.
That does not belong in the API process, so it is here, behind a jail.
The one answer that justifies the service is a `Location` with `external: true`:
a definition that left the repository and landed in the dependency cache.
## The invariant
**The network and untrusted bytes are never in the same process.**
| | FETCH | SERVE |
|---|---|---|
| runs | `go mod download` — first-party toolchain, download-only | `gopls serve -mode=stdio` — third-party, parses tenant source |
| socket | permitted | **denied** by seccomp (`socket`, `socketpair`, and the io_uring family) |
| tree | writable | read-only |
| dependency cache | writable | read-only |
| namespaces | new user/mount/pid/ipc/uts, minimal chroot, rlimits, `no_new_privs` | same |
The phase is not a field anyone sets — it is `jail.Fetch` or `jail.Serve`, two
functions, so there is no value to get wrong. The only difference between their
seccomp filters is `socket`/`socketpair`, and that is one two-line function
(`denied` in `internal/jail/jail_linux.go`).
Fetches that would run DEPENDENCY-authored code (`uv sync` building an sdist,
`npm install` running postinstall, `cargo build` running build.rs) do not run at
all — see `Fetchable` in `internal/lsp/langs.go`. A language server resolves
definitions from source; build artifacts buy nothing and would be arbitrary code
executing in the one phase that has a network.
## Fail closed
On Linux the jail is REQUIRED. At boot the daemon runs `jail.Probe`, which
builds a real jail in each phase and makes a canary ask the kernel for a socket:
serving must be refused one, fetching must get one. If that does not hold —
no gVisor RuntimeClass, no user namespace — `/readyz` is 503 and every request
that would run a language server is refused. There is no environment variable
that turns the jail off, so there is none that can be set by accident.
Off Linux there is no jail to require, and the daemon says so loudly in the log
and on `/readyz`. The image is linux/amd64, so that case is a developer's
machine.
## Roots are immutable
A root is `(org, repo, rev)` where **rev is a resolved commit** — 40 or 64 hex
digits, refused at the door otherwise. That single rule is the entire
cache-invalidation design: a sha names one tree for all time, a push is a
different key, and there is no subsystem that decides when anything is stale.
The caller resolves refs; it is the one that has the repository.
The daemon holds **no git credential** and makes no git request. The caller
sends the tree. A daemon that could fetch a repository would need a credential
that could reach every repository.
## Wire contract
All bodies are JSON. Every route but the probes requires `X-API-Key`
(constant-time; unset key ⇒ 503, not an open door). `org` is a namespacing key,
not an authorization decision: this daemon does not authenticate tenants, it
isolates them. The caller behind the key — the cloud API — is the authenticator.
### `POST /root`
```json
{ "org": "acme", "repo": "app",
"rev": "9f2c1e...40 or 64 hex...",
"files": [ { "path": "go.mod", "content": "module …" },
{ "path": "main.go", "content": "package main…" } ] }
```
→ `200 { "ready": true, "cold": true, "langs": ["go"] }`
Materializes the tree at `/var/lib/lsp/roots/{org}/{repo}/{rev}`, runs the fetch
phase once per module directory, starts one server per language that is both
present in the tree and installed in this image. `cold` is false when the root
was already live. Idempotent: roots are immutable, so re-sending a tree is a
no-op.
Errors: `400` bad key or tree (a path that escapes, a duplicate, an empty tree,
past the 20 000-file / 256 MiB bound), `409` on `/root/warm` only, `503`
sandbox unavailable, `504` build timed out.
### `POST /root/warm`
The same body and the same work, accepted **only if this org already holds a
live root for this repo**. Pre-warming a revision nobody asked about is free
work; it is offered only where a cold start on the same repository has already
been paid for.
→ `409 { "need": "held", "org": …, "repo": … }` otherwise.
### `POST /ask`
```json
{ "org": "acme", "repo": "app", "rev": "9f2c1e…",
"op": "locate", "relation": "definition",
"path": "main.go", "line": 5, "character": 9 }
```
`op` ∈ `hover` · `locate` · `symbols` · `diagnostics` · `complete`
`relation` (required for `locate`) ∈ `definition` · `reference` · `type` · `implementation`
**Positions are the LSP's**: `line` and `character` are 0-BASED and `character`
counts UTF-16 code units. An editor's 1-based line must have 1 subtracted before
it is sent. They pass through untouched — re-basing them would corrupt every
line containing a multi-byte character.
→ `200`:
```json
{ "op": "locate", "lang": "go",
"locations": [ { "path": "example.com/[email protected]/dep.go",
"external": true,
"range": { "start": {"line":3,"character":5},
"end": {"line":3,"character":10} } } ] }
```
Exactly one result field is populated: `locations`, `hover`, `symbols`,
`completions` or `diagnostics`. `external: false` (omitted) means `path` is
repo-relative. `external: true` means the answer left the repository and `path`
is its module coordinate — the dependency cache's own layout, which names the
module, its version and the file, and leaks no path belonging to this daemon.
A null result is not an error: "no definition here" is a real answer and comes
back with an empty list.
→ `409 { "need": "tree", "org": …, "repo": …, "rev": … }` when no root is held.
The caller then `POST /root` with the tree and asks again. Two calls cold, one
warm.
Errors: `400` bad key, unknown op or relation, negative position, a path that is
not in the tree, or a file no installed server answers for; `502` the language
server did not answer; `503` sandbox unavailable.
### `GET /healthz` · `GET /readyz`
Liveness answers 200 while the process is up. Readiness answers what the jail
decided: `{ "ready": true, "jail": "jailed", "langs": ["go"] }`, or 503 with the
reason. They are different questions and do not collapse into one.
## Languages
`internal/lsp/langs.go` carries five complete entries — `go`, `rust`,
`typescript`, `python`, `cpp` — with argv, root markers, extensions, cache and
fetch. What decides whether one is SERVED is `Lang.Available()`: whether its
binary is on PATH in the image.
**Phase 1 ships the Go toolchain and gopls, so Go is the only language served.**
The other four are inert in this image and light up the moment their server is
installed in the Dockerfile — no code change. That is deliberate: the table never
promises what the deployment cannot do.
## Bounds
| | |
|---|---|
| live roots | 4, LRU, 20-minute idle TTL |
| concurrent cold starts | 2 |
| one dependency fetch | 10 minutes |
| tree | 20 000 files, 256 MiB, depth 32 |
| serve rlimits | 6 GiB address space, 4096 fds, 256 procs, 2 GiB tmpfs, **no CPU cap** (total-CPU-seconds on a warm server is a scheduled death; the bound is the pod cgroup and the idle TTL) |
| fetch rlimits | 4 GiB address space, 900 CPU seconds, 1024 fds, 128 procs |
| one LSP frame | 32 MiB |
| handshake / query | 120 s / 30 s |
## Configuration
| env | default | |
|---|---|---|
| `LSP_LISTEN` | `:8000` | |
| `LSP_KEY` | *unset* | shared service key, from KMS. Unset ⇒ refuse everything. |
| `LSP_ROOTS` | `/var/lib/lsp/roots` | PVC |
| `LSP_DEPS` | `/var/lib/lsp/deps` | PVC — public, checksum-verified modules only |
| `LSP_STAGE` | `/tmp/jail` | emptyDir; each jail mounts its chroot base here |
| `LSP_PROXY` | `https://proxy.golang.org,direct` | the ONLY host the fetch phase may reach |
| `LSP_SUMDB` | `sum.golang.org` | verification is what makes a shared cache safe |
The dependency cache is shared across orgs on purpose. It holds only public
modules verified against the checksum database, because this daemon carries no
credential and so no private dependency can enter it. Sharing it is most of the
cold-start win.
## Layout
```
main.go boot: jail child hook, config, server, shutdown
internal/jail/ the jailer — COPY of code-exec's, see below
jail.go Spec, Limits, the two phases, the control channel
jail_linux.go the child: namespaces, chroot, rlimits, seccomp, exec
jail_other.go no jail off Linux, decided by platform not by flag
internal/lsp/
lsp.go types, placing an answer, decoding a server's reply
conn.go the LSP JSON-RPC client (PORTED, see below)
langs.go the language table (PORTED, see below)
root.go materialize · fetch · serve
pool.go LRU + idle TTL, single-flight build
http.go the door
```
### Provenance
- `internal/lsp/conn.go` and `langs.go` are PORTED from `hanzoai/cloud` branch
`lsp` (`apps/lsp/server.go`, `apps/lsp/langs.go`, commit `67975187`), with
their tests. Two regressions that build pinned are pinned here too: `Close`
hanging on a server that stopped reading its stdin, and a frame reader that
would allocate whatever `Content-Length` it was told.
- `internal/jail/` is a COPY of `hanzoai/code-exec`'s jailer
(`internal/codeexec/jail_linux.go` at `d2794ce`), generalized to two phases.
### CTO call — the jailer should be one module
The seccomp filter now exists in two repositories. A security boundary that
exists twice drifts, and a change has to land in both. It **should** be
published as `github.com/hanzoai/jail` and imported by `code-exec` and `lsp`
alike. Publishing a new shared module is a call to make deliberately, so it is
noted here rather than made in passing.
This copy carries **one fix over the original**: the jail child now calls
`runtime.LockOSThread()` before installing the filter. `no_new_privs` and
`PR_SET_SECCOMP` apply to the CALLING THREAD, and `syscall.Exec` execs the
calling thread — if the Go scheduler moved the goroutine between the two, the
exec'd program would inherit no filter and the sandbox would silently not exist.
The original's sequence is short enough that it has always held in practice.
"In practice" is not a security boundary. **This fix belongs upstream in
code-exec too.**
## Tests
```
go test ./... # everything; add gopls to PATH for the acceptance test
```
The acceptance test (`internal/lsp/gopls_test.go`) is hermetic: it publishes a
tiny module into a directory laid out as a Go module proxy and points the fetch
phase at it with `GOPROXY=file://`, so the REAL `go mod download` runs with no
network, and then requires `locate definition` on `dep.Greet` to come back
`external: true` at the module coordinate. It skips where `gopls` is not
installed.
The jail tests interpret the cBPF program the way the kernel would, so the jump
arithmetic — where an off-by-one silently ALLOWS a denied syscall — is checked
on any Linux with no privileges. `TestProbeProvesTheBoundaryOnThisHost` runs the
real thing and skips where a user namespace cannot be created (a plain CI
container; the gVisor target can).
## Phase 2
1. **cloud `apps/lsp` becomes a thin proxy.** The blue monolith on branch `lsp`
ran language servers inside the fleet pod with its own git checkout — the
wrong shape, and superseded. Replace it with ~300 lines at
`/v1/code/lsp/*`: validate the principal, take org from it, resolve the ref
to a sha, read the tree through `apps/git` `repo.WalkText` (SHARED with
`apps/code` — one tree read, not a second checkout), `Gate`/`MeterUsage`
(prepare price on cold, query price on warm), then POST to
`lsp.hanzo.svc:8000`. On `409 {"need":"tree"}`, POST `/root` and retry once.
2. **universe Deployment.** `ghcr.io/hanzoai/lsp` on a gvisor node pool (scale
the gvisor-installer DaemonSet up), `runtimeClassName: gvisor`, non-root,
RO rootfs, all capabilities dropped, no service-account token, a PVC for
`/var/lib/lsp`, an emptyDir for `/tmp`, and a NetworkPolicy whose egress
allowlist is the module proxy and nothing else. `LSP_KEY` from a KMSSecret.
3. **MCP.** EXTEND the existing single `lsp` tool (`mcp/rust/src/tools/lsp_tool.rs`,
`python-sdk/pkg/hanzo-tools-lsp`) with `repo`/`rev` parameters — a file goes
to the local stdio server, a repo goes to `/v1/code/lsp/*`. Not a new tool.
4. **More languages** = install the server in the Dockerfile. The table entries
already exist.
+20
View File
@@ -0,0 +1,20 @@
# lsp
The jailed language-server daemon. It holds immutable per-(org, repo, commit)
working trees, runs a language server over each one inside a jail with no
network, and answers position questions about them.
The answer it exists for is a definition that leaves the repository and lands in
a dependency — which a static symbol index structurally cannot give you.
```
POST /root {org, repo, rev, files} → {ready, cold, langs}
POST /root/warm same body, only for a held repo
POST /ask {org, repo, rev, op, path, line, character, relation}
→ the answer, or 409 {"need":"tree"}
GET /healthz liveness
GET /readyz readiness — green only once the jail proved out on this host
```
Everything else — the wire contract in full, the two-phase security model, the
deployment, and what phase 2 is — is in [LLM.md](LLM.md).
+10
View File
@@ -0,0 +1,10 @@
module github.com/hanzoai/lsp
// Jailed language-server daemon.
//
// STDLIB ONLY by design: this process runs third-party language servers over
// tenant source, so the dependency surface it can be attacked through is kept at
// zero (net/http, encoding/json, os/exec, syscall, crypto/subtle). The isolation
// is a Go-native jailer — namespaces + minimal chroot + seccomp — running under
// gVisor; there is no external jailer binary, the daemon re-execs itself.
go 1.26.4
+172
View File
@@ -0,0 +1,172 @@
// Package jail runs a process with the kernel taken away from it: new
// user/mount/pid/ipc/uts namespaces, a chroot holding nothing but the paths it
// was handed, rlimits, no_new_privs, and a seccomp-bpf filter.
//
// # Provenance — this is a copy, and it should not stay one
//
// This is hanzoai/code-exec's jailer (internal/codeexec/jail_linux.go at
// d2794ce), lifted here and generalized from "one short exec" to "either phase
// of a language-server root", plus one fix: the child now locks its OS thread
// before installing seccomp (see jail_linux.go).
//
// Two services now carry the same seccomp filter in two files, which is one file
// too many. It SHOULD be published as github.com/hanzoai/jail and imported by
// both — the filter is a security boundary, and a boundary that exists twice
// drifts. Publishing a shared module is a CTO call and is not made here. Until
// it is, a change to the filter must land in BOTH repos.
//
// # Two phases, and the invariant between them
//
// A jailed process is in exactly one of two phases, and the difference between
// them is the whole security model of this daemon:
//
// Fetch — MAY open sockets, and MAY write the paths in Spec.Write and
// Spec.Work. It runs FIRST-PARTY toolchain code in a download-only
// mode (`go mod download`) over a manifest of dependency names. No
// dependency's own code runs: the Go toolchain has no install hook.
//
// Serve — may NOT open a socket, by construction: seccomp denies
// socket/socketpair and the io_uring family, so there is no call that
// returns a socket fd and therefore no egress, no loopback and no DNS.
// EVERY bind is read-only, including the working tree. It parses
// UNTRUSTED bytes — the tenant's source and its dependencies'.
//
// The invariant is the separation itself: THE NETWORK AND UNTRUSTED BYTES ARE
// NEVER IN THE SAME PROCESS. Phase is not a field a caller fills in; it is which
// function the caller called, so there is no value to get wrong and no
// configuration that can weaken it.
package jail
import (
"context"
"os/exec"
"strconv"
"strings"
)
// phase is Fetch or Serve. Unexported: it is chosen by which constructor ran.
type phase string
const (
fetch phase = "fetch"
serve phase = "serve"
)
// Spec is one jailed process.
type Spec struct {
// Root is the directory the jail mounts a tmpfs over and chroots into. It
// is the process's new "/" and contains nothing but mountpoints — the
// daemon's own rootfs, /etc/shadow and any mounted secret are absent.
Root string
// Work is the working tree, bound into the jail AT ITS OWN ABSOLUTE PATH.
Work string
// Dir is the working directory, and must be inside one of the binds. Empty
// means Work. It is separate because a dependency fetch runs in ONE module
// directory while needing the whole tree bound around it.
Dir string
// Read are paths bound read-only, each AT ITS OWN ABSOLUTE PATH: the
// toolchain, the dependency cache, the CA store. A path that does not exist
// is skipped, so one image layout does not break another.
Read []string
// Write are paths the Fetch phase may write — the dependency cache it is
// populating. Serve binds them read-only along with everything else.
Write []string
// Argv is the program and its arguments. argv[0] is an absolute path: PATH
// lookup happens in the parent, where PATH means something.
Argv []string
// Env is the environment the program runs under. It is BUILT by the caller,
// never inherited — this daemon's own environment holds its service key.
Env []string
Limits Limits
}
// Limits are the per-process ceilings. A zero field is NOT applied, which is how
// a long-lived language server opts out of RLIMIT_CPU: a total-CPU-seconds cap
// is a bound on a batch job, and on a warm server it is a scheduled execution.
type Limits struct {
AddrMiB uint64 // RLIMIT_AS, MiB — virtual address space per process
CPU uint64 // RLIMIT_CPU, seconds — zero for a long-lived server
FsizeMiB uint64 // RLIMIT_FSIZE, MiB — one file's size
Nofile uint64 // RLIMIT_NOFILE
Nproc uint64 // RLIMIT_NPROC, per-uid — the fork-bomb bound
TmpMiB uint64 // size of the tmpfs mounted at /tmp inside the jail
}
// Fetch builds the command for the network phase: sockets permitted, Work and
// Write writable. What runs under it must be first-party toolchain code in a
// download-only mode — see the package comment.
func Fetch(ctx context.Context, s Spec) *exec.Cmd { return command(ctx, fetch, s) }
// Serve builds the command for the untrusted phase: no socket, everything
// read-only. What runs under it parses tenant bytes.
func Serve(ctx context.Context, s Spec) *exec.Cmd { return command(ctx, serve, s) }
// ── the control channel between parent and jail child ────────────────────────
//
// The parent hands the child its instructions through the environment, and the
// child strips every one of them before exec'ing the program, so none reaches
// the jailed process.
const (
envPhase = "HANZO_JAIL" // "fetch" | "serve"; absent ⇒ not a jail child
envRoot = "HANZO_JAIL_ROOT" //
envWork = "HANZO_JAIL_WORK" //
envDir = "HANZO_JAIL_DIR" //
envRead = "HANZO_JAIL_READ" // unit-separated
envWrite = "HANZO_JAIL_WRITE" // unit-separated
envArgv = "HANZO_JAIL_ARGV" // unit-separated
envAddr = "HANZO_JAIL_AS" //
envCPU = "HANZO_JAIL_CPU" //
envFsize = "HANZO_JAIL_FSIZE" //
envNofile = "HANZO_JAIL_NOFILE" //
envNproc = "HANZO_JAIL_NPROC" //
envTmp = "HANZO_JAIL_TMP" //
envCanary = "HANZO_JAIL_CANARY" // "1" ⇒ the child probes itself instead of exec'ing
)
// envPrefix is what the child strips: one prefix covers every control variable,
// so adding one above cannot leak it into a jailed process by omission.
const envPrefix = "HANZO_JAIL"
// unit joins a list into one environment variable. It is the ASCII unit
// separator, a control character that cannot occur in a path and that — unlike
// NUL — an environment accepts.
const unit = "\x1f"
// control renders a Spec as the child's control environment.
func control(p phase, s Spec) []string {
return []string{
envPhase + "=" + string(p),
envRoot + "=" + s.Root,
envWork + "=" + s.Work,
envDir + "=" + s.Dir,
envRead + "=" + strings.Join(s.Read, unit),
envWrite + "=" + strings.Join(s.Write, unit),
envArgv + "=" + strings.Join(s.Argv, unit),
envAddr + "=" + strconv.FormatUint(s.Limits.AddrMiB, 10),
envCPU + "=" + strconv.FormatUint(s.Limits.CPU, 10),
envFsize + "=" + strconv.FormatUint(s.Limits.FsizeMiB, 10),
envNofile + "=" + strconv.FormatUint(s.Limits.Nofile, 10),
envNproc + "=" + strconv.FormatUint(s.Limits.Nproc, 10),
envTmp + "=" + strconv.FormatUint(s.Limits.TmpMiB, 10),
}
}
// setupFailed is the exit code the child uses when it could not build the jail.
// It is distinct from anything the jailed program would return, so a sandbox
// failure is never read as a program result.
const setupFailed = 126
// canaryOK is what the canary prints when it could open a socket, and canaryDeny
// what it prints when the kernel refused. Probe reads exactly these.
const (
canaryOK = "socket=open"
canaryDeny = "socket=denied"
)
+488
View File
@@ -0,0 +1,488 @@
//go:build linux
package jail
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"unsafe"
)
// ── how the jail is entered ──────────────────────────────────────────────────
//
// The daemon RE-EXECS ITSELF. command() builds an *exec.Cmd that runs this same
// binary with the control environment set and SysProcAttr asking the kernel for
// new user/mount/pid/ipc/uts namespaces. main() calls Child() first thing, so the
// re-exec'd process takes the child path below, builds its jail, and execs the
// real program as its last act.
//
// WHY NOT nsjail: nsjail is incompatible with gVisor. It calls
// prctl(PR_SET_SECUREBITS) unconditionally during uid setup, which the gVisor
// sentry does not implement, so the child never launches; it also creates a
// cgroup namespace and a netlink-configured net namespace that gVisor rejects,
// and no flag turns those off. Proven on-target. A small auditable Go component
// avoids all three and keeps this repo dependency-free.
//
// WHY NO NET NAMESPACE: the same gVisor incompatibility. The network boundary is
// therefore at the SYSCALL layer — seccomp denies socket/socketpair and the
// io_uring family, so the served process can obtain no socket fd at all. That is
// the property an empty net namespace would give, achieved where gVisor allows.
// self is this binary, re-exec'd as the jail child.
var self, selfErr = os.Executable()
// Supported reports whether a jail can be built on this platform and binary.
// On Linux it is true whenever the executable path resolved: whether the HOST
// can actually create the namespaces is what Probe answers.
func Supported() bool { return selfErr == nil && self != "" }
// command builds the re-exec. The child's environment is the control variables
// plus the clean environment the caller built for the program; this process's
// own environment — which holds the service key — is not passed.
func command(ctx context.Context, p phase, s Spec) *exec.Cmd {
c := exec.CommandContext(ctx, self)
c.Env = append(control(p, s), s.Env...)
c.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNS |
syscall.CLONE_NEWPID | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS,
UidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getuid(), Size: 1}},
GidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getgid(), Size: 1}},
GidMappingsEnableSetgroups: false,
// No Pdeathsig: in Go it fires when the CREATING OS THREAD exits, not the
// process, and the runtime retires threads freely — it would kill warm
// language servers at random. The child is PID 1 of a new PID namespace,
// so killing it already takes everything it forked with it; that is the
// reaping guarantee, and it is the kernel's rather than a signal's.
}
return c
}
// Child runs the jail-child path and never returns, if this process IS a jail
// child. In the daemon process it does nothing. main() calls it first, before any
// server setup, so the child stays minimal.
func Child() {
p := phase(os.Getenv(envPhase))
if p != fetch && p != serve {
return
}
if err := run(p); err != nil {
fmt.Fprintln(os.Stderr, "jail:", err)
os.Exit(setupFailed)
}
os.Exit(setupFailed) // run() execs on success; arriving here means exec failed
}
// run executes inside the new namespaces, as root within its own user namespace
// and as the daemon's non-root uid outside it. It builds the chroot, applies the
// limits, installs the filter, and execs.
func run(p phase) error {
// LOCK THE THREAD FIRST. no_new_privs and PR_SET_SECCOMP apply to the
// CALLING THREAD, and syscall.Exec execs the calling thread. If the Go
// scheduler moved this goroutine between installing the filter and exec'ing,
// the exec'd program would inherit NO filter and the sandbox would silently
// not exist. This is the fix over the code-exec original, where the sequence
// is short enough that it has always held in practice — "in practice" not
// being a security boundary.
runtime.LockOSThread()
root := os.Getenv(envRoot)
work := os.Getenv(envWork)
if root == "" || work == "" {
return errors.New("missing root or work")
}
argv := split(os.Getenv(envArgv))
canary := os.Getenv(envCanary) == "1"
if len(argv) == 0 && !canary {
return errors.New("empty argv")
}
// (a) A private mount tree, so nothing built here propagates back out.
if err := syscall.Mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, ""); err != nil {
return fmt.Errorf("make-rprivate: %w", err)
}
// A tmpfs as the chroot base. It holds only mountpoints, so it can be tiny —
// and it must be, since a tmpfs is charged to the pod's memory.
if err := os.MkdirAll(root, 0o755); err != nil {
return fmt.Errorf("mkdir root: %w", err)
}
if err := syscall.Mount("tmpfs", root, "tmpfs", 0, "mode=0755,size=8m"); err != nil {
return fmt.Errorf("mount root: %w", err)
}
// (b) Bind what the caller allowed, EACH AT ITS OWN ABSOLUTE PATH.
//
// Same-path is not a convenience. A language server reports every answer as a
// file: URI of a path it was given, so a jail that renamed /roots/x to /work
// would hand the caller paths that name nothing — or would need a translation
// table, which is a second place for the two sides to disagree. There is no
// translation here because there is nothing to translate.
//
// Serve binds EVERYTHING read-only, including the working tree: a root is
// immutable and a language server has no business writing to one. Fetch binds
// Work and Write read-write, because populating a dependency cache is
// precisely what it is for.
for _, path := range split(os.Getenv(envRead)) {
if err := bind(path, root, false); err != nil {
return err
}
}
writable := p == fetch
for _, path := range split(os.Getenv(envWrite)) {
if err := bind(path, root, writable); err != nil {
return err
}
}
if err := bind(work, root, writable); err != nil {
return err
}
// A writable tmpfs /tmp: the ONLY thing a served process can write, and it
// dies with the process. Build caches live here for exactly that reason.
tmp := filepath.Join(root, "tmp")
if err := os.MkdirAll(tmp, 0o1777); err != nil {
return fmt.Errorf("mkdir /tmp: %w", err)
}
size := num(envTmp, 256)
if err := syscall.Mount("tmpfs", tmp, "tmpfs", 0,
fmt.Sprintf("mode=1777,size=%dm", size)); err != nil {
return fmt.Errorf("mount /tmp: %w", err)
}
// (c) Enter it. The old root is gone; work is reachable at the same path it
// had outside.
if err := syscall.Chroot(root); err != nil {
return fmt.Errorf("chroot: %w", err)
}
dir := os.Getenv(envDir)
if dir == "" {
dir = work
}
if err := syscall.Chdir(dir); err != nil {
return fmt.Errorf("chdir %s: %w", dir, err)
}
// (d) Ceilings, then no_new_privs, then the filter. Order matters:
// SECCOMP_MODE_FILTER requires no_new_privs for a non-root caller.
if err := limits(); err != nil {
return err
}
if _, _, e := syscall.Syscall6(syscall.SYS_PRCTL, prSetNoNewPrivs, 1, 0, 0, 0, 0); e != 0 {
return fmt.Errorf("no_new_privs: %w", e)
}
if err := seccomp(p); err != nil {
return err
}
// (e) Either prove the boundary to ourselves, or become the program.
if canary {
probe()
os.Exit(0)
}
if err := syscall.Exec(argv[0], argv, clean()); err != nil {
return fmt.Errorf("exec %s: %w", argv[0], err)
}
return nil // unreachable
}
// bind mounts src inside the staging root at src's own path. A missing source is
// skipped: the bind list is the superset across image layouts, and a host that
// lacks /lib64 must not fail to jail.
func bind(src, root string, write bool) error {
fi, err := os.Stat(src)
if err != nil {
return nil
}
dst := filepath.Join(root, src)
if fi.IsDir() {
err = os.MkdirAll(dst, 0o755)
} else {
if err = os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
var f *os.File
if f, err = os.OpenFile(dst, os.O_CREATE, 0o644); err == nil {
err = f.Close()
}
}
}
if err != nil {
return fmt.Errorf("bind target %s: %w", src, err)
}
if err := syscall.Mount(src, dst, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("bind %s: %w", src, err)
}
if write {
return nil
}
// A bind acquires read-only only on a second, remounting call.
if err := syscall.Mount("", dst, "", syscall.MS_BIND|syscall.MS_REC|syscall.MS_REMOUNT|syscall.MS_RDONLY, ""); err != nil {
return fmt.Errorf("seal %s: %w", src, err)
}
return nil
}
// limits applies the ceilings the parent chose. A zero is not applied — that is
// how a long-lived server declines RLIMIT_CPU, which would otherwise kill it
// after a fixed amount of work.
func limits() error {
set := func(res int, v uint64, name string) error {
if v == 0 {
return nil
}
if err := syscall.Setrlimit(res, &syscall.Rlimit{Cur: v, Max: v}); err != nil {
return fmt.Errorf("rlimit %s: %w", name, err)
}
return nil
}
if err := set(syscall.RLIMIT_AS, num(envAddr, 0)*1024*1024, "as"); err != nil {
return err
}
if err := set(syscall.RLIMIT_CPU, num(envCPU, 0), "cpu"); err != nil {
return err
}
if err := set(syscall.RLIMIT_FSIZE, num(envFsize, 0)*1024*1024, "fsize"); err != nil {
return err
}
if err := set(syscall.RLIMIT_NOFILE, num(envNofile, 0), "nofile"); err != nil {
return err
}
return set(rlimitNproc, num(envNproc, 0), "nproc")
}
// clean is the environment handed to the jailed program: this child's own
// environment minus every control variable, so none of them reaches it.
func clean() []string {
out := make([]string, 0, len(os.Environ()))
for _, kv := range os.Environ() {
if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, envPrefix) {
continue
}
out = append(out, kv)
}
return out
}
func split(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, unit)
}
func num(key string, def uint64) uint64 {
if v, err := strconv.ParseUint(os.Getenv(key), 10, 64); err == nil {
return v
}
return def
}
// ── the canary ───────────────────────────────────────────────────────────────
// probe runs INSIDE a fully built jail, after the filter is installed, on the
// thread the filter was installed on (run() locked it). It asks the kernel for a
// socket and reports what happened. That is the daemon proving the invariant
// about ITSELF on THIS host, rather than asserting it in a comment.
func probe() {
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
fmt.Println(canaryDeny, err)
return
}
syscall.Close(fd)
fmt.Println(canaryOK)
}
// Probe builds a real jail in each phase and makes the canary tell it whether a
// socket was obtainable. It is the boot self-test, and it is the reason /readyz
// can be trusted: a host where the namespaces cannot be created, or where the
// filter did not take, fails here and the daemon then refuses every request
// rather than serving untrusted bytes next to a network.
//
// root and work are scratch directories the caller owns.
func Probe(ctx context.Context, root, work string) error {
if !Supported() {
return errors.New("jail: unsupported on this build")
}
for _, c := range []struct {
p phase
want string
}{
{serve, canaryDeny},
{fetch, canaryOK},
} {
spec := Spec{
Root: root, Work: work,
Env: []string{envCanary + "=1"},
Limits: Limits{AddrMiB: 1024, CPU: 10, Nofile: 64, Nproc: 32, TmpMiB: 16},
}
out, err := command(ctx, c.p, spec).Output()
if err != nil {
return fmt.Errorf("jail: %s canary did not run: %w", c.p, err)
}
got := strings.TrimSpace(string(out))
if !strings.HasPrefix(got, c.want) {
return fmt.Errorf("jail: %s canary reported %q, want %q — the phase boundary is not in force",
c.p, got, c.want)
}
}
return nil
}
// ── seccomp-bpf ──────────────────────────────────────────────────────────────
//
// A classic BPF program installed with prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER).
// It:
// - KILLs any audit arch that is not x86_64, which stops the i386 ABI whose
// syscall numbers differ entirely and would sail past a numeric filter;
// - KILLs any syscall carrying the x32 bit (0x40000000), since x32 shares
// AUDIT_ARCH_X86_64 with amd64 — the arch check alone does not catch it, yet
// every x32 number is ORed with that bit and would dodge the numeric deny;
// - returns ERRNO(EPERM) for the denied numbers;
// - ALLOWs everything else, because a toolchain's syscall surface is wide and
// an allow-list of it would be a list of everything.
// escape is denied in BOTH phases. Neither a module download nor a language
// server has any use for these, and each is a documented container-escape or
// sandbox-bypass primitive.
var escape = []uint32{
// io_uring — the OTHER way to get a socket. Its ops (IORING_OP_SOCKET,
// CONNECT, SEND, OPENAT …) do network and filesystem work WITHOUT issuing
// socket()/connect(), so a socket-only deny would not be the whole boundary.
// Denying the ring's setup means no ring exists to issue them from.
sysIoUringSetup, sysIoUringEnter, sysIoUringRegister,
// kernel keyring (container-escape CVEs)
syscall.SYS_ADD_KEY, syscall.SYS_REQUEST_KEY, syscall.SYS_KEYCTL,
// cross-process memory and ptrace
syscall.SYS_PTRACE, sysProcessVMReadv, sysProcessVMWritev,
// eBPF
sysBPF,
// mount / pivot / chroot: the program is already chrooted into a minimal
// root, and a second chroot is an escape primitive, never a need.
syscall.SYS_MOUNT, syscall.SYS_UMOUNT2, syscall.SYS_PIVOT_ROOT, syscall.SYS_CHROOT,
sysMoveMount, sysOpenTree, sysFsopen, sysFsconfig, sysFsmount, sysFspick,
// new namespaces from inside
syscall.SYS_UNSHARE, sysSetns,
// kernel code load
syscall.SYS_INIT_MODULE, sysFinitModule, syscall.SYS_DELETE_MODULE,
syscall.SYS_KEXEC_LOAD, sysKexecFileLoad,
}
// network is denied in the SERVE phase only. With no call that returns a socket
// fd, connect/sendto/recvfrom are unreachable: egress is impossible by
// construction rather than filtered by policy.
var network = []uint32{syscall.SYS_SOCKET, syscall.SYS_SOCKETPAIR}
// denied is the deny-list for p. The ONE difference between the phases lives
// here, in a two-line function, so "what does Fetch have that Serve does not"
// has a single answer that can be read in full.
func denied(p phase) []uint32 {
if p == fetch {
return escape
}
return append(append([]uint32{}, network...), escape...)
}
// x86_64 numbers absent from Go's syscall package. These are a STABLE kernel ABI
// on amd64 — the target arch, and the only one the image is built for — so
// hardcoding them is correct and dependency-free.
const (
sysProcessVMReadv = 310
sysProcessVMWritev = 311
sysBPF = 321
sysSetns = 308
sysFsopen = 430
sysFsconfig = 431
sysFsmount = 432
sysFspick = 433
sysOpenTree = 428
sysMoveMount = 429
sysFinitModule = 313
sysKexecFileLoad = 320
sysIoUringSetup = 425
sysIoUringEnter = 426
sysIoUringRegister = 427
rlimitNproc = 6 // RLIMIT_NPROC, absent from Go's syscall package
prSetNoNewPrivs = 38 // PR_SET_NO_NEW_PRIVS
)
// Filter return actions and arch markers. Package-level so the builder and its
// structural test share ONE definition of each security-critical number.
const (
retAllow = 0x7FFF0000 // SECCOMP_RET_ALLOW
retErrno = 0x00050000 | 1 // SECCOMP_RET_ERRNO | EPERM
retKill = 0x80000000 // SECCOMP_RET_KILL_PROCESS
auditArchAMD64 = 0xC000003E // AUDIT_ARCH_X86_64
x32Bit = 0x40000000 // __X32_SYSCALL_BIT, set on every x32 number
)
// filter is the cBPF program for p. It is built here rather than inline so its
// jump arithmetic — where an off-by-one would silently ALLOW a denied syscall —
// is unit-tested by interpreting the program, with no need to enter seccomp mode.
func filter(p phase) []insn {
const (
ld = 0x00 | 0x00 | 0x20 // BPF_LD|BPF_W|BPF_ABS
jeq = 0x05 | 0x10 | 0x00 // BPF_JMP|BPF_JEQ|BPF_K
jset = 0x05 | 0x40 | 0x00 // BPF_JMP|BPF_JSET|BPF_K
ret = 0x06 | 0x00 // BPF_RET|BPF_K
atNR = 0 // seccomp_data.nr
atArc = 4 // seccomp_data.arch
)
nrs := denied(p)
prog := []insn{
// 1. Wrong arch ⇒ KILL. This is what stops the i386 ABI.
{code: ld, k: atArc},
{code: jeq, jt: 1, k: auditArchAMD64},
{code: ret, k: retKill},
// 2. Load the number, then refuse the x32 ABI wholesale: it shares this
// audit arch but ORs 0x40000000 into every number, so the numeric compares
// below would miss its socket. No toolchain on an amd64 image uses x32.
{code: ld, k: atNR},
{code: jset, jt: 0, jf: 1, k: x32Bit},
{code: ret, k: retKill},
}
// 3. Each denied number jumps forward to the shared ERRNO terminal, over the
// remaining compares and over the ALLOW. jt is an offset from the NEXT
// instruction and is 8 bits; the list is far below 250 long.
for i, nr := range nrs {
prog = append(prog, insn{code: jeq, jt: uint8(len(nrs)-1-i) + 1, k: nr})
}
// 4. The two terminals.
return append(prog, insn{code: ret, k: retAllow}, insn{code: ret, k: retErrno})
}
// insn mirrors struct sock_filter { u16 code; u8 jt; u8 jf; u32 k; }.
type insn struct {
code uint16
jt uint8
jf uint8
k uint32
}
// program mirrors struct sock_fprog { u16 len; sock_filter *filter; }.
type program struct {
len uint16
_ [6]byte // pad to the pointer's 8-byte alignment on amd64
f *insn
}
// seccomp installs filter(p) on the calling thread — which run() has locked, and
// which is the thread that will exec.
func seccomp(p phase) error {
const (
prSetSeccomp = 22 // PR_SET_SECCOMP
modeFilter = 2 // SECCOMP_MODE_FILTER
)
prog := filter(p)
fp := program{len: uint16(len(prog)), f: &prog[0]}
if _, _, e := syscall.Syscall6(syscall.SYS_PRCTL, prSetSeccomp, modeFilter,
uintptr(unsafe.Pointer(&fp)), 0, 0, 0); e != 0 {
return fmt.Errorf("prctl(PR_SET_SECCOMP): %w", e)
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
//go:build linux
package jail
import (
"context"
"testing"
"time"
)
// TestFilterIsTheBoundary interprets the cBPF program exactly as the kernel's
// engine would and pins, for BOTH phases, the three things the filter exists to
// say. It enters no seccomp mode and needs no privilege, so it runs in any Linux
// CI — which matters, because the arithmetic it checks is where an off-by-one
// would silently ALLOW a denied syscall and nothing else would notice.
func TestFilterIsTheBoundary(t *testing.T) {
for _, p := range []phase{serve, fetch} {
prog := filter(p)
eval := interpreter(t, prog)
// A different audit arch is KILLed whatever the number: the i386 ABI's
// numbers mean other things and would sail past the compares below.
const auditArchI386 = 0x40000003
if got := eval(auditArchI386, 41); got != retKill {
t.Fatalf("%s: i386 arch = 0x%x, want KILL", p, got)
}
// Every denied number reaches the ERRNO terminal, and its x32 spelling is
// KILLed rather than slipping past the numeric compare.
for _, nr := range denied(p) {
if got := eval(auditArchAMD64, nr); got != retErrno {
t.Fatalf("%s: denied nr %d = 0x%x, want ERRNO", p, nr, got)
}
if got := eval(auditArchAMD64, nr|x32Bit); got != retKill {
t.Fatalf("%s: x32 of denied nr %d = 0x%x, want KILL", p, nr, got)
}
}
// The x32 ABI is refused wholesale, not only for the denied numbers.
if got := eval(auditArchAMD64, 0|x32Bit); got != retKill {
t.Fatalf("%s: x32 read = 0x%x, want KILL", p, got)
}
// read(0) and openat(257) are not denied — a toolchain's surface is wide
// and the filter must not become an allow-list of everything.
for _, nr := range []uint32{0, 257} {
if got := eval(auditArchAMD64, nr); got != retAllow {
t.Fatalf("%s: nr %d = 0x%x, want ALLOW", p, nr, got)
}
}
// io_uring is denied in BOTH phases: a socket-only deny is not egress
// containment, because IORING_OP_SOCKET issues no socket() syscall.
for _, nr := range []uint32{sysIoUringSetup, sysIoUringEnter, sysIoUringRegister} {
if got := eval(auditArchAMD64, nr); got != retErrno {
t.Fatalf("%s: io_uring nr %d = 0x%x, want ERRNO", p, nr, got)
}
}
}
}
// TestPhasesDifferOnlyByTheNetwork is the invariant stated as a test: Serve can
// obtain no socket, Fetch can, and that is the ONLY thing that separates them.
// If someone ever adds a second difference, or removes this one, this fails.
func TestPhasesDifferOnlyByTheNetwork(t *testing.T) {
serveEval := interpreter(t, filter(serve))
fetchEval := interpreter(t, filter(fetch))
for _, nr := range network {
if got := serveEval(auditArchAMD64, nr); got != retErrno {
t.Fatalf("serve: nr %d = 0x%x, want ERRNO — an untrusted process could open a socket", nr, got)
}
if got := fetchEval(auditArchAMD64, nr); got != retAllow {
t.Fatalf("fetch: nr %d = 0x%x, want ALLOW — the fetch phase could not download", nr, got)
}
}
// Everything else agrees.
for _, nr := range escape {
if serveEval(auditArchAMD64, nr) != retErrno || fetchEval(auditArchAMD64, nr) != retErrno {
t.Fatalf("nr %d is not denied in both phases", nr)
}
}
}
// TestProbeProvesTheBoundaryOnThisHost runs the REAL jail in both phases and
// makes the canary report whether the kernel handed it a socket. It needs a host
// that can create a user namespace — gVisor's sentry can, a plain CI container
// cannot — so it SKIPS off-target rather than failing. On the target it is the
// difference between a sandbox and a comment about one.
func TestProbeProvesTheBoundaryOnThisHost(t *testing.T) {
if !Supported() {
t.Skip("no jail on this build")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := Probe(ctx, t.TempDir()+"/stage", t.TempDir()); err != nil {
t.Skipf("the jail cannot initialize here (expected off the gVisor target): %v", err)
}
}
// interpreter returns a function that runs prog against a synthetic (arch, nr)
// the way the kernel's cBPF engine does, for the four opcodes this filter uses.
func interpreter(t *testing.T, prog []insn) func(arch, nr uint32) uint32 {
t.Helper()
return func(arch, nr uint32) uint32 {
var a uint32
for pc := 0; pc < len(prog); {
f := prog[pc]
switch f.code {
case 0x20: // BPF_LD|BPF_W|BPF_ABS
if f.k == 0 {
a = nr
} else {
a = arch
}
pc++
case 0x15: // BPF_JMP|BPF_JEQ|BPF_K
if a == f.k {
pc += 1 + int(f.jt)
} else {
pc += 1 + int(f.jf)
}
case 0x45: // BPF_JMP|BPF_JSET|BPF_K
if a&f.k != 0 {
pc += 1 + int(f.jt)
} else {
pc += 1 + int(f.jf)
}
case 0x06: // BPF_RET|BPF_K
return f.k
default:
t.Fatalf("unexpected opcode 0x%x at pc=%d", f.code, pc)
}
}
t.Fatal("filter fell through without a RET")
return 0
}
}
+42
View File
@@ -0,0 +1,42 @@
//go:build !linux
package jail
import (
"context"
"errors"
"os/exec"
)
// The jail is namespaces + chroot + seccomp, so it exists only on Linux. Off it —
// a developer's Mac — there is no jail child, no re-exec, and no boundary: the
// program simply runs.
//
// That is safe ONLY because the platform decides, not a flag. The image is
// linux/amd64, so in production Supported() is always true and a pod where the
// namespaces cannot be created fails Probe and refuses every request. There is no
// environment variable that turns the jail off, and therefore none that can be
// set by accident on the thing that matters.
// Supported reports whether a jail can be built here. It cannot.
func Supported() bool { return false }
// command runs the program directly. The caller's clean environment and working
// directory still apply; the kernel boundary does not exist to apply.
func command(ctx context.Context, _ phase, s Spec) *exec.Cmd {
c := exec.CommandContext(ctx, s.Argv[0], s.Argv[1:]...)
c.Dir = s.Dir
if c.Dir == "" {
c.Dir = s.Work
}
c.Env = s.Env
return c
}
// Child is a no-op: nothing re-execs, so nothing is ever a jail child.
func Child() {}
// Probe cannot prove a boundary that is not there.
func Probe(context.Context, string, string) error {
return errors.New("jail: unsupported on this platform")
}
+475
View File
@@ -0,0 +1,475 @@
package lsp
// conn.go is a JSON-RPC 2.0 client speaking the Language Server Protocol over a
// server's stdio: Content-Length framing, one multiplexed connection,
// initialize → didOpen → ask.
//
// PORTED from hanzoai/cloud branch `lsp` (apps/lsp/server.go, 67975187),
// unchanged in substance including its two fixes — the Close that used to hang on
// a wedged server, and the frame reader that refuses an oversize Content-Length.
// What changed is where the process comes from: there it spawned one itself, here
// it is handed an already-jailed *exec.Cmd, because building the jail is root.go's
// concern and speaking the protocol is this file's. Nothing here knows what a
// namespace is.
//
// The one structural decision: a SINGLE reader goroutine owns the stdout side and
// demultiplexes it. LSP is not request/response — a server interleaves responses,
// its own requests, and unsolicited notifications on the same stream, and
// diagnostics are only ever the third kind. Reading inline from Call drops every
// message that is not the response being waited for, which is why a client that
// does that cannot report diagnostics without a second read path. One reader,
// three destinations, no second path.
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// maxFrame bounds one inbound message. A language server is a subprocess we
// spawned, but it is parsing a tenant's tree, and a hostile input that makes it
// emit an enormous frame must not become an allocation the pod dies on.
const maxFrame = 32 << 20 // 32 MiB
// Handshake, request and shutdown budgets. A cold gopls indexes before it
// answers, so initialize is generous where a point query is not.
const (
initWait = 120 * time.Second
callWait = 30 * time.Second
closeWait = 3 * time.Second
)
// Conn is one live language server: a process (or, in a test, a pipe) plus the
// bookkeeping to route its stream. Safe for concurrent use — Call may be entered
// from several requests against the same warm root.
type Conn struct {
w io.WriteCloser
stop func() // releases the transport (kills the process)
wmu sync.Mutex // serializes frame writes; a torn frame desynchronizes the stream
seq atomic.Int64
mu sync.Mutex
wait map[int64]chan msg
dmu sync.Mutex
diag map[string][]Diagnostic
omu sync.Mutex
open map[string]bool // documents already declared to the server
done chan struct{} // closed when the reader stops
err error // why it stopped; read only after done
once sync.Once
}
// msg is any JSON-RPC frame in either direction. Which of the four kinds it is
// follows from which fields are present, which is what [Conn.read] switches on.
type msg struct {
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string { return fmt.Sprintf("lsp: rpc %d: %s", e.Code, e.Message) }
// attach starts cmd — which the caller has already jailed — and completes the
// handshake against a server rooted at root.
//
// The process is deliberately NOT tied to a request's context: a Conn outlives
// the request that warmed it, which is the entire point of the pool. [Conn.Close]
// is what ends it.
func attach(ctx context.Context, cmd *exec.Cmd, l Lang, root string) (*Conn, error) {
// The server's stderr is its own log, not ours to relay: it is chatty and it
// echoes tenant source. Dropping it keeps both out of our logs.
cmd.Stderr = io.Discard
in, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("lsp: stdin: %w", err)
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("lsp: stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("lsp: start %s: %w", l.Start[0], err)
}
c := newConn(in, out, func() {
if cmd.Process != nil {
// The jailed process is PID 1 of its own PID namespace, so this one
// kill takes every `go list` it forked with it.
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
if err := c.handshake(ctx, l, root); err != nil {
c.Close()
return nil, err
}
return c, nil
}
// newConn puts a Conn on an already-open transport and starts its reader. attach
// uses it for a real process; a test uses it for a pipe, and both then drive the
// identical client.
func newConn(w io.WriteCloser, r io.Reader, stop func()) *Conn {
c := &Conn{
w: w,
stop: stop,
wait: make(map[int64]chan msg),
diag: make(map[string][]Diagnostic),
open: make(map[string]bool),
done: make(chan struct{}),
}
go c.read(bufio.NewReaderSize(r, 64<<10))
return c
}
// handshake performs initialize → initialized. initializationOptions (langs.go:
// Init) is where a server that would otherwise compile project code at load time
// is told not to.
func (c *Conn) handshake(ctx context.Context, l Lang, root string) error {
ctx, cancel := context.WithTimeout(ctx, initWait)
defer cancel()
uri := pathURI(root)
params := map[string]any{
"processId": os.Getpid(),
"rootUri": uri,
"rootPath": root,
"capabilities": map[string]any{
"workspace": map[string]any{"workspaceFolders": true, "applyEdit": false},
"textDocument": map[string]any{
"synchronization": map[string]any{"dynamicRegistration": true, "didSave": true},
"completion": map[string]any{"completionItem": map[string]any{"snippetSupport": true}},
"hover": map[string]any{"contentFormat": []string{"markdown", "plaintext"}},
"definition": map[string]any{"dynamicRegistration": true, "linkSupport": true},
"references": map[string]any{"dynamicRegistration": true},
"publishDiagnostics": map[string]any{"relatedInformation": false},
},
},
"workspaceFolders": []map[string]any{{"uri": uri, "name": filepath.Base(root)}},
}
if l.Init != nil {
params["initializationOptions"] = l.Init
}
if _, err := c.Call(ctx, "initialize", params); err != nil {
return fmt.Errorf("lsp: initialize: %w", err)
}
return c.Notify("initialized", map[string]any{})
}
// Call issues a request and waits for the response with that id.
func (c *Conn) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
id := c.seq.Add(1)
ch := make(chan msg, 1)
c.mu.Lock()
c.wait[id] = ch
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.wait, id)
c.mu.Unlock()
}()
if err := c.send(map[string]any{
"jsonrpc": "2.0", "id": id, "method": method, "params": params,
}); err != nil {
return nil, err
}
select {
case m := <-ch:
if m.Error != nil {
return nil, m.Error
}
return m.Result, nil
case <-c.done:
return nil, c.stopped()
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Notify sends a notification — no id, so no response is expected or waited for.
func (c *Conn) Notify(method string, params any) error {
return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
}
// Open makes the server willing to answer about a file, ONCE.
//
// didOpen is not idempotent in LSP: it declares that the CLIENT now owns the
// document and is the authority on its contents, and a second one for the same
// URI is a protocol violation the server is entitled to reject — which gopls
// does, by then answering null to everything about that document. Once is also
// all that is ever needed here, because a root is immutable: the file cannot
// change for as long as this server exists.
//
// Content is read from disk: the root is the source of truth, and accepting
// caller-supplied text would let one request answer about a file the tenant's
// repository does not have.
func (c *Conn) Open(l Lang, abs string) (string, error) {
uri := pathURI(abs)
c.omu.Lock()
defer c.omu.Unlock()
if c.open[uri] {
return uri, nil
}
text, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("lsp: read %s: %w", filepath.Base(abs), err)
}
if err := c.Notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": l.ID(abs), "version": 1, "text": string(text),
},
}); err != nil {
return "", err
}
c.open[uri] = true
return uri, nil
}
// Diagnostics collects what the server published for uri.
//
// LSP has no "diagnostics complete" signal — publishDiagnostics is unsolicited
// and a server may publish several times as analysis deepens. So this waits for a
// first publication, then for a settle window in which nothing new arrives, and
// reports what it has. A server that publishes nothing (a clean file) is reported
// clean at the deadline, which is the honest reading.
func (c *Conn) Diagnostics(ctx context.Context, uri string, settle time.Duration) []Diagnostic {
const tick = 50 * time.Millisecond
var last int
var quiet time.Duration
for {
select {
case <-ctx.Done():
return c.published(uri)
case <-c.done:
return c.published(uri)
case <-time.After(tick):
}
got := c.published(uri)
if len(got) != last {
last, quiet = len(got), 0
continue
}
if last > 0 {
if quiet += tick; quiet >= settle {
return got
}
}
}
}
func (c *Conn) published(uri string) []Diagnostic {
c.dmu.Lock()
defer c.dmu.Unlock()
if d, ok := c.diag[uri]; ok {
return append([]Diagnostic(nil), d...)
}
return nil
}
// Close shuts the server down politely, then unconditionally. Idempotent.
//
// Polite first, because a server asked to exit flushes and releases its own
// children. But the polite phase can BLOCK: a server that has stopped reading its
// stdin — crashed, or wedged mid-index — leaves our write with nowhere to go, and
// Close would then never return. It would hold a pool slot, a process handle and,
// at shutdown, the whole binary.
//
// So the courtesy runs off to the side and this waits on a clock, never on the
// server. Closing the writer afterwards is what releases that goroutine: a blocked
// write to a closed pipe returns rather than waits.
func (c *Conn) Close() {
c.once.Do(func() {
select {
case <-c.done:
// Already dead. There is nobody to say goodbye to, and saying it
// anyway is exactly how this used to hang.
default:
polite := make(chan struct{})
go func() {
defer close(polite)
ctx, cancel := context.WithTimeout(context.Background(), closeWait)
defer cancel()
_, _ = c.Call(ctx, "shutdown", nil)
_ = c.Notify("exit", nil)
}()
select {
case <-polite:
case <-time.After(closeWait):
}
}
_ = c.w.Close()
select {
case <-c.done:
case <-time.After(closeWait):
}
if c.stop != nil {
c.stop()
}
})
}
// send frames one message. The write lock spans header and body: two goroutines
// interleaving there would produce a frame whose length does not match its
// payload, and the stream never recovers from that.
func (c *Conn) send(v any) error {
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("lsp: encode: %w", err)
}
c.wmu.Lock()
defer c.wmu.Unlock()
if _, err := fmt.Fprintf(c.w, "Content-Length: %d\r\n\r\n", len(b)); err != nil {
return fmt.Errorf("lsp: write header: %w", err)
}
if _, err := c.w.Write(b); err != nil {
return fmt.Errorf("lsp: write body: %w", err)
}
return nil
}
// read is the sole owner of the inbound stream. Every frame is exactly one of
// four kinds, and each has exactly one destination.
func (c *Conn) read(r *bufio.Reader) {
defer close(c.done)
for {
body, err := readFrame(r)
if err != nil {
c.err = err
return
}
var m msg
if json.Unmarshal(body, &m) != nil {
continue // a frame we cannot parse is not a reason to drop the session
}
switch {
case m.Method != "" && len(m.ID) > 0:
// A server→client REQUEST (workspace/configuration,
// client/registerCapability). It BLOCKS the server until answered, so
// silence here is a hang, not a no-op. A null result is a valid answer
// to every one of them and commits us to nothing.
_ = c.send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": nil})
case m.Method != "" && len(m.ID) == 0:
if m.Method == "textDocument/publishDiagnostics" {
c.publish(m.Params)
}
case len(m.ID) > 0:
var id int64
if json.Unmarshal(m.ID, &id) != nil {
continue // a response to an id we never issued
}
c.mu.Lock()
ch := c.wait[id]
c.mu.Unlock()
if ch != nil {
ch <- m // buffered, and the waiter is the only receiver
}
}
}
}
func (c *Conn) publish(params json.RawMessage) {
var p struct {
URI string `json:"uri"`
Diagnostics []Diagnostic `json:"diagnostics"`
}
if json.Unmarshal(params, &p) != nil || p.URI == "" {
return
}
c.dmu.Lock()
c.diag[p.URI] = p.Diagnostics
c.dmu.Unlock()
}
func (c *Conn) stopped() error {
if c.err != nil && !errors.Is(c.err, io.EOF) {
return fmt.Errorf("lsp: server stopped: %w", c.err)
}
return errors.New("lsp: server stopped")
}
// readFrame reads one Content-Length-framed message.
//
// Content-Length is REQUIRED and is the only header that matters; Content-Type is
// accepted and ignored, as the spec allows. A frame is refused rather than
// truncated when it exceeds maxFrame, because a truncated read leaves the stream
// pointing at the middle of a message.
func readFrame(r *bufio.Reader) ([]byte, error) {
n := -1
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // end of headers
}
k, v, ok := strings.Cut(line, ":")
if !ok || !strings.EqualFold(strings.TrimSpace(k), "Content-Length") {
continue
}
if n, err = strconv.Atoi(strings.TrimSpace(v)); err != nil {
return nil, fmt.Errorf("lsp: bad Content-Length %q", v)
}
}
if n < 0 {
return nil, errors.New("lsp: frame without Content-Length")
}
if n > maxFrame {
return nil, fmt.Errorf("lsp: frame of %d bytes exceeds %d", n, maxFrame)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return body, nil
}
// pathURI renders an absolute path as a file: URI. url.URL does the escaping, so
// a path with a space or a percent survives the round trip.
func pathURI(p string) string {
return (&url.URL{Scheme: "file", Path: p}).String()
}
// uriPath is pathURI's inverse for a file: URI, and the empty string for anything
// else — a server may cite a definition inside a jar: or zipfile: URI, which names
// no path on our disk.
func uriPath(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "file" {
return ""
}
return u.Path
}
+381
View File
@@ -0,0 +1,381 @@
package lsp
// conn_test.go drives the real client against a FAKE language server.
//
// PORTED from hanzoai/cloud branch `lsp` (apps/lsp/server_test.go, 67975187),
// including the two regressions it pins: Close hanging on a wedged server, and a
// frame reader that would allocate whatever Content-Length it was told.
//
// The fake speaks the actual wire protocol — Content-Length framing, JSON-RPC 2.0
// — over an in-process pipe, so these tests need no gopls, no toolchain and no
// network, and still exercise the same code attach hands a real subprocess.
// newConn is what makes that possible; nothing here reimplements the client under
// test.
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// fake is a language server: it records the methods it is asked, in order, and
// answers from a table of canned replies.
type fake struct {
mu sync.Mutex
seen []string
reply map[string]any // method → result
// push, when set, is published as a diagnostics notification after didOpen,
// which is how a real server delivers them: unsolicited, not as a response.
push map[string]any
}
// serve reads frames from r and writes answers to w until r closes.
func (f *fake) serve(r io.Reader, w io.WriteCloser) {
defer w.Close()
br := bufio.NewReader(r)
for {
body, err := readFrame(br)
if err != nil {
return
}
var m struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(body, &m) != nil {
continue
}
f.mu.Lock()
f.seen = append(f.seen, m.Method)
f.mu.Unlock()
if m.Method == "exit" {
return
}
if len(m.ID) > 0 { // a request: answer it
result, ok := f.reply[m.Method]
if !ok {
result = map[string]any{}
}
f.write(w, map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": result})
}
if m.Method == "textDocument/didOpen" && f.push != nil {
f.write(w, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": f.push,
})
}
}
}
func (f *fake) write(w io.Writer, v any) {
b, _ := json.Marshal(v)
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(b))
w.Write(b)
}
func (f *fake) methods() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.seen...)
}
// dial puts a Conn on a fake over two pipes.
func dial(t *testing.T, f *fake) *Conn {
t.Helper()
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
go f.serve(toServer, fromServer)
c := newConn(fromClient, toClient, func() {})
t.Cleanup(c.Close)
return c
}
// sample writes a file into a temp dir and returns the dir and the abs path.
func sample(t *testing.T, name, body string) (string, string) {
t.Helper()
dir := t.TempDir()
abs := filepath.Join(dir, name)
if err := os.WriteFile(abs, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return dir, abs
}
// TestDefinitionDrivesTheProtocol is the core claim: given a position, the client
// performs initialize → initialized → didOpen → definition, in that order, and
// parses the Location the server answered with.
func TestDefinitionDrivesTheProtocol(t *testing.T) {
dir, abs := sample(t, "main.go", "package main\n\nfunc main() {}\n")
target := filepath.Join(dir, "other.go")
f := &fake{reply: map[string]any{
"initialize": map[string]any{"capabilities": map[string]any{}},
"textDocument/definition": []any{map[string]any{
"uri": pathURI(target),
"range": map[string]any{
"start": map[string]any{"line": 41, "character": 8},
"end": map[string]any{"line": 41, "character": 16},
},
}},
}}
c := dial(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
l := table["go"]
if err := c.handshake(ctx, l, dir); err != nil {
t.Fatalf("handshake: %v", err)
}
uri, err := c.Open(l, abs)
if err != nil {
t.Fatalf("didOpen: %v", err)
}
raw, err := c.Call(ctx, "textDocument/definition", map[string]any{
"textDocument": map[string]any{"uri": uri},
"position": map[string]any{"line": 2, "character": 5},
})
if err != nil {
t.Fatalf("definition: %v", err)
}
// The ORDER is the contract: a server asked before initialize completes is
// entitled to refuse, and one asked about a document it was never told about
// answers null.
want := []string{"initialize", "initialized", "textDocument/didOpen", "textDocument/definition"}
got := f.methods()
if len(got) != len(want) {
t.Fatalf("methods = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("method %d = %q, want %q", i, got[i], want[i])
}
}
locs := locations(raw, spellings(dir), nil)
if len(locs) != 1 {
t.Fatalf("locations = %d, want 1", len(locs))
}
if locs[0].Path != "other.go" || locs[0].External {
t.Errorf("location = %+v, want other.go inside the tree", locs[0])
}
// Positions pass through untouched — 0-based line, 0-based UTF-16 character.
if locs[0].Range.Start.Line != 41 || locs[0].Range.Start.Character != 8 {
t.Errorf("start = %+v, want {41 8} (LSP positions must not be re-based)", locs[0].Range.Start)
}
}
// TestServerRequestIsAnswered pins the deadlock this client is built to avoid: a
// server→client request (workspace/configuration, client/registerCapability)
// BLOCKS the server until it is answered. A client that only reads responses
// hangs here.
func TestServerRequestIsAnswered(t *testing.T) {
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
answered := make(chan struct{})
go func() {
defer fromServer.Close()
br := bufio.NewReader(toServer)
f := &fake{}
// Read the client's initialize, then turn around and ASK it something
// before answering — exactly what rust-analyzer does.
body, err := readFrame(br)
if err != nil {
return
}
var m struct {
ID json.RawMessage `json:"id"`
}
json.Unmarshal(body, &m)
f.write(fromServer, map[string]any{
"jsonrpc": "2.0", "id": 9001, "method": "client/registerCapability",
"params": map[string]any{"registrations": []any{}},
})
if _, err := readFrame(br); err != nil { // the client's reply
return
}
close(answered)
f.write(fromServer, map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{}})
for {
if _, err := readFrame(br); err != nil {
return
}
}
}()
c := newConn(fromClient, toClient, func() {})
t.Cleanup(c.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.handshake(ctx, table["go"], t.TempDir()); err != nil {
t.Fatalf("handshake: %v", err)
}
select {
case <-answered:
default:
t.Fatal("client never answered the server's request — a real server would still be blocked")
}
}
// TestDiagnosticsAreCollectedFromNotifications: diagnostics are published, not
// returned, so they only arrive if the reader routes notifications.
func TestDiagnosticsAreCollectedFromNotifications(t *testing.T) {
dir, abs := sample(t, "main.go", "package main\n")
uri := pathURI(abs)
f := &fake{
reply: map[string]any{"initialize": map[string]any{}},
push: map[string]any{
"uri": uri,
"diagnostics": []any{map[string]any{
"range": map[string]any{
"start": map[string]any{"line": 0, "character": 0},
"end": map[string]any{"line": 0, "character": 7},
},
"severity": 1,
"source": "compiler",
"message": "undefined: x",
}},
},
}
c := dial(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
l := table["go"]
if err := c.handshake(ctx, l, dir); err != nil {
t.Fatalf("handshake: %v", err)
}
if _, err := c.Open(l, abs); err != nil {
t.Fatalf("didOpen: %v", err)
}
got := c.Diagnostics(ctx, uri, 100*time.Millisecond)
if len(got) != 1 {
t.Fatalf("diagnostics = %d, want 1", len(got))
}
if got[0].Message != "undefined: x" || got[0].Severity != 1 {
t.Errorf("diagnostic = %+v, want severity 1 'undefined: x'", got[0])
}
}
// TestCallFailsWhenTheServerDies proves the client fails CLOSED: a dead server is
// an error, never a hang until the request's own deadline and never a silently
// empty answer that reads as "no definition found".
func TestCallFailsWhenTheServerDies(t *testing.T) {
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
go func() {
br := bufio.NewReader(toServer)
readFrame(br) // take the request
fromServer.Close() // then die without answering
}()
c := newConn(fromClient, toClient, func() {})
t.Cleanup(c.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := c.Call(ctx, "textDocument/hover", map[string]any{}); err == nil {
t.Fatal("Call succeeded against a dead server")
} else if ctx.Err() != nil {
t.Fatalf("Call waited for its own deadline instead of noticing the server: %v", err)
}
}
// TestCloseDoesNotHangOnAWedgedServer is the regression this suite found: Close
// used to write a polite `shutdown` unconditionally, and a server that had
// stopped reading its stdin left that write with nowhere to go — Close never
// returned, holding a pool slot and, at shutdown, the whole binary.
func TestCloseDoesNotHangOnAWedgedServer(t *testing.T) {
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
// A server that reads NOTHING and answers nothing. Writes to it block.
_ = toServer
c := newConn(fromClient, toClient, func() {})
done := make(chan struct{})
go func() { defer close(done); c.Close() }()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("Close hung on a server that stopped reading its stdin")
}
fromServer.Close()
}
// TestFrameRejectsAnOversizeLength: Content-Length is attacker-influenced (a
// server parsing tenant source emits it), so an absurd one must be refused rather
// than allocated.
func TestFrameRejectsAnOversizeLength(t *testing.T) {
r := bufio.NewReader(strings.NewReader("Content-Length: 999999999999\r\n\r\n"))
if _, err := readFrame(r); err == nil {
t.Fatal("readFrame accepted a length past maxFrame")
}
}
// TestFrameRejectsAMissingLength: a frame with no Content-Length has no boundary,
// so continuing to read desynchronizes the stream.
func TestFrameRejectsAMissingLength(t *testing.T) {
r := bufio.NewReader(strings.NewReader("Content-Type: application/vscode-jsonrpc\r\n\r\n"))
if _, err := readFrame(r); err == nil {
t.Fatal("readFrame accepted a frame with no Content-Length")
}
}
// TestURIRoundTrip: paths with characters that must be escaped survive both
// directions, so a definition in a directory with a space is still findable.
func TestURIRoundTrip(t *testing.T) {
for _, p := range []string{"/tmp/x/main.go", "/tmp/a b/c#d/main.go", "/tmp/100%/x.go"} {
if got := uriPath(pathURI(p)); got != p {
t.Errorf("round trip %q → %q", p, got)
}
}
if got := uriPath("jar:file:///x.jar!/A.class"); got != "" {
t.Errorf("non-file URI resolved to a path %q — it names nothing on our disk", got)
}
}
// TestExternalIsWhatLeftTheTree pins the field this whole service exists for: a
// target inside the tree is repo-relative and internal, one inside the dependency
// cache is named by its module coordinate and marked external.
func TestExternalIsWhatLeftTheTree(t *testing.T) {
tree := t.TempDir()
deps := t.TempDir()
raw := json.RawMessage(fmt.Sprintf(`[
{"uri":%q,"range":{"start":{"line":1,"character":2},"end":{"line":1,"character":7}}},
{"uri":%q,"range":{"start":{"line":3,"character":0},"end":{"line":3,"character":5}}}
]`,
pathURI(filepath.Join(tree, "pkg", "a.go")),
pathURI(filepath.Join(deps, "example.com", "[email protected]", "dep.go"))))
got := locations(raw, spellings(tree), spellings(deps))
if len(got) != 2 {
t.Fatalf("locations = %d, want 2", len(got))
}
if got[0].Path != "pkg/a.go" || got[0].External {
t.Errorf("in-tree = %+v, want pkg/a.go internal", got[0])
}
if got[1].Path != "example.com/[email protected]/dep.go" || !got[1].External {
t.Errorf("in-cache = %+v, want the module coordinate marked external", got[1])
}
}
+265
View File
@@ -0,0 +1,265 @@
package lsp
// gopls_test.go is THE acceptance test, and it asserts the one thing that
// justifies this daemon existing.
//
// A static symbol index over a repository can tell you where `Greet` is written
// in that repository. It structurally cannot tell you where `dep.Greet` is
// DEFINED, because the definition is in a module the repository only names — and
// finding it means downloading that module and type-checking both halves. This
// test does exactly that, end to end through the real HTTP door, and requires the
// answer to come back with External true and a module coordinate.
//
// It is hermetic. The dependency is published into a directory laid out as a Go
// module proxy and fetched through GOPROXY=file://, so the FETCH phase runs the
// real `go mod download` — writing a real go.sum into the tree and a real module
// into GOMODCACHE — with no network and no dependency on anybody's uptime.
import (
"archive/zip"
"encoding/json"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"testing"
"time"
)
// The tiny dependency and the tiny module that imports it.
const (
depPath = "example.com/dep"
depVersion = "v1.0.0"
depMod = "module example.com/dep\n\ngo 1.21\n"
depGo = `package dep
// Greet returns a greeting for name.
func Greet(name string) string { return "hello " + name }
`
appMod = "module example.com/app\n\ngo 1.21\n\nrequire example.com/dep v1.0.0\n"
appGo = `package main
import "example.com/dep"
func main() {
_ = dep.Greet("world")
}
`
)
func TestDefinitionCrossesIntoTheDependency(t *testing.T) {
needTool(t, "go")
needTool(t, "gopls")
proxy := t.TempDir()
publish(t, proxy, depPath, depVersion, map[string]string{
"go.mod": depMod,
"dep.go": depGo,
})
r := launch(t, "file://"+filepath.ToSlash(proxy))
r.needJail(t)
rev := sha(1)
code, body := r.post(t, "/root", Tree{
Org: "acme", Repo: "app", Rev: rev,
Files: []File{
{Path: "go.mod", Content: appMod},
{Path: "main.go", Content: appGo},
},
})
if code != 200 {
t.Fatalf("POST /root = %d %v", code, body)
}
if got := body["cold"]; got != true {
t.Errorf("first /root reported cold=%v, want true", got)
}
if !hasLang(body, "go") {
t.Fatalf("/root served no Go server: %v", body)
}
// The dependency must really be in the cache: if the fetch phase did not run,
// the definition below could only ever resolve inside the tree, and the test
// would be asserting nothing.
cached := filepath.Join(r.cfg.Deps, "go", depPath+"@"+depVersion, "dep.go")
if _, err := os.Stat(cached); err != nil {
t.Fatalf("the fetch phase did not populate the module cache (%s): %v", cached, err)
}
line, char := find(appGo, "Greet")
t.Run("locate definition", func(t *testing.T) {
body := r.ask(t, Ask{
Org: "acme", Repo: "app", Rev: rev,
Op: "locate", Relation: "definition",
Path: "main.go", Line: line, Character: char,
}, func(b map[string]any) bool { return len(locs(b)) > 0 })
got := locs(body)
if len(got) == 0 {
t.Fatalf("no location for dep.Greet: %v", body)
}
first := got[0]
if !first.External {
t.Fatalf("definition = %+v, want External true — it is in a module, not in the repo", first)
}
want := path.Join(depPath+"@"+depVersion, "dep.go")
if first.Path != want {
t.Fatalf("definition path = %q, want %q (the module coordinate)", first.Path, want)
}
if first.Range.Start.Line != 3 {
t.Errorf("definition line = %d, want 3 (0-based, where Greet is declared)", first.Range.Start.Line)
}
})
t.Run("hover reads the dependency's doc", func(t *testing.T) {
body := r.ask(t, Ask{
Org: "acme", Repo: "app", Rev: rev,
Op: "hover", Path: "main.go", Line: line, Character: char,
}, func(b map[string]any) bool { return b["hover"] != nil })
text, _ := body["hover"].(string)
if !strings.Contains(text, "Greet") {
t.Fatalf("hover = %q, want it to mention Greet", text)
}
if !strings.Contains(text, "returns a greeting") {
t.Errorf("hover = %q, want the DEPENDENCY's doc comment — that is what resolving through the module buys", text)
}
})
t.Run("second root is warm", func(t *testing.T) {
code, body := r.post(t, "/root", Tree{
Org: "acme", Repo: "app", Rev: rev,
Files: []File{{Path: "go.mod", Content: appMod}, {Path: "main.go", Content: appGo}},
})
if code != 200 {
t.Fatalf("second /root = %d %v", code, body)
}
if got := body["cold"]; got != false {
t.Errorf("second /root reported cold=%v, want false — the root is held", got)
}
})
t.Run("warm is accepted for a repo already held", func(t *testing.T) {
code, body := r.post(t, "/root/warm", Tree{
Org: "acme", Repo: "app", Rev: sha(2),
Files: []File{{Path: "go.mod", Content: appMod}, {Path: "main.go", Content: appGo}},
})
if code != 200 {
t.Fatalf("POST /root/warm = %d %v", code, body)
}
})
}
// ask posts a question and retries until done says the answer arrived.
//
// gopls loads a workspace asynchronously and its first answer after a cold start
// can race that load, so a single shot is a flaky assertion about timing rather
// than about resolution. This polls the real door on the real clock and fails with
// the last body it saw.
func (r *rig) ask(t *testing.T, in Ask, done func(map[string]any) bool) map[string]any {
t.Helper()
deadline := time.Now().Add(90 * time.Second)
var last map[string]any
for {
code, body := r.post(t, "/ask", in)
if code != 200 {
t.Fatalf("POST /ask = %d %v", code, body)
}
last = body
if done(body) {
return body
}
if time.Now().After(deadline) {
t.Fatalf("gopls never answered %s within the deadline; last body %v", in.Op, last)
}
time.Sleep(500 * time.Millisecond)
}
}
// locs decodes the locations out of a raw answer body.
func locs(body map[string]any) []Location {
raw, err := json.Marshal(body["locations"])
if err != nil {
return nil
}
var out []Location
if json.Unmarshal(raw, &out) != nil {
return nil
}
return out
}
func hasLang(body map[string]any, want string) bool {
list, _ := body["langs"].([]any)
for _, l := range list {
if s, _ := l.(string); s == want {
return true
}
}
return false
}
// find returns the 0-based line and character of the first occurrence of needle.
// The fixtures are ASCII, so a byte index is also a UTF-16 code-unit index —
// which is what the protocol counts, and what the daemon passes through untouched.
func find(src, needle string) (int, int) {
for i, line := range strings.Split(src, "\n") {
if col := strings.Index(line, needle); col >= 0 {
return i, col
}
}
return -1, -1
}
func needTool(t *testing.T, name string) {
t.Helper()
if _, err := exec.LookPath(name); err != nil {
t.Skipf("%s is not installed here; the image ships it", name)
}
}
// publish writes one module version into a directory laid out as a Go module
// proxy: the .info, .mod and .zip a `GOPROXY=file://` fetch reads.
func publish(t *testing.T, proxy, module, version string, files map[string]string) {
t.Helper()
dir := filepath.Join(proxy, filepath.FromSlash(module), "@v")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("proxy dir: %v", err)
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
write("list", version+"\n")
write(version+".info", fmt.Sprintf(`{"Version":%q,"Time":"2024-01-01T00:00:00Z"}`, version))
write(version+".mod", files["go.mod"])
var buf strings.Builder
zw := zip.NewWriter(&stringWriter{&buf})
for name, body := range files {
// Every entry is prefixed module@version, which is the layout the module
// zip format requires and how the cache lays the module out on disk.
w, err := zw.Create(module + "@" + version + "/" + name)
if err != nil {
t.Fatalf("zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
write(version+".zip", buf.String())
}
// stringWriter adapts a strings.Builder to io.Writer for archive/zip.
type stringWriter struct{ b *strings.Builder }
func (w *stringWriter) Write(p []byte) (int, error) { return w.b.Write(p) }
+514
View File
@@ -0,0 +1,514 @@
package lsp
// http.go is the door: three POSTs and two probes.
//
// The shape follows from roots being immutable and expensive. A caller cannot
// ask a question about a tree the daemon does not hold, and the daemon cannot go
// and get one — it has no git credential and no reason to have one. So /ask
// answers 409 {"need":"tree"} and the caller, which already has the repository,
// sends it to /root and asks again. Two calls on a cold root, one on a warm one,
// and no path by which this daemon reaches out to anything.
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"log/slog"
"net/http"
"regexp"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/hanzoai/lsp/internal/jail"
)
// diagSettle is how long diagnostics must stay unchanged before the snapshot is
// taken. See Conn.Diagnostics — LSP has no completion signal for them.
const diagSettle = 400 * time.Millisecond
// bodyMost bounds a request body. /root carries a whole tree, so the cap is the
// tree cap plus the room JSON escaping needs.
const bodyMost = byteMost + (32 << 20)
// Service is the daemon.
type Service struct {
cfg Config
key string
log *slog.Logger
pool *pool
// cold bounds how many roots are built at once. A cold start is a dependency
// fetch and a language server's first index, which is where all the memory
// and all the CPU are.
cold chan struct{}
// ready is the jail's verdict, and it is why /readyz can be believed. On
// Linux it is set only after Probe watched a served process be refused a
// socket; when it is false every request that would run anything is refused.
ready atomic.Bool
why atomic.Pointer[string]
}
// New builds the daemon. key is the shared service key the caller presents; an
// empty key fails every guarded route closed.
func New(cfg Config, key string, log *slog.Logger) *Service {
if log == nil {
log = slog.Default()
}
return &Service{
cfg: cfg,
key: strings.TrimSpace(key),
log: log,
pool: newPool(),
cold: make(chan struct{}, coldMost),
}
}
// Prove runs the jail's boot self-test and decides whether this daemon may serve.
//
// On Linux the jail is REQUIRED: a pod without the gVisor runtime class, or one
// where the namespaces cannot be created, fails here and then refuses every
// request. That is the fail-closed spine, and it has no override — there is no
// environment variable that turns the jail off, so there is none that can be set
// by accident on the thing that matters.
//
// Off Linux there is no jail to require. The image is linux/amd64, so that case
// is a developer's machine; it is loud in the log and visible on /readyz.
func (s *Service) Prove(ctx context.Context) {
if !jail.Supported() {
s.log.Warn("NO JAIL ON THIS PLATFORM — development only. " +
"Language servers will run unconfined: never do this anywhere real.")
s.note("unjailed: this platform has no namespaces")
s.ready.Store(true)
return
}
if err := jail.Probe(ctx, s.cfg.Stage+"/probe", s.cfg.Stage); err != nil {
s.log.Error("JAIL SELF-TEST FAILED — refusing every request", "err", err)
s.note("jail did not initialize: " + err.Error())
s.ready.Store(false)
return
}
s.log.Info("jail proven on this host",
"boundary", "gVisor + userns + minimal chroot + seccomp(socket denied when serving) + rlimits")
s.note("jailed")
s.ready.Store(true)
}
func (s *Service) note(v string) { s.why.Store(&v) }
func (s *Service) status() string {
if v := s.why.Load(); v != nil {
return *v
}
return "not yet probed"
}
// Close releases every live root and its servers.
func (s *Service) Close() { s.pool.closeAll() }
// Routes is the whole surface. The stdlib router does method and wildcards, so
// there is no third-party mux in a process whose dependency count is the point.
func (s *Service) Routes() http.Handler {
mux := http.NewServeMux()
// The probes are unauthenticated: they are the kubelet's, and they disclose
// nothing a caller could not learn by connecting.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /readyz", s.handleReady)
mux.Handle("POST /root", s.guard(http.HandlerFunc(s.handleRoot)))
mux.Handle("POST /root/warm", s.guard(http.HandlerFunc(s.handleWarm)))
mux.Handle("POST /ask", s.guard(http.HandlerFunc(s.handleAsk)))
return mux
}
// guard is the constant-time shared-key check, and it fails CLOSED twice over:
// an unset key means the daemon is misconfigured, which is a 503 and not an open
// door; a wrong key is a 401 that took the same time to compute as a right one.
//
// This is authorization to REACH the daemon, not authentication of a tenant. The
// caller behind this key is the cloud API, and it is what turns a user into the
// org this daemon isolates by.
func (s *Service) guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.key == "" {
writeErr(w, http.StatusServiceUnavailable, "daemon not configured")
return
}
got := strings.TrimSpace(r.Header.Get("X-API-Key"))
if subtle.ConstantTimeCompare([]byte(got), []byte(s.key)) != 1 {
writeErr(w, http.StatusUnauthorized, "invalid api key")
return
}
if !s.ready.Load() {
writeErr(w, http.StatusServiceUnavailable, "sandbox unavailable")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Service) handleReady(w http.ResponseWriter, _ *http.Request) {
code := http.StatusOK
if !s.ready.Load() {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, map[string]any{
"ready": s.ready.Load(),
"jail": s.status(),
"langs": served(),
"status": http.StatusText(code),
})
}
// served is the languages this IMAGE can run — the table narrowed to the
// toolchains actually installed. It is what makes the table a claim the
// deployment keeps.
func served() []string {
out := make([]string, 0, len(table))
for _, name := range names {
if table[name].Available() {
out = append(out, name)
}
}
return out
}
// ── /root ────────────────────────────────────────────────────────────────────
// Tree is the body of /root: a whole working tree, sent by the caller.
type Tree struct {
Org string `json:"org"`
Repo string `json:"repo"`
Rev string `json:"rev"`
Files []File `json:"files"`
}
// Ready is what /root answers.
type Ready struct {
Ready bool `json:"ready"`
Cold bool `json:"cold"`
Langs []string `json:"langs"`
}
func (s *Service) handleRoot(w http.ResponseWriter, r *http.Request) { s.root(w, r, false) }
// handleWarm is /root with one extra rule: it is only accepted when this org
// already holds a root for this repo.
//
// Warming a revision nobody has asked about is work the caller has not paid for,
// and an unguarded door for it is a way to make this daemon fetch and index
// anything. Requiring a live root for the same repository means a warm request
// can only ever follow a cold start the caller already paid for.
func (s *Service) handleWarm(w http.ResponseWriter, r *http.Request) { s.root(w, r, true) }
func (s *Service) root(w http.ResponseWriter, r *http.Request, warm bool) {
var in Tree
if err := decode(r, &in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
k, err := keyOf(in.Org, in.Repo, in.Rev)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if warm && !s.pool.holds(k.org, k.repo) {
writeJSON(w, http.StatusConflict, map[string]any{
"need": "held", "org": k.org, "repo": k.repo,
"error": "warming is only accepted for a repository that already has a live root",
})
return
}
root, cold, err := s.open(r.Context(), k, in.Files)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
writeErr(w, http.StatusGatewayTimeout, "root build timed out")
return
}
s.log.Warn("root build failed", "org", k.org, "repo", k.repo, "err", err)
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, Ready{Ready: true, Cold: cold, Langs: root.Langs()})
}
// open returns the live root for k, building it from files if it is not held.
// The bool reports a COLD start: the tree write, the dependency fetch and each
// server's first index.
func (s *Service) open(ctx context.Context, k key, files []File) (*Root, bool, error) {
root, mine := s.pool.claim(k)
if !mine {
select {
case <-root.ready:
case <-ctx.Done():
return nil, false, ctx.Err()
}
return root, false, root.err
}
// Bound how many cold starts run at once. Excess requests WAIT — a cold start
// is minutes of a dependency fetch and an index, and letting them pile up is
// how one pod OOMs for everybody.
select {
case s.cold <- struct{}{}:
defer func() { <-s.cold }()
case <-ctx.Done():
s.pool.settle(root, nil, ctx.Err())
return nil, true, ctx.Err()
}
built, err := s.build(ctx, k, files)
s.pool.settle(root, built, err)
if err != nil {
return nil, true, err
}
return root, true, nil
}
// ── /ask ─────────────────────────────────────────────────────────────────────
// Ask is one question about one position in one root.
type Ask struct {
Org string `json:"org"`
Repo string `json:"repo"`
Rev string `json:"rev"`
// Op is hover, locate, symbols, diagnostics or complete.
Op string `json:"op"`
// Relation refines locate: definition, reference, type or implementation.
Relation string `json:"relation,omitempty"`
// Path is tree-relative.
Path string `json:"path"`
// Line is 0-based, per the LSP specification.
Line int `json:"line"`
// Character is a 0-based UTF-16 code-unit offset within Line, per the LSP
// specification — not a byte offset and not a rune index.
Character int `json:"character"`
}
// Answer carries whichever result the op produces. Exactly one result field is
// populated, so a client reads the field its op names and never discriminates a
// union.
type Answer struct {
Op string `json:"op"`
Lang string `json:"lang"`
Locations []Location `json:"locations,omitempty"`
Hover string `json:"hover,omitempty"`
Symbols []Symbol `json:"symbols,omitempty"`
Completions []Completion `json:"completions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// ops and relations are CLOSED sets. The door names what it serves, so an
// unknown string is a 400 here rather than an arbitrary method handed to a
// language server.
var (
ops = []string{"hover", "locate", "symbols", "diagnostics", "complete"}
relations = []string{"definition", "reference", "type", "implementation"}
)
func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
var in Ask
if err := decode(r, &in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
k, err := keyOf(in.Org, in.Repo, in.Rev)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if !slices.Contains(ops, in.Op) {
writeErr(w, http.StatusBadRequest, "op must be one of "+strings.Join(ops, ", "))
return
}
if in.Op == "locate" && !slices.Contains(relations, in.Relation) {
writeErr(w, http.StatusBadRequest, "locate needs a relation: "+strings.Join(relations, ", "))
return
}
if in.Line < 0 || in.Character < 0 {
writeErr(w, http.StatusBadRequest, "line and character are 0-based and cannot be negative")
return
}
root, held, err := s.pool.hold(r.Context(), k)
if err != nil {
writeErr(w, http.StatusBadRequest, "root unavailable")
return
}
if held {
defer root.release()
} else {
// The daemon holds no credential and makes no git request, so it cannot
// go and get the tree. The caller has it; this says so precisely.
writeJSON(w, http.StatusConflict, map[string]any{
"need": "tree", "org": k.org, "repo": k.repo, "rev": k.rev,
"error": "no root for this revision — POST /root with the tree, then ask again",
})
return
}
l, ok := langOf(root, in.Path)
if !ok {
writeErr(w, http.StatusBadRequest, "no language server in this image answers for "+in.Path)
return
}
abs, err := clean(root.dir, in.Path)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
uri, err := l.conn.Open(l.lang, abs)
if err != nil {
s.log.Warn("didOpen failed", "org", k.org, "repo", k.repo, "err", err)
writeErr(w, http.StatusBadGateway, "language server did not accept the document")
return
}
out, err := resolve(r.Context(), root, l, uri, &in)
if err != nil {
s.log.Warn("query failed", "org", k.org, "repo", k.repo, "op", in.Op, "err", err)
writeErr(w, http.StatusBadGateway, "language server did not answer")
return
}
writeJSON(w, http.StatusOK, out)
}
// langOf picks the live server that answers for path.
func langOf(r *Root, path string) (*live, bool) {
l, ok := langFor(path)
if !ok {
return nil, false
}
got := r.langs[l.Name]
return got, got != nil
}
// resolve asks the server the one question and folds the reply into an Answer.
//
// diagnostics is the outlier and is handled first: it is not a request at all in
// LSP but an unsolicited notification the server publishes after didOpen, so it
// is collected rather than called.
func resolve(ctx context.Context, r *Root, l *live, uri string, in *Ask) (*Answer, error) {
ctx, cancel := context.WithTimeout(ctx, callWait)
defer cancel()
out := &Answer{Op: in.Op, Lang: l.lang.Name}
if in.Op == "diagnostics" {
out.Diagnostics = l.conn.Diagnostics(ctx, uri, diagSettle)
if out.Diagnostics == nil {
out.Diagnostics = []Diagnostic{}
}
return out, nil
}
doc := map[string]any{"uri": uri}
params := map[string]any{
"textDocument": doc,
"position": map[string]any{"line": in.Line, "character": in.Character},
}
var method string
switch in.Op {
case "hover":
method = "textDocument/hover"
case "complete":
method = "textDocument/completion"
case "symbols":
method = "textDocument/documentSymbol"
params = map[string]any{"textDocument": doc} // no position: the whole file
case "locate":
switch in.Relation {
case "definition":
method = "textDocument/definition"
case "type":
method = "textDocument/typeDefinition"
case "implementation":
method = "textDocument/implementation"
case "reference":
method = "textDocument/references"
params["context"] = map[string]any{"includeDeclaration": true}
}
}
raw, err := l.conn.Call(ctx, method, params)
if err != nil {
return nil, err
}
// A null result is not an error: "nothing here" is a real answer and it
// arrives as JSON null. Every branch leaves the field empty rather than
// failing, so a caller tells "nothing found" from "the server broke" by
// status code and not by guesswork.
if len(raw) == 0 || string(raw) == "null" {
return out, nil
}
switch in.Op {
case "hover":
out.Hover = hover(raw)
case "symbols":
out.Symbols = symbols(raw)
case "complete":
out.Completions = completions(raw)
case "locate":
out.Locations = locations(raw, r.dirs, l.deps)
}
return out, nil
}
// ── narrowing the key ────────────────────────────────────────────────────────
var (
// org and repo are path segments under the daemon's data directory, so they
// carry no slash and no leading dot: a key cannot climb out of its own tenant
// or name another one.
orgRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
repoRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$`)
// rev is a RESOLVED commit and nothing else. That single rule is what makes a
// root immutable, and therefore what removes cache invalidation from this
// service: a branch name would move, a sha never does. The caller resolves
// refs; it is the one that has the repository.
revRe = regexp.MustCompile(`^[0-9a-f]{40}$|^[0-9a-f]{64}$`)
)
func keyOf(org, repo, rev string) (key, error) {
k := key{
org: strings.TrimSpace(org),
repo: strings.TrimSpace(repo),
rev: strings.ToLower(strings.TrimSpace(rev)),
}
switch {
case !orgRe.MatchString(k.org):
return key{}, errors.New("org must be a lowercase slug")
case !repoRe.MatchString(k.repo):
return key{}, errors.New("repo must be a repository name")
case !revRe.MatchString(k.rev):
return key{}, errors.New("rev must be a resolved commit sha (40 or 64 hex digits)")
}
return k, nil
}
// ── small helpers ────────────────────────────────────────────────────────────
func decode(r *http.Request, v any) error {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, bodyMost)).Decode(v)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeErr answers with a message chosen HERE. A language server's own error text
// echoes tenant source and this daemon's paths, so it goes to the log and never
// to the wire.
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]any{"status": status, "error": msg})
}
+252
View File
@@ -0,0 +1,252 @@
package lsp
// http_test.go drives the door itself: what it refuses, and what it says when it
// refuses. Nothing here needs a language server — these are the answers that must
// hold before one is ever started.
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestKeyIsRequiredAndConstantTime(t *testing.T) {
r := launch(t, "")
body := Ask{Org: "acme", Repo: "app", Rev: sha(1), Op: "hover", Path: "main.go"}
if code, _ := r.postKey(t, "/ask", body, ""); code != http.StatusUnauthorized {
t.Errorf("no key = %d, want 401", code)
}
if code, _ := r.postKey(t, "/ask", body, "wrong"); code != http.StatusUnauthorized {
t.Errorf("wrong key = %d, want 401", code)
}
// A key that is a PREFIX of the real one must fare no better: the comparison
// is over the whole value, in constant time.
if code, _ := r.postKey(t, "/ask", body, testKey[:4]); code != http.StatusUnauthorized {
t.Errorf("prefix key = %d, want 401", code)
}
}
// An unconfigured daemon is not an open one. With no key it refuses everything
// with 503 rather than serving whoever asks.
func TestUnconfiguredDaemonRefuses(t *testing.T) {
svc := New(Config{Roots: t.TempDir(), Deps: t.TempDir(), Stage: t.TempDir()}, "",
slog.New(slog.NewTextHandler(io.Discard, nil)))
t.Cleanup(svc.Close)
svc.Prove(context.Background())
srv := httptest.NewServer(svc.Routes())
t.Cleanup(srv.Close)
r := &rig{http: srv, svc: svc, ready: svc.ready.Load()}
if code, _ := r.post(t, "/ask", Ask{}); code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured daemon = %d, want 503", code)
}
}
// The 409 is the whole cold protocol: the daemon holds no credential and cannot
// go and get a tree, so it says precisely what it needs and from whom.
func TestAskWithoutARootAsksForTheTree(t *testing.T) {
r := launch(t, "")
r.needJail(t)
code, body := r.post(t, "/ask", Ask{
Org: "acme", Repo: "app", Rev: sha(3),
Op: "hover", Path: "main.go", Line: 0, Character: 0,
})
if code != http.StatusConflict {
t.Fatalf("/ask with no root = %d %v, want 409", code, body)
}
if body["need"] != "tree" {
t.Errorf("need = %v, want \"tree\"", body["need"])
}
if body["rev"] != sha(3) {
t.Errorf("the 409 must echo the revision to send: %v", body)
}
}
// Warming is free work, so it is only offered where a cold start has already been
// paid for on the same repository.
func TestWarmIsRefusedForARepoNotHeld(t *testing.T) {
r := launch(t, "")
r.needJail(t)
code, body := r.post(t, "/root/warm", Tree{
Org: "acme", Repo: "app", Rev: sha(4),
Files: []File{{Path: "notes.txt", Content: "hi"}},
})
if code != http.StatusConflict {
t.Fatalf("/root/warm with nothing held = %d %v, want 409", code, body)
}
if body["need"] != "held" {
t.Errorf("need = %v, want \"held\"", body["need"])
}
}
// A revision must be a resolved commit. That single rule is what makes a root
// immutable — and therefore what removes cache invalidation from this daemon — so
// it is refused at the door rather than coped with later.
func TestRevisionMustBeAResolvedCommit(t *testing.T) {
r := launch(t, "")
r.needJail(t)
for _, rev := range []string{"main", "v1.2.3", "HEAD", "", "abc", sha(1) + "0", "../" + sha(1)[3:]} {
code, _ := r.post(t, "/ask", Ask{
Org: "acme", Repo: "app", Rev: rev, Op: "hover", Path: "main.go",
})
if code != http.StatusBadRequest {
t.Errorf("rev %q = %d, want 400 — only a resolved sha names an immutable tree", rev, code)
}
}
// Hex is case-insensitive and both spellings name the SAME commit, so the
// revision is folded before it is a key. Two roots for one tree would be two
// gopls processes and two answers to one question.
upper := strings.ToUpper(sha(1))
code, body := r.post(t, "/ask", Ask{
Org: "acme", Repo: "app", Rev: upper, Op: "hover", Path: "main.go",
})
if code != http.StatusConflict {
t.Fatalf("uppercase sha = %d %v, want 409 (accepted, no root yet)", code, body)
}
if body["rev"] != sha(1) {
t.Errorf("the 409 echoed %v, want the folded %q", body["rev"], sha(1))
}
}
func TestTenantNamesAreNarrowed(t *testing.T) {
r := launch(t, "")
r.needJail(t)
cases := []struct{ org, repo string }{
{"../other", "app"},
{"acme/evil", "app"},
{".hidden", "app"},
{"acme", "../../etc"},
{"acme", "a/b"},
{"ACME", "app"}, // orgs are lowercase slugs
{"", "app"},
{"acme", ""},
}
for _, c := range cases {
code, _ := r.post(t, "/ask", Ask{
Org: c.org, Repo: c.repo, Rev: sha(1), Op: "hover", Path: "main.go",
})
if code != http.StatusBadRequest {
t.Errorf("org=%q repo=%q = %d, want 400", c.org, c.repo, code)
}
}
}
// A tree is tenant content. A path that climbs out of it is refused BEFORE
// anything is written, so there is no window in which a file lands outside the
// root and is then cleaned up.
func TestTreePathsCannotEscape(t *testing.T) {
r := launch(t, "")
r.needJail(t)
for _, p := range []string{"../evil", "/etc/passwd", "a/../../evil", ".."} {
code, body := r.post(t, "/root", Tree{
Org: "acme", Repo: "app", Rev: sha(5),
Files: []File{{Path: p, Content: "x"}},
})
if code != http.StatusBadRequest {
t.Errorf("tree path %q = %d %v, want 400", p, code, body)
}
}
}
func TestOpsAndRelationsAreClosedSets(t *testing.T) {
r := launch(t, "")
r.needJail(t)
base := Ask{Org: "acme", Repo: "app", Rev: sha(1), Path: "main.go"}
for _, op := range []string{"", "definition", "textDocument/definition", "rename", "exec"} {
in := base
in.Op = op
if code, _ := r.post(t, "/ask", in); code != http.StatusBadRequest {
t.Errorf("op %q = %d, want 400 — the door names what it serves", op, code)
}
}
// locate without a relation is not a question.
in := base
in.Op = "locate"
if code, _ := r.post(t, "/ask", in); code != http.StatusBadRequest {
t.Errorf("locate with no relation = %d, want 400", code)
}
in.Relation = "callers"
if code, _ := r.post(t, "/ask", in); code != http.StatusBadRequest {
t.Errorf("unknown relation = %d, want 400", code)
}
// A negative position is a caller that re-based a 1-based editor line.
in.Relation = "definition"
in.Line = -1
if code, _ := r.post(t, "/ask", in); code != http.StatusBadRequest {
t.Errorf("negative line = %d, want 400", code)
}
}
// A root whose files no installed server answers for still builds — it just
// serves nothing, and says so, rather than failing.
func TestRootWithNoServableLanguage(t *testing.T) {
r := launch(t, "")
r.needJail(t)
code, body := r.post(t, "/root", Tree{
Org: "acme", Repo: "docs", Rev: sha(6),
Files: []File{{Path: "README.txt", Content: "nothing to see"}},
})
if code != http.StatusOK {
t.Fatalf("/root = %d %v", code, body)
}
if langs, _ := body["langs"].([]any); len(langs) != 0 {
t.Errorf("langs = %v, want none", langs)
}
code, body = r.post(t, "/ask", Ask{
Org: "acme", Repo: "docs", Rev: sha(6),
Op: "hover", Path: "README.txt",
})
if code != http.StatusBadRequest {
t.Fatalf("/ask on an unserved file = %d %v, want 400", code, body)
}
}
// An empty tree is not a root.
func TestEmptyTreeIsRefused(t *testing.T) {
r := launch(t, "")
r.needJail(t)
if code, _ := r.post(t, "/root", Tree{Org: "acme", Repo: "app", Rev: sha(7)}); code != http.StatusBadRequest {
t.Fatalf("empty tree = %d, want 400", code)
}
}
// Liveness answers while the process is up; readiness answers what the jail
// decided. They are different questions and must not collapse into one.
func TestProbesAreUnauthenticated(t *testing.T) {
r := launch(t, "")
resp, err := http.Get(r.http.URL + "/healthz")
if err != nil {
t.Fatalf("healthz: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("healthz = %d, want 200 while the process is up", resp.StatusCode)
}
resp, err = http.Get(r.http.URL + "/readyz")
if err != nil {
t.Fatalf("readyz: %v", err)
}
resp.Body.Close()
want := http.StatusServiceUnavailable
if r.ready {
want = http.StatusOK
}
if resp.StatusCode != want {
t.Errorf("readyz = %d, want %d", resp.StatusCode, want)
}
}
+292
View File
@@ -0,0 +1,292 @@
package lsp
// langs.go is the language table — the ONE place that answers four questions
// about a language: how to RECOGNIZE it in a tree, how to START its server, how
// to FETCH its dependencies, and WHERE those dependencies land.
//
// PORTED from hanzoai/cloud branch `lsp` (apps/lsp/langs.go, 67975187), whose
// servers and argv were themselves ported from the Python tool
// (hanzo/python-sdk/pkg/hanzo-tools-lsp). A second opinion about how to spawn
// gopls is a second bug surface, so there is not one.
//
// # A table entry is a claim, and Available is what makes it true
//
// Every language below is complete: argv, markers, extensions, cache, fetch. What
// decides whether one is SERVED is whether its binary is on PATH in the image
// (Available), so the table never promises what the deployment cannot do, and
// adding a language to production is adding a toolchain to the Dockerfile — no
// code change, no entry to remember to write.
//
// Phase 1 ships the Go toolchain and gopls. The other four entries are inert in
// that image and light up the moment their toolchain is installed.
//
// # Scripts-off, and why the fetch phase does not weaken it
//
// Fetching dependencies is the dangerous half. `npm install` runs postinstall;
// `cargo build` runs build.rs; `pip install` of an sdist runs setup.py. Each is
// third-party code executing as us, triggered by whatever tree the caller sent —
// and it would be executing in the ONE phase that has a network. That is exactly
// the pairing the whole daemon exists to prevent.
//
// It is also UNNECESSARY. A language server resolves definitions, references and
// types from SOURCE — the dependency's .go/.d.ts/.pyi/.rs files — not from what a
// build script produces. Turning scripts off costs some generated code and some
// proc-macro expansion; it does not cost go-to-definition.
//
// So Executes marks the fetches that run dependency-authored code, and Fetchable
// is the ONE predicate that reads it. Today it refuses them all, and no
// configuration turns it on: a switch here would only be a way to lose the
// argument at three in the morning.
import (
"os/exec"
"path"
"slices"
"strings"
)
// Lang is one language this daemon can speak.
type Lang struct {
// Name is BOTH the table key and the LSP languageId sent on didOpen. One
// string, so a language cannot be called one thing here and another on the
// wire. (The per-file refinement TypeScript needs — tsx vs jsx vs plain js —
// is a property of the FILE, not the language, and lives in ID.)
Name string
// Start is the argv that runs the server speaking JSON-RPC on its stdio.
Start []string
// Roots are the marker files that make a directory a module of this
// language. They locate the FETCH; the server itself is rooted at the tree.
Roots []string
// Exts are the file extensions this server answers for.
Exts []string
// Cache is this language's dependency cache, as a subdirectory of the
// daemon's dependency root. It is bound writable during Fetch and read-only
// during Serve, and it is the origin a definition OUTSIDE the tree is named
// against — so for Go it is GOMODCACHE itself, which makes an external path
// read as the module coordinate it is.
Cache string
// Fetch is the argv that populates Cache, run once per module directory.
// Nil means the language has nothing to fetch.
Fetch []string
// Executes reports that Fetch runs code authored by the DEPENDENCIES rather
// than only downloading them. It is the whole of the scripts-off policy's
// input; Fetchable is the policy.
Executes bool
// Env is the environment both phases share. ${cache}, ${proxy} and ${sumdb}
// are substituted from the daemon's configuration.
Env []string
// FetchEnv is added during Fetch, ServeEnv during Serve. This is where the
// module proxy is named in one and turned off in the other.
FetchEnv []string
ServeEnv []string
// Init is initializationOptions on the initialize request. It is where a
// server that would otherwise run project code at load time is told not to.
Init map[string]any
}
// table is every language this daemon speaks, keyed by Lang.Name.
var table = map[string]Lang{
"go": {
Name: "go",
Start: []string{"gopls", "serve", "-mode=stdio"},
Roots: []string{"go.work", "go.mod"},
Exts: []string{".go"},
Cache: "go",
// `go mod download` resolves and verifies modules against the checksum
// database. It does NOT build them, so no module's code runs: the Go
// toolchain has no install-time hook to abuse. Executes stays false, and
// this is the one fetch phase 1 actually performs.
//
// `all`, not the bare form, and that word is load-bearing. Bare `go mod
// download` in a module at go1.17+ records only each dependency's
// go.mod hash in go.sum — NOT the module zip's. The serve phase then
// loads with -mod=readonly and no network, hits "missing go.sum entry",
// and gopls answers null to every question with no error anywhere: a
// service that silently never works. `all` records both hashes, for the
// test closure too, which is what makes a _test.go file answerable.
Fetch: []string{"go", "mod", "download", "all"},
Env: []string{
// GOTOOLCHAIN=local: a tenant's go.mod can name any Go version, and
// the default would go and DOWNLOAD that toolchain — an unbounded
// fetch in the fetch phase and an impossible one in the serve phase.
// A repo that needs a newer Go degrades to unresolved imports, which
// is the honest failure.
"GOTOOLCHAIN=local",
"GOMODCACHE=${cache}",
// GOPATH and GOCACHE are ephemeral by design: /tmp is the only thing
// a served process can write, and it dies with the process.
"GOPATH=/tmp/gopath",
"GOCACHE=/tmp/gocache",
"GOENV=off",
},
FetchEnv: []string{"GOPROXY=${proxy}", "GOSUMDB=${sumdb}", "GOFLAGS=-mod=mod"},
// GOPROXY=off and -mod=readonly are belt to the seccomp braces: the serve
// phase could not reach a proxy if it tried, and it cannot write the tree
// to record having tried.
ServeEnv: []string{"GOPROXY=off", "GOFLAGS=-mod=readonly"},
},
"rust": {
Name: "rust",
Start: []string{"rust-analyzer"},
Roots: []string{"Cargo.toml"},
Exts: []string{".rs"},
Cache: "cargo",
// `cargo fetch` downloads and unpacks; it does not compile, so no build.rs
// runs. `cargo build` would, which is why the fetch is not that.
Fetch: []string{"cargo", "fetch", "--locked"},
Env: []string{"CARGO_HOME=${cache}", "RUSTUP_TOOLCHAIN=stable"},
// The fetch being safe is not enough: rust-analyzer COMPILES AND RUNS
// build.rs and expands proc macros at workspace load, by default. That is
// the same code execution the fetch was careful to avoid, arriving through
// the server instead. Both are turned off here — scripts-off has to hold
// for the server or it does not hold at all.
Init: map[string]any{
"cargo": map[string]any{"buildScripts": map[string]any{"enable": false}},
"procMacro": map[string]any{"enable": false},
},
},
"typescript": {
Name: "typescript",
Start: []string{"typescript-language-server", "--stdio"},
Roots: []string{"tsconfig.json", "package.json"},
Exts: []string{".ts", ".tsx", ".js", ".jsx"},
Cache: "npm",
// --ignore-scripts is the whole reason this fetch is allowed: it is npm's
// own switch for "place the tree, run none of its lifecycle hooks". The
// .d.ts files under node_modules are what the server actually reads, and
// they land in the TREE, so node_modules is reported repo-relative.
Fetch: []string{"npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"},
Env: []string{"npm_config_cache=${cache}", "npm_config_update_notifier=false"},
},
"python": {
Name: "python",
Start: []string{"pyright-langserver", "--stdio"},
Roots: []string{"pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"},
Exts: []string{".py", ".pyi"},
Cache: "uv",
// uv sync BUILDS any dependency published only as an sdist, which runs
// that dependency's setup.py / PEP-517 backend as us. --no-install-project
// spares us the TREE's own build, not its dependencies'. So this one
// executes, and therefore does not run: pyright reads .py/.pyi source and
// resolves the stdlib and any vendored packages without it.
Fetch: []string{"uv", "sync", "--frozen", "--no-install-project"},
Executes: true,
Env: []string{"UV_CACHE_DIR=${cache}", "UV_NO_PROGRESS=1"},
},
"cpp": {
Name: "cpp",
Start: []string{"clangd"},
Roots: []string{"compile_commands.json", "CMakeLists.txt"},
Exts: []string{".cpp", ".cc", ".cxx", ".c", ".h", ".hpp"},
// No fetch: C++ has no dependency resolver to run. clangd answers from
// compile_commands.json when the tree carries one, and degrades to
// single-file mode when it does not.
},
}
// Fetchable reports whether l's dependency fetch may run. See the scripts-off
// note at the top of this file: it is one predicate, in one place, read by the
// one caller that runs a fetch.
func Fetchable(l Lang) bool { return len(l.Fetch) > 0 && !l.Executes }
// Available reports whether this image can actually run l's server. It is what
// keeps the table from promising more than the deployment ships.
func (l Lang) Available() bool {
if len(l.Start) == 0 {
return false
}
_, err := exec.LookPath(l.Start[0])
return err == nil
}
// Speaks reports whether any file in the tree is one l answers for.
func (l Lang) Speaks(paths []string) bool {
for _, p := range paths {
if slices.Contains(l.Exts, strings.ToLower(path.Ext(p))) {
return true
}
}
return false
}
// Modules are the directories in the tree that carry one of l's markers — where
// a dependency fetch has to run. Slash-separated and tree-relative; "." is the
// tree root. A monorepo with several modules gets several fetches, and a tree
// with no marker gets none, which is the right answer for a language server
// asked about a loose file.
func (l Lang) Modules(paths []string) []string {
seen := map[string]bool{}
var out []string
for _, p := range paths {
if !slices.Contains(l.Roots, path.Base(p)) {
continue
}
dir := path.Dir(p)
if !seen[dir] {
seen[dir] = true
out = append(out, dir)
}
}
slices.Sort(out)
return out
}
// ID is the LSP languageId for one FILE. It is Name for every language but
// TypeScript, whose server distinguishes four dialects that share one toolchain —
// and gets the wrong answer for a React file told it is plain TypeScript.
func (l Lang) ID(p string) string {
if l.Name != "typescript" {
return l.Name
}
switch strings.ToLower(path.Ext(p)) {
case ".tsx":
return "typescriptreact"
case ".jsx":
return "javascriptreact"
case ".js":
return "javascript"
default:
return "typescript"
}
}
// langFor picks the language for a tree-relative path by extension.
//
// Extension, not markers, decides — the question is "which server answers about
// THIS file", and a polyglot repo (a Go service with a TypeScript console) has
// several right answers at once, one per file.
func langFor(p string) (Lang, bool) {
ext := strings.ToLower(path.Ext(p))
if ext == "" {
return Lang{}, false
}
// Deterministic: map iteration is randomized, and a file that resolved to a
// different server between two identical requests would answer differently
// for no reason the caller can see. Names are walked in sorted order.
for _, name := range names {
if l := table[name]; slices.Contains(l.Exts, ext) {
return l, true
}
}
return Lang{}, false
}
// names is table's keys in sorted order — the tie-break that makes langFor a
// function of its argument alone.
var names = func() []string {
out := make([]string, 0, len(table))
for name := range table {
out = append(out, name)
}
slices.Sort(out)
return out
}()
+284
View File
@@ -0,0 +1,284 @@
// Package lsp is the daemon: immutable roots, a live language server per root,
// and five questions you can ask one.
//
// # What this exists to answer that an index cannot
//
// A static symbol index knows what a repository says about itself. It does not
// know what `dep.Greet` resolves to, because the answer is in a module the
// repository only NAMES. Resolving that means fetching the dependency and
// running a real type-checker over both — which means running a third-party
// binary over untrusted bytes, which is why this is a separate daemon behind a
// jail and not a library inside the API.
//
// So the one answer that justifies the whole service is a Location with
// External true: a definition that left the repository and landed in the
// dependency cache.
//
// # Roots are immutable
//
// A root is (org, repo, rev) where rev is a resolved commit — 40 or 64 hex
// digits, never a branch name. That is the entire cache-invalidation design: a
// sha names one tree for all time, so a root can never be stale, a push is
// simply a different key, and there is no subsystem that decides when to throw
// anything away. The caller resolves refs; this daemon only knows revisions.
//
// # Positions are the protocol's
//
// line and character are 0-BASED, and character counts UTF-16 code units, per
// the LSP specification. That is deliberately not the 1-based line an editor
// shows a human: the callers here already speak LSP, and a service that
// silently re-based positions would corrupt every line containing a multi-byte
// character — an emoji before the cursor is one UTF-16 unit in the protocol's
// arithmetic and two in Go's. Positions pass through untouched.
//
// # Isolation, and who does it
//
// This daemon does not AUTHENTICATE anyone. It holds one shared key, and the
// caller behind it — the cloud API — is what turns a user into an org. What this
// daemon does is ISOLATE: org is the first segment of every root key and every
// root path, so two tenants naming the same repo at the same revision get two
// roots, two directories and two servers, and no warm server is ever handed
// across the boundary.
package lsp
import (
"encoding/json"
"path/filepath"
"strings"
)
// Position is the LSP's: 0-based line, 0-based UTF-16 character.
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
// Range is a half-open span between two positions.
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
// Location is one place a question resolved to.
//
// External is the field the service exists for. False means Path is
// repo-relative — a place in the caller's own tree. True means the answer left
// the repository, and Path is then the module coordinate it landed in
// ("golang.org/x/[email protected]/semver/semver.go"): the dependency cache's own
// layout, which names the module, its version and the file, and which leaks no
// path belonging to this daemon.
type Location struct {
Path string `json:"path"`
External bool `json:"external,omitempty"`
Range Range `json:"range"`
}
// Symbol is one entry in a file's outline.
type Symbol struct {
Name string `json:"name"`
Kind int `json:"kind"`
Detail string `json:"detail,omitempty"`
Range Range `json:"range"`
}
// Completion is one candidate at a position.
type Completion struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
}
// Diagnostic is one problem a server reported. Severity is the LSP's: 1 error,
// 2 warning, 3 information, 4 hint.
type Diagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity,omitempty"`
Code any `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
// ── placing an answer ────────────────────────────────────────────────────────
// place turns an absolute path a language server reported into the pair a caller
// can use: a name, and whether that name left the repository.
//
// roots and deps are each given in EVERY spelling they have. A server reports
// paths as the OS handed them to it, and a directory reached through a symlink —
// /var → /private/var on a Mac, a mounted volume in the cluster — gives one file
// two absolute names. Comparing one spelling against the other puts every answer
// "outside" the tree, and the caller then receives this daemon's own filesystem
// layout for files that were in their own repository all along.
//
// This is presentation. The security boundary is the chroot the server runs in
// and [clean], which proves a REQUESTED path is inside the tree.
func place(abs string, roots, deps []string) (string, bool) {
if abs == "" {
return "", false
}
if rel, ok := under(abs, roots); ok {
return rel, false
}
if rel, ok := under(abs, deps); ok {
return rel, true
}
// Outside both. Under the jail there is nowhere else to be, so this is the
// unjailed development path; report it honestly rather than pretend.
return abs, true
}
func under(abs string, dirs []string) (string, bool) {
for _, dir := range dirs {
if dir == "" {
continue
}
rel, err := filepath.Rel(dir, abs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}
return filepath.ToSlash(rel), true
}
return "", false
}
// spellings returns every absolute name dir has: the one given, and the one with
// symlinks resolved when they differ.
func spellings(dir string) []string {
out := []string{dir}
if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir {
out = append(out, resolved)
}
return out
}
// ── decoding what a server answered ──────────────────────────────────────────
// locations decodes the three shapes a location-returning request may answer
// with: a single Location, an array of them, or an array of LocationLink (the
// linkSupport form, whose target range lives under different keys). All three
// are in the specification and gopls, rust-analyzer and tsserver do not agree on
// which to send, so all three are read.
func locations(raw json.RawMessage, roots, deps []string) []Location {
var many []struct {
URI string `json:"uri"`
Range Range `json:"range"`
TargetURI string `json:"targetUri"`
Target Range `json:"targetSelectionRange"`
}
if json.Unmarshal(raw, &many) != nil {
var one struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
if json.Unmarshal(raw, &one) != nil || one.URI == "" {
return []Location{}
}
path, ext := place(uriPath(one.URI), roots, deps)
return []Location{{Path: path, External: ext, Range: one.Range}}
}
out := make([]Location, 0, len(many))
for _, m := range many {
uri, rng := m.URI, m.Range
if uri == "" { // a LocationLink
uri, rng = m.TargetURI, m.Target
}
p := uriPath(uri)
if p == "" {
continue // a jar: or zipfile: target names no path of ours
}
path, ext := place(p, roots, deps)
out = append(out, Location{Path: path, External: ext, Range: rng})
}
return out
}
// hover decodes MarkupContent, a MarkedString, or an array of either.
func hover(raw json.RawMessage) string {
var h struct {
Contents json.RawMessage `json:"contents"`
}
if json.Unmarshal(raw, &h) != nil || len(h.Contents) == 0 {
return ""
}
var markup struct {
Value string `json:"value"`
}
if json.Unmarshal(h.Contents, &markup) == nil && markup.Value != "" {
return markup.Value
}
var plain string
if json.Unmarshal(h.Contents, &plain) == nil {
return plain
}
var list []json.RawMessage
if json.Unmarshal(h.Contents, &list) != nil {
return ""
}
parts := make([]string, 0, len(list))
for _, item := range list {
if json.Unmarshal(item, &markup) == nil && markup.Value != "" {
parts = append(parts, markup.Value)
continue
}
if json.Unmarshal(item, &plain) == nil && plain != "" {
parts = append(parts, plain)
}
}
return strings.Join(parts, "\n\n")
}
// symbols decodes DocumentSymbol or SymbolInformation, which carry the range
// under different keys.
func symbols(raw json.RawMessage) []Symbol {
var list []struct {
Name string `json:"name"`
Kind int `json:"kind"`
Detail string `json:"detail"`
Range Range `json:"range"`
Location struct {
Range Range `json:"range"`
} `json:"location"`
}
if json.Unmarshal(raw, &list) != nil {
return []Symbol{}
}
out := make([]Symbol, 0, len(list))
for _, s := range list {
rng := s.Range
if rng == (Range{}) {
rng = s.Location.Range
}
out = append(out, Symbol{Name: s.Name, Kind: s.Kind, Detail: s.Detail, Range: rng})
}
return out
}
// completions decodes CompletionList or a bare CompletionItem array, and bounds
// the reply: a server offering every identifier in a large dependency tree can
// answer with tens of thousands of items, which is not an answer anybody reads.
func completions(raw json.RawMessage) []Completion {
const most = 200
type item struct {
Label string `json:"label"`
Kind int `json:"kind"`
Detail string `json:"detail"`
}
var list struct {
Items []item `json:"items"`
}
var items []item
if json.Unmarshal(raw, &list) == nil && list.Items != nil {
items = list.Items // CompletionList
} else if json.Unmarshal(raw, &items) != nil {
return []Completion{} // neither shape
}
if len(items) > most {
items = items[:most]
}
out := make([]Completion, 0, len(items))
for _, i := range items {
out = append(out, Completion{Label: i.Label, Kind: i.Kind, Detail: i.Detail})
}
return out
}
+173
View File
@@ -0,0 +1,173 @@
package lsp
// pool.go is the bounded set of live roots: LRU by last use, with an idle TTL.
//
// There is no invalidation here, and that is the point. A root is keyed by a
// resolved commit, so it names one tree for all time — nothing can make a warm
// root wrong. A push is a different key, and the only reason anything is ever
// discarded is that memory is finite.
import (
"context"
"sync"
"time"
)
// key names one root. org is FIRST and is the caller's tenant: two tenants
// naming the same repo at the same revision get two keys, two directories and
// two servers, so a warm server can never be handed across the boundary.
type key struct{ org, repo, rev string }
type pool struct {
mu sync.Mutex
live map[key]*Root
most int
ttl time.Duration
// now is a field so the eviction policy can be tested as the arithmetic it
// is, with no toolchain and no wall clock.
now func() time.Time
}
func newPool() *pool {
return &pool{live: map[key]*Root{}, most: warmMost, ttl: warmTTL, now: time.Now}
}
// hold returns the root for k if one is live, waiting for a build already in
// flight. It never starts one: a caller that gets false is told to send a tree.
//
// A held root is BUSY until the caller releases it, and a busy root is never
// evicted — so a query cannot have its servers killed and its tree removed while
// it is running. Every path out of here that does not return a root releases it.
func (p *pool) hold(ctx context.Context, k key) (*Root, bool, error) {
p.mu.Lock()
p.sweep()
r, ok := p.live[k]
if ok {
r.used = p.now()
r.busy.Add(1)
}
p.mu.Unlock()
if !ok {
return nil, false, nil
}
select {
case <-r.ready:
case <-ctx.Done():
r.release()
return nil, true, ctx.Err()
}
if r.err != nil {
r.release()
return nil, true, r.err
}
return r, true, nil
}
// claim reserves k for a build. mine reports that this caller must do it; a
// second caller for the same key gets mine=false and the pending root to wait on,
// so two requests never build competing trees into one directory.
func (p *pool) claim(k key) (r *Root, mine bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.sweep()
if r, ok := p.live[k]; ok {
r.used = p.now()
return r, false
}
r = &Root{key: k, used: p.now(), ready: make(chan struct{})}
p.live[k] = r
p.evict()
return r, true
}
// settle publishes a claimed build's outcome and releases everyone waiting. A
// failed build is removed, so the next request retries rather than inheriting a
// permanent error.
func (p *pool) settle(r *Root, built *Root, err error) {
if err != nil {
r.err = err
close(r.ready)
p.mu.Lock()
if p.live[r.key] == r {
delete(p.live, r.key)
}
p.mu.Unlock()
return
}
r.dir, r.dirs, r.langs = built.dir, built.dirs, built.langs
close(r.ready)
}
// holds reports whether any revision of one repo is live for one org. It is the
// whole admission rule for a warm request: pre-warming a NEW revision is free
// work, and free work is only offered where the caller has already paid for a
// cold start on the same repository.
func (p *pool) holds(org, repo string) bool {
p.mu.Lock()
defer p.mu.Unlock()
for k := range p.live {
if k.org == org && k.repo == repo {
return true
}
}
return false
}
// sweep drops roots idle past the TTL. The caller holds mu.
func (p *pool) sweep() {
cut := p.now().Add(-p.ttl)
for k, r := range p.live {
if r.settled() && r.busy.Load() == 0 && r.used.Before(cut) {
delete(p.live, k)
go r.close()
}
}
}
// evict enforces the ceiling by dropping least-recently-used roots. The caller
// holds mu.
func (p *pool) evict() {
for len(p.live) > p.most {
var oldest key
var found bool
for k, r := range p.live {
// Neither a root being built (nothing to close, somebody waiting)
// nor one being queried (killing it would fail that query) is a
// candidate. A burst can therefore briefly exceed the ceiling, which
// is the right trade against evicting what somebody is using.
if !r.settled() || r.busy.Load() > 0 {
continue
}
if !found || r.used.Before(p.live[oldest].used) {
oldest, found = k, true
}
}
if !found {
return
}
r := p.live[oldest]
delete(p.live, oldest)
go r.close()
}
}
func (p *pool) closeAll() {
p.mu.Lock()
live := p.live
p.live = map[key]*Root{}
p.mu.Unlock()
for _, r := range live {
if r.settled() {
r.close()
}
}
}
func (r *Root) settled() bool {
select {
case <-r.ready:
return true
default:
return false
}
}
+150
View File
@@ -0,0 +1,150 @@
package lsp
// pool_test.go tests the eviction policy as the arithmetic it is — no toolchain,
// no processes, no wall clock. `now` is a field for exactly this reason.
import (
"context"
"testing"
"time"
)
// warm inserts a finished root under k, last used at the given time.
func warm(p *pool, k key, used time.Time) *Root {
r := &Root{key: k, used: used, ready: make(chan struct{}), langs: map[string]*live{}}
close(r.ready)
p.live[k] = r
return r
}
func TestIdleRootsAreSwept(t *testing.T) {
clock := time.Unix(1_000_000, 0)
p := newPool()
p.now = func() time.Time { return clock }
fresh := key{"acme", "app", sha(1)}
stale := key{"acme", "app", sha(2)}
warm(p, fresh, clock.Add(-time.Minute))
warm(p, stale, clock.Add(-2*warmTTL))
p.mu.Lock()
p.sweep()
p.mu.Unlock()
if _, ok := p.live[stale]; ok {
t.Error("a root idle past the TTL was kept")
}
if _, ok := p.live[fresh]; !ok {
t.Error("a root used a minute ago was swept")
}
}
func TestEvictionTakesTheLeastRecentlyUsed(t *testing.T) {
clock := time.Unix(1_000_000, 0)
p := newPool()
p.now = func() time.Time { return clock }
// Fill to the ceiling, oldest first.
var oldest key
for i := 0; i < warmMost; i++ {
k := key{"acme", "app", sha(byte(i))}
if i == 0 {
oldest = k
}
warm(p, k, clock.Add(-time.Duration(warmMost-i)*time.Minute))
}
// One more claim pushes past the ceiling.
if _, mine := p.claim(key{"acme", "app", sha(90)}); !mine {
t.Fatal("a new key must be ours to build")
}
if _, ok := p.live[oldest]; ok {
t.Error("eviction did not take the least recently used root")
}
if len(p.live) > warmMost {
t.Errorf("live = %d, want at most %d", len(p.live), warmMost)
}
}
// A root still being built is never evicted: it has no server to close and a
// request is already waiting on it.
func TestPendingRootsSurviveEviction(t *testing.T) {
clock := time.Unix(1_000_000, 0)
p := newPool()
p.now = func() time.Time { return clock }
p.most = 1
pending, mine := p.claim(key{"acme", "app", sha(1)})
if !mine {
t.Fatal("first claim must be ours")
}
if _, mine := p.claim(key{"acme", "app", sha(2)}); !mine {
t.Fatal("second claim must be ours")
}
if _, ok := p.live[pending.key]; !ok {
t.Fatal("a root being built was evicted out from under its waiter")
}
}
// Two requests for the same key must not build competing trees into one
// directory: the second waits on the first.
func TestSecondClaimWaitsRatherThanBuilding(t *testing.T) {
p := newPool()
k := key{"acme", "app", sha(1)}
first, mine := p.claim(k)
if !mine {
t.Fatal("first claim must be ours")
}
second, mine := p.claim(k)
if mine {
t.Fatal("second claim must NOT build — it would race the first into the same directory")
}
if first != second {
t.Fatal("the second caller must wait on the SAME root")
}
}
// A failed build is removed, so the next request retries rather than inheriting a
// permanent error.
func TestFailedBuildIsNotCached(t *testing.T) {
p := newPool()
k := key{"acme", "app", sha(1)}
r, _ := p.claim(k)
p.settle(r, nil, context.DeadlineExceeded)
if _, ok := p.live[k]; ok {
t.Fatal("a failed build stayed in the pool — every later request would inherit it")
}
if _, mine := p.claim(k); !mine {
t.Fatal("the next caller must get to try again")
}
}
// holds is the warm door's whole admission rule, and it is scoped by ORG: another
// tenant's live root for the same repo name must not admit yours.
func TestHoldsIsScopedByOrg(t *testing.T) {
p := newPool()
warm(p, key{"acme", "app", sha(1)}, time.Now())
if !p.holds("acme", "app") {
t.Error("holds must see the org's own live root")
}
if p.holds("other", "app") {
t.Error("holds leaked across the tenant boundary")
}
if p.holds("acme", "other") {
t.Error("holds matched the wrong repository")
}
}
func TestHoldReportsAnAbsentRoot(t *testing.T) {
p := newPool()
_, held, err := p.hold(context.Background(), key{"acme", "app", sha(1)})
if err != nil {
t.Fatalf("hold: %v", err)
}
if held {
t.Fatal("hold reported a root nobody built")
}
}
+456
View File
@@ -0,0 +1,456 @@
package lsp
// root.go builds one root: materialize the tree the caller sent, run the FETCH
// phase, then start the language servers under the SERVE phase.
//
// The order is the security model. Fetch has a network and runs first-party
// toolchain code over a list of dependency names. Serve has no socket and parses
// what fetch downloaded. They are two processes, never one, and they are built by
// two functions in package jail that cannot be confused for each other.
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/hanzoai/lsp/internal/jail"
)
// Budgets. A root is a tree plus one process per language, so the bounds are on
// memory, file descriptors and wall clock, not on rows.
const (
fetchWait = 10 * time.Minute // one module directory's dependency fetch
warmMost = 4 // roots held live at once
warmTTL = 20 * time.Minute // idle before eviction
coldMost = 2 // roots being built at once
)
// The tree a caller may send. These are the bounds on ONE root; the pool bounds
// how many roots exist.
const (
fileMost = 20000
byteMost = 256 << 20 // 256 MiB across the whole tree
depthMost = 32
)
// jailPATH is the PATH inside the jail, for a toolchain's own subprocess lookups
// — `go` invoking the compiler, gopls invoking `go list`. Every argv[0] this
// daemon passes is already absolute; this is for what those programs then do.
const jailPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/go/bin"
// toolPATH is the PATH a jailed toolchain runs with. Inside the jail it is the
// IMAGE's layout, because only the bound paths exist there. Where there is no
// jail — a developer's machine — the process runs on the host and gets the
// host's PATH, since the image layout would name nothing. The platform decides,
// which is the same predicate that decides whether this daemon may serve at all.
func toolPATH() string {
if jail.Supported() {
return jailPATH
}
return os.Getenv("PATH")
}
// toolchainRead is the read-only bind list: the ALLOW-LIST of host paths a jailed
// process can see. The daemon's own rootfs is NOT chrooted into — only these are
// bound, so /etc/shadow, a mounted KMS secret and the service-account token are
// absent rather than defended against.
//
// Missing paths are skipped, so an image that lacks /lib64 still jails. The list
// is the superset across the toolchains langs.go can run.
var toolchainRead = []string{
"/usr/bin", // the compiler drivers, node, python
"/usr/lib", // shared libraries
"/usr/libexec",
"/usr/include",
"/usr/share",
"/usr/local", // /usr/local/go, /usr/local/bin/gopls
"/lib", // ld-linux and libc on merged-usr and split layouts
"/lib64",
"/bin",
"/sbin",
"/etc/alternatives",
"/etc/ssl", // the CA store — the fetch phase speaks TLS
"/etc/ld.so.cache", // a file bind
"/etc/nsswitch.conf",
"/etc/resolv.conf", // the fetch phase resolves the proxy's name
"/etc/hosts",
"/etc/passwd", // uid → name; hashes live in shadow, which is NOT bound
"/etc/group",
}
// Config is the daemon's deployment: where roots and dependency caches live, and
// which module proxy the fetch phase is allowed to talk to.
type Config struct {
// Roots is the directory materialized trees live under, one PVC path.
Roots string
// Deps is the directory dependency caches live under, shared across orgs.
// It holds only PUBLIC, checksum-verified modules: this daemon carries no
// credential, so no private dependency can enter it.
Deps string
// Stage is the writable directory each jail mounts its chroot base over.
Stage string
// Proxy is GOPROXY for the fetch phase. The Deployment's egress policy
// allows this host and nothing else.
Proxy string
// Sumdb is GOSUMDB. Verification is what makes a shared cache safe.
Sumdb string
}
// live is one language server attached to one root.
type live struct {
lang Lang
conn *Conn
// deps are every spelling of the cache an external answer is named against.
deps []string
}
// Root is one immutable (org, repo, rev) tree with its servers attached.
//
// ready is closed when it is usable. A Root is put in the pool BEFORE the slow
// work starts, so a second request for the same key waits for the first build
// rather than starting a competing one into the same directory.
type Root struct {
key key
dir string
dirs []string // every spelling of dir
langs map[string]*live
used time.Time
ready chan struct{}
err error
// busy counts the queries currently using this root. A root in use is never
// evicted, so a query can never have its servers killed and its tree removed
// out from under it. Every hold is paired with a release.
busy atomic.Int32
}
// release ends one query's use of a root.
func (r *Root) release() { r.busy.Add(-1) }
// Langs is the languages this root actually serves, sorted.
func (r *Root) Langs() []string {
out := make([]string, 0, len(r.langs))
for _, name := range names {
if r.langs[name] != nil {
out = append(out, name)
}
}
return out
}
func (r *Root) close() {
if r == nil {
return
}
for _, l := range r.langs {
l.conn.Close()
}
if r.dir != "" {
_ = os.RemoveAll(r.dir)
}
}
// build is the cold path, and it runs in the order the security model requires:
// materialize, fetch, serve.
func (s *Service) build(ctx context.Context, k key, files []File) (*Root, error) {
dir := filepath.Join(s.cfg.Roots, k.org, k.repo, k.rev)
// A root is immutable, so anything already at this path is the debris of a
// build that did not finish. Start from nothing.
if err := os.RemoveAll(dir); err != nil {
return nil, fmt.Errorf("clear root: %w", err)
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create root: %w", err)
}
paths, err := materialize(dir, files)
if err != nil {
_ = os.RemoveAll(dir)
return nil, err
}
r := &Root{key: k, dir: dir, dirs: spellings(dir), langs: map[string]*live{}}
for _, name := range names {
l := table[name]
if !l.Available() || !l.Speaks(paths) {
continue
}
// FETCH — network, first-party toolchain, one run per module directory.
// A failure is NOT fatal: a server still answers about the tree's own
// source with nothing resolved, and a dependency we cannot reach is a
// degraded answer rather than a dead root.
if Fetchable(l) {
for _, mod := range l.Modules(paths) {
if err := s.fetch(ctx, l, dir, mod); err != nil {
s.log.Warn("dependency fetch failed",
"org", k.org, "repo", k.repo, "lang", l.Name, "module", mod, "err", err)
}
}
}
// SERVE — no socket, everything read-only.
conn, err := s.serve(ctx, l, dir)
if err != nil {
s.log.Warn("language server did not start",
"org", k.org, "repo", k.repo, "lang", l.Name, "err", err)
continue
}
r.langs[l.Name] = &live{lang: l, conn: conn, deps: spellings(s.cache(l))}
}
return r, nil
}
// cache is where l's dependencies live on this deployment.
func (s *Service) cache(l Lang) string {
if l.Cache == "" {
return ""
}
return filepath.Join(s.cfg.Deps, l.Cache)
}
// fetch runs l's dependency fetch in one module directory, WITH a network and
// WITHOUT any dependency's own code — see langs.go on scripts-off.
func (s *Service) fetch(ctx context.Context, l Lang, dir, mod string) error {
argv, err := absolute(l.Fetch)
if err != nil {
return err
}
cache := s.cache(l)
if cache != "" {
if err := os.MkdirAll(cache, 0o755); err != nil {
return fmt.Errorf("dependency cache: %w", err)
}
}
ctx, cancel := context.WithTimeout(ctx, fetchWait)
defer cancel()
cmd := jail.Fetch(ctx, jail.Spec{
Root: s.cfg.Stage,
Work: dir,
Dir: filepath.Join(dir, filepath.FromSlash(mod)),
Read: toolchainRead,
Write: []string{cache},
Argv: argv,
Env: s.env(l, l.FetchEnv),
Limits: jail.Limits{
AddrMiB: 4096, CPU: 900, FsizeMiB: 2048,
Nofile: 1024, Nproc: 128, TmpMiB: 512,
},
})
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %w: %s", strings.Join(l.Fetch, " "), err, trim(string(out), 512))
}
return nil
}
// serve starts l's language server over the root, in a jail with no socket.
//
// The PROCESS is tied to context.Background(), not to ctx: a server outlives the
// request that warmed it, which is the entire point of the pool. ctx still bounds
// the HANDSHAKE, so a server that never finishes initializing does not hold a
// request open forever.
func (s *Service) serve(ctx context.Context, l Lang, dir string) (*Conn, error) {
argv, err := absolute(l.Start)
if err != nil {
return nil, err
}
read := append(append([]string{}, toolchainRead...), s.cache(l))
cmd := jail.Serve(context.Background(), jail.Spec{
Root: s.cfg.Stage,
Work: dir,
Read: read,
Argv: argv,
Env: s.env(l, l.ServeEnv),
Limits: jail.Limits{
// No CPU ceiling: RLIMIT_CPU is total seconds, and on a warm server
// that is a scheduled death. The bound on a runaway server is the
// pod's cgroup and the idle TTL.
AddrMiB: 6144, FsizeMiB: 256,
Nofile: 4096, Nproc: 256, TmpMiB: 2048,
},
})
return attach(ctx, cmd, l, dir)
}
// env is the environment a jailed toolchain process runs under. It is BUILT,
// never inherited: this daemon holds the service key, and a language server is a
// third-party binary parsing tenant source — the two must not meet.
func (s *Service) env(l Lang, phase []string) []string {
expand := func(v string) string {
return os.Expand(v, func(name string) string {
switch name {
case "cache":
return s.cache(l)
case "proxy":
return s.cfg.Proxy
case "sumdb":
return s.cfg.Sumdb
}
return ""
})
}
out := []string{
"PATH=" + toolPATH(),
"HOME=/tmp",
"TMPDIR=/tmp",
"LANG=C.UTF-8",
// A toolchain that shells out to git must not be able to prompt, to read
// an inherited credential helper, or to follow an insteadOf rewrite.
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
}
for _, v := range append(append([]string{}, l.Env...), phase...) {
out = append(out, expand(v))
}
return out
}
// absolute resolves argv[0] on PATH here, in the parent, where PATH means
// something. Inside the jail there is no shell and no lookup: execve is given a
// path or it fails.
func absolute(argv []string) ([]string, error) {
if len(argv) == 0 {
return nil, errors.New("empty argv")
}
bin, err := exec.LookPath(argv[0])
if err != nil {
return nil, fmt.Errorf("%s not installed: %w", argv[0], err)
}
out := append([]string{bin}, argv[1:]...)
return out, nil
}
// ── materializing the tree ───────────────────────────────────────────────────
// File is one file of the tree the caller sends. Content is the whole file: this
// daemon holds no git credential and makes no git request, so the caller — which
// already has the repository — is the only thing that can produce the tree. That
// is deliberate: a daemon that could fetch a repository would need a credential
// that could reach every repository.
type File struct {
Path string `json:"path"`
Content string `json:"content"`
}
// materialize writes the tree into dir and returns the paths it wrote.
//
// Every path is narrowed BEFORE anything is created, and every file is created
// with O_EXCL|O_NOFOLLOW into a directory this function made itself. No symlink
// is ever created, so no later step can be walked out of the tree through one;
// combined with the chroot, a path outside the root is not reachable rather than
// refused.
func materialize(dir string, files []File) ([]string, error) {
if len(files) == 0 {
return nil, errors.New("a tree needs at least one file")
}
if len(files) > fileMost {
return nil, fmt.Errorf("tree has %d files, at most %d", len(files), fileMost)
}
total := 0
paths := make([]string, 0, len(files))
for _, f := range files {
rel, err := narrow(f.Path)
if err != nil {
return nil, err
}
if total += len(f.Content); total > byteMost {
return nil, fmt.Errorf("tree exceeds %d bytes", byteMost)
}
abs := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil {
return nil, fmt.Errorf("%s: %w", rel, err)
}
fh, err := os.OpenFile(abs, os.O_CREATE|os.O_EXCL|os.O_WRONLY|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return nil, fmt.Errorf("%s: %w", rel, err)
}
_, werr := fh.WriteString(f.Content)
if cerr := fh.Close(); werr == nil {
werr = cerr
}
if werr != nil {
return nil, fmt.Errorf("%s: %w", rel, werr)
}
paths = append(paths, rel)
}
return paths, nil
}
// narrow reduces a caller's path to a slash-separated tree-relative one, or
// refuses it. It is lexical and total: absolute paths, parent traversal, NUL,
// and absurd depth are all named here rather than discovered later.
func narrow(p string) (string, error) {
if p == "" {
return "", errors.New("empty path")
}
if strings.ContainsRune(p, 0) {
return "", errors.New("path contains NUL")
}
if strings.HasPrefix(p, "/") || filepath.IsAbs(p) {
return "", fmt.Errorf("%s: path must be tree-relative", p)
}
// filepath.Clean on a slash path: the tree's wire format is slashes, and the
// daemon runs on Linux, so this is one conversion and not two spellings.
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(p)))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%s: path escapes the tree", p)
}
if strings.Count(clean, "/") > depthMost {
return "", fmt.Errorf("%s: path is deeper than %d", p, depthMost)
}
return clean, nil
}
// clean narrows a tree-relative path to a location PROVEN to be inside dir, and
// returns it under DIR'S OWN SPELLING.
//
// Two different jobs, and they need two different paths. The PROOF is done on the
// resolved path, and that is the part that matters: refusing ".." is not
// sufficient, because a tree is tenant content that a dependency fetch writes
// into, so a link could exist that names /etc, a KMS mount, or another org's
// directory one level up. Only resolving and then proving containment refuses
// that — lexical checks first, since they refuse the cheap attacks without a
// syscall, then EvalSymlinks, then containment.
//
// What is RETURNED is the unresolved join, because that path is about to become a
// file: URI sent to a language server that was rooted at dir. A server told its
// root is /var/x and then handed a document at /private/var/x does not consider
// the document to be in its workspace, and answers null to every question about
// it — which is not an error anywhere, just a service that never works. The
// spelling has to match the root's, so it is the root's.
func clean(dir, p string) (string, error) {
rel, err := narrow(p)
if err != nil {
return "", err
}
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return "", fmt.Errorf("resolve root: %w", err)
}
abs, err := filepath.EvalSymlinks(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return "", errors.New("no such file in the tree")
}
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", errors.New("path escapes the tree")
}
if info, err := os.Stat(abs); err != nil || info.IsDir() {
return "", errors.New("no such file in the tree")
}
return filepath.Join(dir, filepath.FromSlash(rel)), nil
}
func trim(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n] + "…"
}
return s
}
+217
View File
@@ -0,0 +1,217 @@
package lsp
// root_test.go pins the tree: what a caller may name, what lands on disk, and
// what the language table concludes about it.
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestNarrowRefusesEverythingButATreePath(t *testing.T) {
bad := []string{
"", "..", "../x", "a/../../x", "/etc/passwd", "/", "\x00", "a\x00b",
strings.Repeat("a/", depthMost+2) + "x",
}
for _, p := range bad {
if got, err := narrow(p); err == nil {
t.Errorf("narrow(%q) = %q, want an error", p, got)
}
}
good := map[string]string{
"main.go": "main.go",
"./main.go": "main.go",
"a/b/c.go": "a/b/c.go",
"a//b.go": "a/b.go",
"a/./b.go": "a/b.go",
"a/b/../c.go": "a/c.go",
".hidden/config.yml": ".hidden/config.yml",
}
for in, want := range good {
got, err := narrow(in)
if err != nil {
t.Errorf("narrow(%q): %v", in, err)
continue
}
if got != want {
t.Errorf("narrow(%q) = %q, want %q", in, got, want)
}
}
}
func TestMaterializeWritesTheTree(t *testing.T) {
dir := t.TempDir()
paths, err := materialize(dir, []File{
{Path: "go.mod", Content: "module x\n"},
{Path: "pkg/deep/a.go", Content: "package deep\n"},
})
if err != nil {
t.Fatalf("materialize: %v", err)
}
if !slices.Equal(paths, []string{"go.mod", "pkg/deep/a.go"}) {
t.Errorf("paths = %v", paths)
}
body, err := os.ReadFile(filepath.Join(dir, "pkg", "deep", "a.go"))
if err != nil || string(body) != "package deep\n" {
t.Errorf("nested file = %q %v", body, err)
}
}
// The same path twice is a caller that cannot be trusted about the rest of the
// tree either. O_EXCL turns it into an error rather than a silent last-wins.
func TestMaterializeRefusesADuplicatePath(t *testing.T) {
dir := t.TempDir()
_, err := materialize(dir, []File{
{Path: "a.go", Content: "first"},
{Path: "a.go", Content: "second"},
})
if err == nil {
t.Fatal("materialize accepted the same path twice")
}
}
// A tree cannot smuggle a write past its own root through a path it also names
// as a directory.
func TestMaterializeRefusesAFileUsedAsADirectory(t *testing.T) {
dir := t.TempDir()
_, err := materialize(dir, []File{
{Path: "a", Content: "file"},
{Path: "a/b", Content: "under a file"},
})
if err == nil {
t.Fatal("materialize accepted a path whose parent is a file")
}
}
// clean proves containment on the RESOLVED path, because a tree is tenant
// content and a dependency fetch writes into it: a link planted there must not
// become a read of anything outside.
func TestCleanRefusesASymlinkOutOfTheTree(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(secret, []byte("KEY"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "real.go"), []byte("package x"), 0o600); err != nil {
t.Fatalf("write real: %v", err)
}
if err := os.Symlink(secret, filepath.Join(dir, "escape.go")); err != nil {
t.Fatalf("symlink: %v", err)
}
if _, err := clean(dir, "real.go"); err != nil {
t.Errorf("clean refused a genuine file: %v", err)
}
if got, err := clean(dir, "escape.go"); err == nil {
t.Fatalf("clean FOLLOWED a symlink out of the tree: %q", got)
}
if _, err := clean(dir, "missing.go"); err == nil {
t.Error("clean accepted a file that is not there")
}
// A directory is not a document.
if err := os.Mkdir(filepath.Join(dir, "pkg"), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := clean(dir, "pkg"); err == nil {
t.Error("clean accepted a directory")
}
}
func TestLangForPicksByExtension(t *testing.T) {
cases := map[string]string{
"main.go": "go",
"a/b/main.go": "go",
"lib.rs": "rust",
"index.tsx": "typescript",
"setup.py": "python",
"util.hpp": "cpp",
"README.md": "",
"Makefile": "",
"main.GO": "go", // extensions are matched case-insensitively
"noextension/": "",
}
for path, want := range cases {
l, ok := langFor(path)
if want == "" {
if ok {
t.Errorf("langFor(%q) = %q, want none", path, l.Name)
}
continue
}
if !ok || l.Name != want {
t.Errorf("langFor(%q) = %q %v, want %q", path, l.Name, ok, want)
}
}
}
func TestModulesAreWhereTheFetchRuns(t *testing.T) {
paths := []string{
"go.mod", "main.go",
"service/go.mod", "service/main.go",
"web/package.json", "web/index.ts",
"docs/readme.md",
}
got := table["go"].Modules(paths)
if !slices.Equal(got, []string{".", "service"}) {
t.Errorf("go modules = %v, want [. service]", got)
}
if got := table["typescript"].Modules(paths); !slices.Equal(got, []string{"web"}) {
t.Errorf("typescript modules = %v, want [web]", got)
}
if got := table["rust"].Modules(paths); len(got) != 0 {
t.Errorf("rust modules = %v, want none", got)
}
}
func TestSpeaksIsAboutFilesNotMarkers(t *testing.T) {
// A repository with Go source but no go.mod is still a Go repository to a
// language server; it just has nothing to fetch.
paths := []string{"scratch/main.go"}
if !table["go"].Speaks(paths) {
t.Error("go must speak for a tree of .go files with no go.mod")
}
if len(table["go"].Modules(paths)) != 0 {
t.Error("with no go.mod there is nothing to fetch")
}
if table["rust"].Speaks(paths) {
t.Error("rust claimed a tree with no .rs file")
}
}
// The four dialects TypeScript's server distinguishes are a property of the FILE.
func TestTypeScriptDialects(t *testing.T) {
ts := table["typescript"]
cases := map[string]string{
"a.ts": "typescript", "a.tsx": "typescriptreact",
"a.js": "javascript", "a.jsx": "javascriptreact",
}
for path, want := range cases {
if got := ts.ID(path); got != want {
t.Errorf("ID(%q) = %q, want %q", path, got, want)
}
}
if got := table["go"].ID("a.go"); got != "go" {
t.Errorf("go ID = %q", got)
}
}
// The scripts-off policy is one predicate, and this is what it currently says.
func TestFetchableRefusesEveryFetchThatRunsDependencyCode(t *testing.T) {
for name, l := range table {
if l.Executes && Fetchable(l) {
t.Errorf("%s: a fetch that runs dependency code is fetchable", name)
}
if len(l.Fetch) == 0 && Fetchable(l) {
t.Errorf("%s: a language with no fetch is fetchable", name)
}
}
if !Fetchable(table["go"]) {
t.Error("go's fetch downloads and verifies without running anything; it must be fetchable")
}
if Fetchable(table["python"]) {
t.Error("uv sync builds sdists, which is dependency code; it must not be fetchable")
}
}
+128
View File
@@ -0,0 +1,128 @@
package lsp
// service_test.go is the shared rig: a real Service behind a real HTTP server,
// with real directories. Nothing here is a mock — the tests that use it drive the
// same handlers a caller does.
import (
"bytes"
"context"
"encoding/json"
"io"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
const testKey = "test-service-key"
// rig is a running daemon and the directories it was given.
type rig struct {
http *httptest.Server
cfg Config
svc *Service
ready bool // the jail proved out (or this platform has none)
}
// launch starts a daemon whose fetch phase resolves modules from proxy — a
// directory in the module-proxy layout, so a test exercises the REAL `go mod
// download` with no network at all.
func launch(t *testing.T, proxy string) *rig {
t.Helper()
base := t.TempDir()
cfg := Config{
Roots: filepath.Join(base, "roots"),
Deps: filepath.Join(base, "deps"),
Stage: filepath.Join(base, "stage"),
Proxy: proxy,
// off, because the modules a test publishes are not in anybody's
// transparency log. Production sets a real one; see Config.
Sumdb: "off",
}
svc := New(cfg, testKey, slog.New(slog.NewTextHandler(io.Discard, nil)))
t.Cleanup(svc.Close)
// The Go module cache is deliberately read-only — that is the toolchain
// protecting verified content, and production wants it. It also means the
// test framework cannot remove its own temp directory, so unlock it first.
// Cleanups run last-registered-first, and t.TempDir registered before this.
t.Cleanup(func() { unlock(cfg.Deps) })
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
svc.Prove(ctx)
cancel()
srv := httptest.NewServer(svc.Routes())
t.Cleanup(srv.Close)
return &rig{http: srv, cfg: cfg, svc: svc, ready: svc.ready.Load()}
}
// post sends a JSON body to path with the service key and returns the status and
// the decoded body.
func (r *rig) post(t *testing.T, path string, body any) (int, map[string]any) {
t.Helper()
return r.postKey(t, path, body, testKey)
}
func (r *rig) postKey(t *testing.T, path string, body any, key string) (int, map[string]any) {
t.Helper()
buf, err := json.Marshal(body)
if err != nil {
t.Fatalf("encode: %v", err)
}
req, err := http.NewRequest(http.MethodPost, r.http.URL+path, bytes.NewReader(buf))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("X-API-Key", key)
}
resp, err := r.http.Client().Do(req)
if err != nil {
t.Fatalf("post %s: %v", path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// needJail skips when the daemon refused to serve because the jail did not
// initialize here. That is the fail-closed path working, not a test failure: a
// plain CI container cannot create a user namespace, and the gVisor target can.
func (r *rig) needJail(t *testing.T) {
t.Helper()
if !r.ready {
t.Skipf("daemon refused to serve — the jail did not initialize here (%s)", r.svc.status())
}
}
// sha is a synthetic resolved commit. The daemon requires one because a root is
// keyed by an immutable revision; what the digits mean is the caller's business.
func sha(seed byte) string {
out := make([]byte, 40)
for i := range out {
out[i] = "0123456789abcdef"[(int(seed)+i)%16]
}
return string(out)
}
// unlock makes a tree removable. See launch.
func unlock(dir string) {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
_ = os.Chmod(path, 0o755)
} else {
_ = os.Chmod(path, 0o644)
}
return nil
})
}
+105
View File
@@ -0,0 +1,105 @@
// Command lsp is the jailed language-server daemon.
//
// It holds immutable per-(org, repo, commit) working trees on a volume, runs a
// language server over each one inside a jail with no network, and answers
// position questions about them: hover, locate, symbols, diagnostics, complete.
//
// The answer it exists for is a definition that leaves the repository and lands
// in a dependency — which requires fetching that dependency and type-checking
// both, which requires running a third-party binary over untrusted bytes, which
// is why this is a separate daemon behind a jail. See internal/jail for the two
// phases and the invariant between them, and LLM.md for the wire contract.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/hanzoai/lsp/internal/jail"
"github.com/hanzoai/lsp/internal/lsp"
)
func main() {
// FIRST. If this invocation is a jail child — a re-exec of ourselves inside
// new namespaces — it builds its jail and execs the language server, and
// never returns. In the daemon process this does nothing. It must precede
// every other line so the child stays minimal.
jail.Child()
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg := lsp.Config{
Roots: env("LSP_ROOTS", "/var/lib/lsp/roots"),
Deps: env("LSP_DEPS", "/var/lib/lsp/deps"),
Stage: env("LSP_STAGE", "/tmp/jail"),
Proxy: env("LSP_PROXY", "https://proxy.golang.org,direct"),
Sumdb: env("LSP_SUMDB", "sum.golang.org"),
}
for _, dir := range []string{cfg.Roots, cfg.Deps, cfg.Stage} {
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Error("prepare directory", "dir", dir, "err", err)
os.Exit(1)
}
}
svc := lsp.New(cfg, os.Getenv("LSP_KEY"), log)
defer svc.Close()
// Prove the jail on THIS host before accepting anything. A pod where it does
// not initialize still boots — the probes must answer — but every request
// that would run a language server is refused. This daemon never parses
// tenant bytes outside a jail it has watched work.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
svc.Prove(ctx)
cancel()
listen := env("LSP_LISTEN", ":8000")
srv := &http.Server{
Addr: listen,
Handler: svc.Routes(),
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: a cold root is a dependency fetch and a first index,
// legitimately minutes. The bounds on that work are in the daemon —
// per-fetch deadlines, a cold-start semaphore and the jail's rlimits —
// not in a socket timeout that would cut an honest request in half.
}
stop, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer done()
errs := make(chan error, 1)
go func() {
log.Info("listening", "addr", listen, "roots", cfg.Roots, "deps", cfg.Deps)
errs <- srv.ListenAndServe()
}()
select {
case err := <-errs:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("serve", "err", err)
os.Exit(1)
}
case <-stop.Done():
log.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error("shutdown", "err", err)
os.Exit(1)
}
}
}
func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}