diff --git a/.github/ISSUE_TEMPLATE/submit-plugin.yml b/.github/ISSUE_TEMPLATE/submit-plugin.yml index 5b026ae..0e1f88d 100644 --- a/.github/ISSUE_TEMPLATE/submit-plugin.yml +++ b/.github/ISSUE_TEMPLATE/submit-plugin.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Your repository must follow the [contract](https://github.com/goblogplatform/plugins/blob/main/docs/CONTRACT.md): `goblog-plugin.json`, `README.md`, the plugin `.go` file, and a release tagged `vX.Y.Z`. A workflow validates it and, if it passes, opens the pull request for you. + Your repository must follow the [contract](https://github.com/goblogplatform/plugins/blob/main/docs/CONTRACT.md): `goblog-plugin.json` with `"runtime": "wasm"`, `README.md`, and a release tagged `vX.Y.Z` with `plugin.wasm` attached. A workflow validates it and, if it passes, opens the pull request for you. - type: input id: repo attributes: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dea825f..53a91a5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: run: | go build -o "$RUNNER_TEMP/registry" ./cmd/registry set +e - "$RUNNER_TEMP/registry" build --out dist --image compscidr/goblog:v0.2.7 + "$RUNNER_TEMP/registry" build --out dist --image compscidr/goblog:v0.2.9 code=$? set -e if [ "$code" = "2" ]; then echo "skipped=true" >> "$GITHUB_OUTPUT"; exit 0; fi diff --git a/.github/workflows/submit.yml b/.github/workflows/submit.yml index 1cc7b48..d77cd33 100644 --- a/.github/workflows/submit.yml +++ b/.github/workflows/submit.yml @@ -55,7 +55,7 @@ jobs: printf ' - repo: %s\n' "$REPO" >> registry.yaml go build -o "$RUNNER_TEMP/registry" ./cmd/registry set +e - "$RUNNER_TEMP/registry" build --out "$RUNNER_TEMP/dist" --image compscidr/goblog:v0.2.7 > result.txt 2>&1 + "$RUNNER_TEMP/registry" build --out "$RUNNER_TEMP/dist" --image compscidr/goblog:v0.2.9 > result.txt 2>&1 set -e cat result.txt if grep -Fxq "$REPO: built" result.txt; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 332ca30..efd9dab 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,4 +17,4 @@ jobs: - name: Validate every registry entry env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: go run ./cmd/registry validate --image compscidr/goblog:v0.2.7 + run: go run ./cmd/registry validate --image compscidr/goblog:v0.2.9 diff --git a/README.md b/README.md index 6303d16..c93c858 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins). - `registry.yaml` — the list. Submit your repository via the [issue form](.github/ISSUE_TEMPLATE/submit-plugin.yml) (a pull request by hand is the alternative); see [docs/CONTRACT.md](docs/CONTRACT.md). -- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`) and `stars` (GitHub stargazers, the directory's default ordering); `plugins/.json` adds the rendered README, changelog and release history. +- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index: the latest release of each plugin, with `download_url` (the release asset named by the manifest's `entry`, `plugin.wasm` by default) and its `sha256`, `runtime` and `install_type` (both `wasm`), `allowed_hosts` (the network the module may reach; `[]` for none) and `stars` (GitHub stargazers, the directory's default ordering); `plugins/.json` adds the rendered README, changelog and release history. - `cmd/registry` — the tool CI runs: `validate` on pull requests, `build` on merge and every six hours. ```bash @@ -12,7 +12,7 @@ go run ./cmd/registry validate --repo you/plugin # one entry go run ./cmd/registry build --out dist # what gets published ``` -Set `GITHUB_TOKEN` to avoid API rate limits. `validate`/`build` run `goblog validate-plugin` in the `compscidr/goblog` Docker image (`--image` to override; Renovate keeps the default current). With snap-installed Docker, set `TMPDIR` to a directory under your home; snap's Docker cannot bind-mount `/tmp`. Resource limits (`--memory`, `--pids-limit`) need cgroup controllers; on rootless Docker they may be downgraded or rejected — pass `--image` to a local build or run on a rootful daemon. +Set `GITHUB_TOKEN` to avoid API rate limits. `validate`/`build` download each plugin's module — the release asset named by `entry` in its manifest, `plugin.wasm` by default — and run `goblog validate-plugin` on it in the `compscidr/goblog` Docker image (`--image` to override; Renovate keeps the default current). With snap-installed Docker, set `TMPDIR` to a directory under your home; snap's Docker cannot bind-mount `/tmp`. Resource limits (`--memory`, `--pids-limit`) need cgroup controllers; on rootless Docker they may be downgraded or rejected — pass `--image` to a local build or run on a rootful daemon. ## Submissions diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 751fac6..6b7894f 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -21,7 +21,7 @@ import ( ) const ( - defaultImage = "compscidr/goblog:v0.2.7" + defaultImage = "compscidr/goblog:v0.2.9" defaultBaseURL = "https://goblogplatform.github.io/plugins" ) diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index 62af1b3..ab24a7c 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -30,6 +30,12 @@ func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]by } return nil, registry.ErrNotFound } +func (m *memSource) ReleaseAsset(_ context.Context, _, _ string, id int64) ([]byte, error) { + if id == 11 { + return []byte("\x00asm hello"), nil + } + return nil, errors.New("no such asset") +} func (m *memSource) RenderMarkdown(_ context.Context, _, md string) (string, error) { return "

" + md + "

", nil } @@ -38,7 +44,7 @@ func (m *memSource) RepoStars(context.Context, string, string) (int, error) { re type okValidator struct{} func (okValidator) Validate(_ context.Context, _ []byte) (registry.Info, error) { - return registry.Info{Name: "hello", DisplayName: "Hello", Version: "1.0.0"}, nil + return registry.Info{Name: "hello", DisplayName: "Hello", Version: "1.0.0", Runtime: "wasm"}, nil } func fixture(t *testing.T) (string, *memSource) { @@ -48,12 +54,12 @@ func fixture(t *testing.T) (string, *memSource) { os.WriteFile(reg, []byte("plugins:\n - repo: o/hello\n - repo: o/broken\n"), 0644) src := &memSource{ releases: map[string][]registry.Release{ - "o/hello": {{Tag: "v1.0.0", Body: "First", URL: "u", PublishedAt: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)}}, + "o/hello": {{Tag: "v1.0.0", Body: "First", URL: "u", PublishedAt: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC), + Assets: []registry.Asset{{ID: 11, Name: "plugin.wasm", Size: 11, DownloadURL: "https://github.com/o/hello/releases/download/v1.0.0/plugin.wasm"}}}}, "o/broken": {}, }, files: map[string]string{ - "o/hello@v1.0.0:goblog-plugin.json": `{"name":"hello","display_name":"Hello","description":"d","author":"a","license":"MIT","min_goblog_version":"0.2.6"}`, - "o/hello@v1.0.0:plugin.go": "package main\n", + "o/hello@v1.0.0:goblog-plugin.json": `{"name":"hello","display_name":"Hello","description":"d","author":"a","license":"MIT","runtime":"wasm","min_goblog_version":"0.2.6"}`, "o/hello@v1.0.0:README.md": "# Hello", }, } diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 4c4fc66..131c3d6 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,10 +1,12 @@ # Publishing a goblog plugin -The directory at [goblog.live/plugins](https://goblog.live/plugins) lists plugins from this registry. A plugin is a GitHub repository; each GitHub release is a version. Submitting means adding your repository to `registry.yaml` in a pull request — CI validates it and, once merged, the index is rebuilt (on every merge and every six hours). +The directory at [goblog.live/plugins](https://goblog.live/plugins) lists plugins from this registry. A plugin is a GitHub repository whose releases each carry a compiled WebAssembly module; each GitHub release is a version. Submitting means adding your repository to `registry.yaml` in a pull request — CI validates it and, once merged, the index is rebuilt (on every merge and every six hours). + +**Only WebAssembly plugins are accepted.** Yaegi-interpreted `.go` plugins (goblog's [dynamic plugins](https://github.com/goblogplatform/goblog#dynamic-plugins)) still work when an operator drops the file in by hand, but the directory does not list or install them: a `.wasm` module can bundle any dependency its author likes, runs with no filesystem and no network beyond the hosts it declares, and needs no goblog rebuild. ## What the directory publishes -The index entry for your plugin is built from the manifest, the latest release, and your repository's GitHub star count (`stars`), which the directory uses for its default ordering. Stars are best-effort: if GitHub cannot be reached for them, the entry is published with `0`. +The index entry for your plugin is built from the manifest, the latest release and its module asset (the one named by `entry`, `plugin.wasm` by default) (`download_url` is the asset's browser URL; `sha256` is of the asset), and your repository's GitHub star count (`stars`), which the directory uses for its default ordering. Stars are best-effort: if GitHub cannot be reached for them, the entry is published with `0`. The entry also carries `runtime: "wasm"`, `install_type: "wasm"` and the manifest's `allowed_hosts`, which goblog's admin page shows as "Talks to: …" before an operator installs. ## What the repository must contain @@ -13,10 +15,12 @@ At the root of the repository, at the release tag being published (the tool chec | File | Required | Notes | |---|---|---| | `goblog-plugin.json` | yes | the manifest, below | -| the entry file (default `plugin.go`) | yes | a [dynamic plugin](https://github.com/goblogplatform/goblog#dynamic-plugins): `package main`, `func NewPlugin() plugin.Plugin` | +| the plugin's source | yes | anything that builds the module — Go with [`github.com/extism/go-pdk`](https://github.com/extism/go-pdk), TinyGo, Rust, or any language with an [Extism PDK](https://extism.org/docs/concepts/pdk) | | `README.md` | yes | shown on the plugin's directory page | | `CHANGELOG.md` | no | shown when present | -| `LICENSE` | recommended | should match `license` in the manifest | +| `LICENSE` | recommended | not checked by the validator; state the same license as `license` in the manifest | + +And attached to every release: the compiled module, named as `entry` in the manifest (default `plugin.wasm`). The module is a release **asset**, not a file in the repository. ### `goblog-plugin.json` @@ -27,35 +31,80 @@ At the root of the repository, at the release tag being published (the tool chec "description": "One sentence shown in the listing.", "author": "Your Name", "license": "Apache-2.0", - "entry": "plugin.go", - "min_goblog_version": "0.2.6", + "runtime": "wasm", + "entry": "plugin.wasm", + "allowed_hosts": ["api.example.com"], + "min_goblog_version": "0.2.9", "homepage": "https://example.com/optional" } ``` -- `name`: `^[a-z0-9-]+$`, unique across the registry, and equal to what your plugin's `Name()` returns. -- `display_name`: the label shown in the directory. It does not have to equal your plugin's `DisplayName()`, which labels its settings group in the admin UI. +- `name`: `^[a-z0-9-]+$`, unique across the registry, and equal to the `name` your plugin's `identity` export returns. It keys the plugin's settings and its persistent store, so keep it stable across versions. +- `display_name`: the label shown in the directory. It does not have to equal your plugin's `display_name`, which labels its settings group in the admin UI. - `license`: an SPDX identifier from the list in `internal/registry/manifest.go` (MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, GPL/LGPL/AGPL `-only`/`-or-later`, Unlicense, 0BSD). Open an issue to add another. -- `entry`: a `.go` file at the repository root; defaults to `plugin.go`. -- `min_goblog_version`: plain semver (`0.2.6`, no `v`) — the oldest goblog your plugin works with. +- `runtime`: must be `"wasm"`. Anything else is rejected. +- `entry`: the name of the `.wasm` asset attached to each release (letters, digits, `_`, `.`, `-`; no path); defaults to `plugin.wasm`. +- `allowed_hosts`: the hosts the module may reach over HTTP — exact hostnames (`api.example.com`), IPs, or globs (`*.example.com`), each optionally with a port; never a scheme or a path. A glob must still name a domain — `*` alone (or `**`, `*.*`) is rejected. Omit it, or leave it empty, and the plugin gets no network at all. goblog checks every request (and every redirect hop) against this list, and the directory shows it to operators as "Talks to" before they install, so declare only what you use. +- `min_goblog_version`: plain semver (`0.2.9`, no `v`) — the oldest goblog your plugin works with. WebAssembly plugins need at least `0.2.9`. + +### The module + +A plugin is one `.wasm` file built for [Extism](https://extism.org/): every export takes and returns JSON through Extism's input/output. Only `identity` is mandatory; the others (`settings`, `pages`, `jobs`, `template_head`, `template_footer`, `template_data`, `render_page`, `run_job`, `on_init`) are optional and mirror goblog's compiled-in plugin interface. Host functions give you a per-plugin key/value store (`store_get`/`store_set`/`store_delete`/`store_list`), logging through the PDK's logger, and Extism's `http_request` limited to `allowed_hosts`. Calls are capped at 10 s (120 s for jobs and `on_init`) and 64 MB of memory. The full contract — every export's input and output shape, `ctx`, the host functions and the limits — is in goblog's README under [WebAssembly plugins](https://github.com/goblogplatform/goblog#webassembly-plugins), and [`plugin/wasm/testdata/echo/main.go`](https://github.com/goblogplatform/goblog/blob/main/plugin/wasm/testdata/echo/main.go) implements all of it. [goblog-plugin-hello](https://github.com/goblogplatform/goblog-plugin-hello) is the smallest complete example and is meant to be copied. + +With the standard Go toolchain (1.24 or newer): + +```bash +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . +``` + +The module must be 16 MiB or smaller; `-ldflags="-s -w"` keeps a Go build well under that. ### Releases - Tag releases `vX.Y.Z` (exactly three numbers). Drafts and pre-releases are ignored. -- The tag without `v` must equal the string your plugin's `Version()` returns. +- The tag without `v` must equal the `version` your plugin's `identity` export returns. +- **Every release must have the module attached** as the asset named by `entry`. The registry validates and publishes the asset, never a file from the repository, so a release without it fails validation with `has no asset named plugin.wasm`. - The GitHub release body is shown as the version's release notes. - The directory lists the **latest** published release; the detail page shows all of them. +Copy this workflow into `.github/workflows/release.yml` and the asset is built and uploaded whenever you publish a release: + +```yaml +name: Release +on: + release: + types: [published] +permissions: + contents: write +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Build plugin.wasm + run: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . + - name: Upload to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" plugin.wasm --clobber +``` + +The registry reads the asset when it validates; if the workflow is still uploading when it looks, re-run validation once the asset is up (edit your submission issue, or re-run the pull request's checks). + ## Check before you submit ```bash +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -ldflags="-s -w" -o plugin.wasm . docker run --rm --network none -v "$PWD:/p:ro" \ - --entrypoint /go/src/github.com/compscidr/goblog/goblog compscidr/goblog:v0.2.7 \ - validate-plugin /p/plugin.go -# {"name":"hello","display_name":"Hello","version":"1.0.0"} + --entrypoint /go/src/github.com/compscidr/goblog/goblog compscidr/goblog:v0.2.9 \ + validate-plugin /p/plugin.wasm +# {"name":"hello","display_name":"Hello","version":"1.0.0","runtime":"wasm"} ``` -The registry's CI runs this (plus a timeout and memory/process limits), then compares `name` and `version` with your manifest and tag. Your file is executed by the Go interpreter during the check, which is why it runs with networking off. +The registry's CI runs this (plus a timeout and memory/process limits) against the asset on your latest release, then requires `"runtime":"wasm"` and compares `name` and `version` with your manifest and tag. `validate-plugin` loads the module with no store and no network and calls `identity`, `settings`, `pages` and `jobs`, so those exports must not depend on either. ## Submit @@ -70,4 +119,4 @@ plugins: - repo: you/goblog-plugin-yours ``` -Plugins run inside the goblog process of whoever installs them. Keep them small and readable; the registry is curated and maintainers may decline or remove entries. +Plugins run inside the goblog process of whoever installs them, sandboxed but trusted with the hosts they declare and the settings they are given. Keep them small and readable; the registry is curated and maintainers may decline or remove entries. diff --git a/internal/registry/build.go b/internal/registry/build.go index 9c6412a..e2ceee4 100644 --- a/internal/registry/build.go +++ b/internal/registry/build.go @@ -17,20 +17,22 @@ import ( // Field names are the contract consumed by goblog's directory plugin and // the admin installer; do not rename them. type IndexEntry struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Version string `json:"version"` - Author string `json:"author"` - License string `json:"license"` - SourceURL string `json:"source_url"` - DownloadURL string `json:"download_url"` - SHA256 string `json:"sha256"` - MinGoblogVersion string `json:"min_goblog_version"` - InstallType string `json:"install_type"` - ReleasedAt string `json:"released_at"` - DetailURL string `json:"detail_url"` - Stars int `json:"stars"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Version string `json:"version"` + Author string `json:"author"` + License string `json:"license"` + SourceURL string `json:"source_url"` + DownloadURL string `json:"download_url"` + SHA256 string `json:"sha256"` + MinGoblogVersion string `json:"min_goblog_version"` + InstallType string `json:"install_type"` // "wasm" + Runtime string `json:"runtime"` // "wasm" + AllowedHosts []string `json:"allowed_hosts"` // never null: [] when the plugin uses no network + ReleasedAt string `json:"released_at"` + DetailURL string `json:"detail_url"` + Stars int `json:"stars"` } // ReleaseDoc is one release in a plugin's history. @@ -135,10 +137,12 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) Author: v.Manifest.Author, License: v.Manifest.License, SourceURL: "https://github.com/" + ownerRepo, - DownloadURL: fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", ownerRepo, v.Release.Tag, v.Manifest.Entry), + DownloadURL: v.Asset.DownloadURL, SHA256: v.SHA256, MinGoblogVersion: v.Manifest.MinGoblogVersion, - InstallType: "dynamic", + InstallType: "wasm", + Runtime: "wasm", + AllowedHosts: v.Manifest.AllowedHosts, ReleasedAt: v.Release.PublishedAt.UTC().Format(time.RFC3339), DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), } diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index 65ee891..2c35450 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -13,13 +14,17 @@ import ( func TestBuild_WritesIndexAndDetails(t *testing.T) { src := helloSource() - // A second plugin, older release, to check sorting and skipping. - src.releases["o/zeta"] = []Release{{Tag: "v0.1.0", Body: "z", URL: "https://github.com/o/zeta/releases/tag/v0.1.0", PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}} - src.files["o/zeta@v0.1.0:goblog-plugin.json"] = strings.Replace(strings.Replace(goodManifest, `"hello"`, `"zeta"`, 1), `"Hello"`, `"Zeta"`, 1) - src.files["o/zeta@v0.1.0:plugin.go"] = "package main // zeta\n" + // A second plugin, older release and no allowed_hosts, to check sorting, + // skipping and that allowed_hosts never serialises as null. + zetaWasm := []byte("\x00asm zeta v0.1.0") + src.releases["o/zeta"] = []Release{{Tag: "v0.1.0", Body: "z", URL: "https://github.com/o/zeta/releases/tag/v0.1.0", PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Assets: []Asset{{ID: 21, Name: "plugin.wasm", Size: len(zetaWasm), DownloadURL: "https://github.com/o/zeta/releases/download/v0.1.0/plugin.wasm"}}}} + zetaManifest := strings.Replace(strings.Replace(goodManifest, `"hello"`, `"zeta"`, 1), `"Hello"`, `"Zeta"`, 1) + src.files["o/zeta@v0.1.0:goblog-plugin.json"] = strings.Replace(zetaManifest, `"allowed_hosts": ["api.example.test"],`, "", 1) src.files["o/zeta@v0.1.0:README.md"] = "# Zeta" + src.assets[21] = zetaWasm val := helloValidator() - val.Infos[sum([]byte("package main // zeta\n"))] = Info{Name: "zeta", DisplayName: "Zeta", Version: "0.1.0"} + val.Infos[sum(zetaWasm)] = Info{Name: "zeta", DisplayName: "Zeta", Version: "0.1.0", Runtime: "wasm"} out := t.TempDir() res, err := Build(context.Background(), src, val, []string{"o/zeta", "o/hello"}, out, "https://example.test/plugins") @@ -39,13 +44,18 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { want := IndexEntry{ Name: "hello", DisplayName: "Hello", Description: "Says hi.", Version: "1.1.0", Author: "Jason Ernst", License: "Apache-2.0", SourceURL: "https://github.com/o/hello", - DownloadURL: "https://raw.githubusercontent.com/o/hello/v1.1.0/plugin.go", SHA256: sum([]byte(helloSrc)), - MinGoblogVersion: "0.2.6", InstallType: "dynamic", ReleasedAt: "2026-09-15T00:00:00Z", - DetailURL: "https://example.test/plugins/plugins/hello.json", Stars: 7, + DownloadURL: "https://github.com/o/hello/releases/download/v1.1.0/plugin.wasm", SHA256: sum(helloWasm), + MinGoblogVersion: "0.2.6", InstallType: "wasm", Runtime: "wasm", AllowedHosts: []string{"api.example.test"}, + ReleasedAt: "2026-09-15T00:00:00Z", + DetailURL: "https://example.test/plugins/plugins/hello.json", Stars: 7, } - if e != want { + if !reflect.DeepEqual(e, want) { t.Errorf("entry =\n%+v\nwant\n%+v", e, want) } + if z := index[1]; z.Runtime != "wasm" || z.InstallType != "wasm" || z.AllowedHosts == nil || len(z.AllowedHosts) != 0 || + z.DownloadURL != "https://github.com/o/zeta/releases/download/v0.1.0/plugin.wasm" || z.SHA256 != sum(zetaWasm) { + t.Errorf("zeta entry = %+v", z) + } var d DetailDoc mustJSON(t, filepath.Join(out, "plugins", "hello.json"), &d) @@ -84,6 +94,21 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { if err := json.Unmarshal(raw, &generic); err != nil { t.Errorf("index.json is not valid JSON: %v", err) } + // goblog's directory client ranges over allowed_hosts; a plugin with no + // hosts must serialise as an empty array, never null. + if !strings.Contains(string(raw), `"allowed_hosts": []`) { + t.Errorf("a plugin without hosts should serialise \"allowed_hosts\": [], got %s", raw) + } + if strings.Contains(string(raw), "null") { + t.Errorf("index.json must not contain null, got %s", raw) + } + if !strings.Contains(string(raw), `"runtime": "wasm"`) || !strings.Contains(string(raw), `"install_type": "wasm"`) { + t.Errorf("index.json should carry runtime and install_type wasm, got %s", raw) + } + zetaRaw, _ := os.ReadFile(filepath.Join(out, "plugins", "zeta.json")) + if !strings.Contains(string(zetaRaw), `"allowed_hosts": []`) { + t.Errorf("detail doc should also serialise \"allowed_hosts\": [], got %s", zetaRaw) + } } func TestBuild_SkipsBrokenEntriesAndDuplicates(t *testing.T) { diff --git a/internal/registry/manifest.go b/internal/registry/manifest.go index cbe4eb7..87e042b 100644 --- a/internal/registry/manifest.go +++ b/internal/registry/manifest.go @@ -13,22 +13,35 @@ import ( // Manifest is goblog-plugin.json at the root of a plugin repository. type Manifest struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Author string `json:"author"` - License string `json:"license"` - Entry string `json:"entry"` - MinGoblogVersion string `json:"min_goblog_version"` - Homepage string `json:"homepage"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Author string `json:"author"` + License string `json:"license"` + Runtime string `json:"runtime"` + Entry string `json:"entry"` + AllowedHosts []string `json:"allowed_hosts"` + MinGoblogVersion string `json:"min_goblog_version"` + Homepage string `json:"homepage"` } // NamePattern is the rule for plugin names; the directory routes on it. var NamePattern = regexp.MustCompile(`^[a-z0-9-]+$`) -// entryPattern is the rule for the manifest's entry file name: a .go file -// name at the repository root, with no path separators or odd characters. -var entryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+\.go$`) +// entryPattern is the rule for the manifest's entry: the name of a .wasm +// asset attached to each release, with no path separators or odd characters. +var entryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+\.wasm$`) + +// hostPattern is the rule for one allowed_hosts entry: a hostname, IP or +// glob (goblog matches them with github.com/gobwas/glob), optionally with a +// port — never a scheme or a path. +var hostPattern = regexp.MustCompile(`^[A-Za-z0-9.*:-]+$`) + +// wildcardOnly matches an allowed_hosts entry made of nothing but `*` and +// `.` (`*`, `**`, `*.*`): a glob that names no host at all and would let the +// plugin reach anything. The registry is the curation point, so it refuses +// them rather than leaving it to the operator. +var wildcardOnly = regexp.MustCompile(`^[*.]+$`) // knownLicenses is the set of SPDX identifiers accepted in a manifest. It is // deliberately short; add to it when a submission needs another one. @@ -40,14 +53,19 @@ var knownLicenses = map[string]bool{ "AGPL-3.0-only": true, "AGPL-3.0-or-later": true, } -// ParseManifest decodes and validates a manifest. Entry defaults to plugin.go. +// ParseManifest decodes and validates a manifest. Entry defaults to +// plugin.wasm; AllowedHosts is never nil on success so the index serialises +// it as [] rather than null. func ParseManifest(b []byte) (Manifest, error) { var m Manifest if err := json.Unmarshal(b, &m); err != nil { return Manifest{}, fmt.Errorf("goblog-plugin.json: %w", err) } if m.Entry == "" { - m.Entry = "plugin.go" + m.Entry = "plugin.wasm" + } + if m.AllowedHosts == nil { + m.AllowedHosts = []string{} } var problems []string if !NamePattern.MatchString(m.Name) { @@ -61,8 +79,21 @@ func ParseManifest(b []byte) (Manifest, error) { if !knownLicenses[m.License] { problems = append(problems, fmt.Sprintf("license %q is not a known SPDX identifier", m.License)) } + if m.Runtime != "wasm" { + problems = append(problems, "runtime must be \"wasm\": the directory only lists WebAssembly plugins; see docs/CONTRACT.md") + } if !entryPattern.MatchString(m.Entry) { - problems = append(problems, "entry must be a .go file name at the repository root (letters, digits, `_`, `.`, `-`)") + problems = append(problems, "entry must be a .wasm release asset name (letters, digits, `_`, `.`, `-`)") + } + for _, h := range m.AllowedHosts { + if h == "" || !hostPattern.MatchString(h) { + problems = append(problems, "allowed_hosts entries must be hostnames, IPs or globs without scheme or path") + break + } + if wildcardOnly.MatchString(h) { + problems = append(problems, `allowed_hosts entries must name a host; "*" alone is not allowed`) + break + } } if strings.HasPrefix(m.MinGoblogVersion, "v") || !semver.IsValid("v"+m.MinGoblogVersion) || semver.Prerelease("v"+m.MinGoblogVersion) != "" { problems = append(problems, "min_goblog_version must be a plain semver like 0.2.6") diff --git a/internal/registry/manifest_test.go b/internal/registry/manifest_test.go index f0f4f0d..a941d63 100644 --- a/internal/registry/manifest_test.go +++ b/internal/registry/manifest_test.go @@ -11,7 +11,9 @@ const goodManifest = `{ "description": "Says hi.", "author": "Jason Ernst", "license": "Apache-2.0", - "entry": "plugin.go", + "runtime": "wasm", + "entry": "plugin.wasm", + "allowed_hosts": ["api.example.test"], "min_goblog_version": "0.2.6", "homepage": "https://example.test" }` @@ -21,18 +23,58 @@ func TestParseManifest_Good(t *testing.T) { if err != nil { t.Fatal(err) } - if m.Name != "hello" || m.DisplayName != "Hello" || m.License != "Apache-2.0" || m.Entry != "plugin.go" || m.MinGoblogVersion != "0.2.6" || m.Homepage != "https://example.test" { + if m.Name != "hello" || m.DisplayName != "Hello" || m.License != "Apache-2.0" || m.Runtime != "wasm" || m.Entry != "plugin.wasm" || m.MinGoblogVersion != "0.2.6" || m.Homepage != "https://example.test" { t.Errorf("unexpected manifest: %+v", m) } + if len(m.AllowedHosts) != 1 || m.AllowedHosts[0] != "api.example.test" { + t.Errorf("allowed_hosts = %v", m.AllowedHosts) + } } -func TestParseManifest_EntryDefaultsToPluginGo(t *testing.T) { - m, err := ParseManifest([]byte(strings.Replace(goodManifest, `"entry": "plugin.go",`, "", 1))) +func TestParseManifest_EntryDefaultsToPluginWasm(t *testing.T) { + m, err := ParseManifest([]byte(strings.Replace(goodManifest, `"entry": "plugin.wasm",`, "", 1))) if err != nil { t.Fatal(err) } - if m.Entry != "plugin.go" { - t.Errorf("entry should default to plugin.go, got %q", m.Entry) + if m.Entry != "plugin.wasm" { + t.Errorf("entry should default to plugin.wasm, got %q", m.Entry) + } +} + +// TestParseManifest_AllowedHostsNeverNil: a manifest without allowed_hosts +// must parse to an empty slice, so the index serialises "allowed_hosts": [] +// rather than null. +func TestParseManifest_AllowedHostsNeverNil(t *testing.T) { + m, err := ParseManifest([]byte(strings.Replace(goodManifest, `"allowed_hosts": ["api.example.test"],`, "", 1))) + if err != nil { + t.Fatal(err) + } + if m.AllowedHosts == nil || len(m.AllowedHosts) != 0 { + t.Errorf("allowed_hosts should default to an empty, non-nil slice, got %#v", m.AllowedHosts) + } +} + +// TestParseManifest_HostGlobs: a glob that still names a domain is fine; one +// made only of wildcards would let the plugin talk to anything and is refused +// with a message that says so. +func TestParseManifest_HostGlobs(t *testing.T) { + m, err := ParseManifest([]byte(strings.Replace(goodManifest, `["api.example.test"]`, `["*.example.test", "10.0.0.1:8443", "*-cdn.example.test"]`, 1))) + if err != nil { + t.Fatal(err) + } + if len(m.AllowedHosts) != 3 || m.AllowedHosts[0] != "*.example.test" { + t.Errorf("allowed_hosts = %v", m.AllowedHosts) + } + _, err = ParseManifest([]byte(strings.Replace(goodManifest, `["api.example.test"]`, `["*"]`, 1))) + if err == nil || !strings.Contains(err.Error(), `"*" alone is not allowed`) { + t.Errorf("a bare wildcard should be refused with a message naming it, got %v", err) + } +} + +func TestParseManifest_MissingRuntimeMentionsWebAssembly(t *testing.T) { + _, err := ParseManifest([]byte(strings.Replace(goodManifest, `"runtime": "wasm",`, "", 1))) + if err == nil || !strings.Contains(err.Error(), "WebAssembly") { + t.Errorf("a manifest without runtime should be rejected with a pointer to WebAssembly, got %v", err) } } @@ -45,10 +87,19 @@ func TestParseManifest_Errors(t *testing.T) { "missing description": strings.Replace(goodManifest, `"description": "Says hi.",`, "", 1), "missing author": strings.Replace(goodManifest, `"author": "Jason Ernst",`, "", 1), "unknown license": strings.Replace(goodManifest, `"Apache-2.0"`, `"MyLicense"`, 1), - "entry not go": strings.Replace(goodManifest, `"plugin.go"`, `"plugin.txt"`, 1), - "entry with slash": strings.Replace(goodManifest, `"plugin.go"`, `"src/plugin.go"`, 1), - "entry with query char": strings.Replace(goodManifest, `"plugin.go"`, `"a?b.go"`, 1), - "entry with space": strings.Replace(goodManifest, `"plugin.go"`, `"a b.go"`, 1), + "missing runtime": strings.Replace(goodManifest, `"runtime": "wasm",`, "", 1), + "go runtime": strings.Replace(goodManifest, `"runtime": "wasm"`, `"runtime": "go"`, 1), + "entry not wasm": strings.Replace(goodManifest, `"plugin.wasm"`, `"plugin.go"`, 1), + "entry with slash": strings.Replace(goodManifest, `"plugin.wasm"`, `"src/plugin.wasm"`, 1), + "entry with query char": strings.Replace(goodManifest, `"plugin.wasm"`, `"a?b.wasm"`, 1), + "entry with space": strings.Replace(goodManifest, `"plugin.wasm"`, `"a b.wasm"`, 1), + "bad host": strings.Replace(goodManifest, `["api.example.test"]`, `["https://x"]`, 1), + "wildcard host": strings.Replace(goodManifest, `["api.example.test"]`, `["*"]`, 1), + "double wildcard host": strings.Replace(goodManifest, `["api.example.test"]`, `["**"]`, 1), + "dotted wildcard host": strings.Replace(goodManifest, `["api.example.test"]`, `["*.*"]`, 1), + "wildcard among hosts": strings.Replace(goodManifest, `["api.example.test"]`, `["api.example.test", "*"]`, 1), + "host with path": strings.Replace(goodManifest, `["api.example.test"]`, `["x/api"]`, 1), + "empty host": strings.Replace(goodManifest, `["api.example.test"]`, `[""]`, 1), "min version with v": strings.Replace(goodManifest, `"0.2.6"`, `"v0.2.6"`, 1), "min version junk": strings.Replace(goodManifest, `"0.2.6"`, `"latest"`, 1), "missing min version": strings.Replace(goodManifest, `"min_goblog_version": "0.2.6",`, "", 1), diff --git a/internal/registry/source.go b/internal/registry/source.go index daf09be..df1e493 100644 --- a/internal/registry/source.go +++ b/internal/registry/source.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "time" @@ -13,6 +14,18 @@ import ( // ErrNotFound is returned by Source.File when the ref or path does not exist. var ErrNotFound = errors.New("not found") +// MaxAssetBytes is the largest release asset the registry will download and +// validate (16 MiB); goblog's installer applies the same cap. +const MaxAssetBytes = 16 << 20 + +// Asset is a file attached to a GitHub release. +type Asset struct { + ID int64 + Name string + Size int + DownloadURL string // browser_download_url +} + // Release is one GitHub release of a plugin repository. type Release struct { Tag string @@ -22,6 +35,7 @@ type Release struct { PublishedAt time.Time Draft bool Prerelease bool + Assets []Asset } // Source is what the registry needs from GitHub. It is an interface so the @@ -32,6 +46,8 @@ type Source interface { Releases(ctx context.Context, owner, repo string) ([]Release, error) // File returns the contents of path at ref; ErrNotFound when absent. File(ctx context.Context, owner, repo, ref, path string) ([]byte, error) + // ReleaseAsset downloads a release asset by id (at most MaxAssetBytes). + ReleaseAsset(ctx context.Context, owner, repo string, assetID int64) ([]byte, error) // RenderMarkdown renders GitHub-flavoured markdown to sanitized HTML in // the context of ownerRepo (so `#123` and `@user` references resolve; // relative links and images are left as-is). @@ -85,6 +101,9 @@ func (g *GitHubSource) Releases(ctx context.Context, owner, repo string) ([]Rele if r.PublishedAt != nil { rel.PublishedAt = r.PublishedAt.Time } + for _, a := range r.Assets { + rel.Assets = append(rel.Assets, Asset{ID: a.GetID(), Name: a.GetName(), Size: a.GetSize(), DownloadURL: a.GetBrowserDownloadURL()}) + } out = append(out, rel) } if resp.NextPage == 0 { @@ -113,6 +132,27 @@ func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string) return []byte(s), nil } +// assetClient follows the API's redirect to the asset's storage host. It is +// separate from the API client because its timeout has to cover a download +// of up to MaxAssetBytes rather than one JSON response. +var assetClient = &http.Client{Timeout: 2 * time.Minute} + +func (g *GitHubSource) ReleaseAsset(ctx context.Context, owner, repo string, assetID int64) ([]byte, error) { + rc, _, err := g.client.Repositories.DownloadReleaseAsset(ctx, owner, repo, assetID, assetClient) + if err != nil { + return nil, fmt.Errorf("download asset %d of %s/%s: %w", assetID, owner, repo, err) + } + defer rc.Close() + b, err := io.ReadAll(io.LimitReader(rc, MaxAssetBytes+1)) + if err != nil { + return nil, fmt.Errorf("download asset %d of %s/%s: %w", assetID, owner, repo, err) + } + if len(b) > MaxAssetBytes { + return nil, fmt.Errorf("asset %d of %s/%s exceeds %d bytes", assetID, owner, repo, MaxAssetBytes) + } + return b, nil +} + func (g *GitHubSource) RepoStars(ctx context.Context, owner, repo string) (int, error) { r, _, err := g.client.Repositories.Get(ctx, owner, repo) if err != nil { diff --git a/internal/registry/source_test.go b/internal/registry/source_test.go index c150269..8626eac 100644 --- a/internal/registry/source_test.go +++ b/internal/registry/source_test.go @@ -5,24 +5,42 @@ import ( "encoding/base64" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" "testing" ) -// fakeGitHub serves the three REST endpoints GitHubSource uses. +// fakeGitHub serves the REST endpoints GitHubSource uses. func fakeGitHub(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /repos/o/r/releases", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[ - {"tag_name":"v1.1.0","name":"v1.1.0","body":"Second","draft":false,"prerelease":false,"published_at":"2026-09-15T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.1.0"}, + {"tag_name":"v1.1.0","name":"v1.1.0","body":"Second","draft":false,"prerelease":false,"published_at":"2026-09-15T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.1.0", + "assets":[{"id":11,"name":"plugin.wasm","size":4,"browser_download_url":"https://github.com/o/r/releases/download/v1.1.0/plugin.wasm"}]}, {"tag_name":"v1.2.0-rc1","name":"rc","body":"","draft":false,"prerelease":true,"published_at":"2026-09-16T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.2.0-rc1"}, {"tag_name":"v1.0.0","name":"v1.0.0","body":"First","draft":false,"prerelease":false,"published_at":"2026-09-14T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.0.0"} ]`)) }) + // DownloadReleaseAsset GETs the API asset path with Accept: + // application/octet-stream and streams the body on a 200 (no redirect). + mux.HandleFunc("GET /repos/o/r/releases/assets/11", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "application/octet-stream" { + http.Error(w, "expected Accept: application/octet-stream", 400) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Write([]byte("wasm")) + }) + // Asset 12 is one byte over the limit; ReleaseAsset must refuse it + // without reading it all into memory first. + mux.HandleFunc("GET /repos/o/r/releases/assets/12", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + io.CopyN(w, zeroReader{}, MaxAssetBytes+1) + }) mux.HandleFunc("GET /repos/o/r", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"full_name":"o/r","stargazers_count":42}`)) @@ -69,6 +87,24 @@ func TestGitHubSource(t *testing.T) { if len(rels) != 3 || rels[0].Tag != "v1.1.0" || rels[1].Prerelease != true || rels[2].Body != "First" || rels[0].URL == "" || rels[0].PublishedAt.IsZero() { t.Errorf("releases = %+v", rels) } + if len(rels[0].Assets) != 1 || rels[0].Assets[0].ID != 11 || rels[0].Assets[0].Name != "plugin.wasm" || rels[0].Assets[0].Size != 4 || + rels[0].Assets[0].DownloadURL != "https://github.com/o/r/releases/download/v1.1.0/plugin.wasm" { + t.Errorf("assets = %+v", rels[0].Assets) + } + if len(rels[1].Assets) != 0 { + t.Errorf("a release without assets should have none, got %+v", rels[1].Assets) + } + + asset, err := src.ReleaseAsset(ctx, "o", "r", 11) + if err != nil || string(asset) != "wasm" { + t.Errorf("ReleaseAsset: %q %v", asset, err) + } + if _, err := src.ReleaseAsset(ctx, "o", "r", 99); err == nil { + t.Error("ReleaseAsset with an unknown id should fail") + } + if _, err := src.ReleaseAsset(ctx, "o", "r", 12); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Errorf("an asset over MaxAssetBytes should be refused, got %v", err) + } b, err := src.File(ctx, "o", "r", "v1.1.0", "plugin.go") if err != nil || string(b) != "package main\n" { @@ -99,3 +135,11 @@ func TestGitHubSource(t *testing.T) { } func jsonDecode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) } + +// zeroReader is an endless stream of zero bytes. +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + clear(p) + return len(p), nil +} diff --git a/internal/registry/validate.go b/internal/registry/validate.go index 3be4170..017b828 100644 --- a/internal/registry/validate.go +++ b/internal/registry/validate.go @@ -24,13 +24,15 @@ type Validated struct { Release Release // the latest published, non-prerelease release Version string // Release.Tag without the leading v Releases []Release // all published, non-prerelease releases, newest first - Entry []byte // the plugin source at Release.Tag + Asset Asset // the release asset named by Manifest.Entry + Entry []byte // the asset's bytes (the WebAssembly module) SHA256 string // hex sha256 of Entry } // ValidateEntry checks one registry entry end to end: a published release -// tagged vX.Y.Z, a valid manifest and README at that tag, an entry file that -// loads in goblog, and an identity that matches the manifest and the tag. +// tagged vX.Y.Z, a valid manifest and README at that tag, a release asset +// named by the manifest's entry that loads in goblog as a WebAssembly +// plugin, and an identity that matches the manifest and the tag. func ValidateEntry(ctx context.Context, src Source, val Validator, repo string) (*Validated, error) { owner, name, ok := strings.Cut(repo, "/") if !ok || owner == "" || name == "" || strings.Contains(name, "/") { @@ -80,15 +82,31 @@ func ValidateEntry(ctx context.Context, src Source, val Validator, repo string) if _, err := src.File(ctx, owner, name, latest.Tag, "README.md"); err != nil { return nil, fmt.Errorf("%s@%s: README.md: %w", repo, latest.Tag, err) } - entry, err := src.File(ctx, owner, name, latest.Tag, manifest.Entry) + var asset *Asset + for i := range latest.Assets { + if latest.Assets[i].Name == manifest.Entry { + asset = &latest.Assets[i] + break + } + } + if asset == nil { + return nil, fmt.Errorf("%s: release %s has no asset named %s (the release workflow must upload it)", repo, latest.Tag, manifest.Entry) + } + if asset.Size > MaxAssetBytes { + return nil, fmt.Errorf("%s@%s: asset %s is %d bytes; the limit is %d (16 MiB)", repo, latest.Tag, asset.Name, asset.Size, MaxAssetBytes) + } + entry, err := src.ReleaseAsset(ctx, owner, name, asset.ID) if err != nil { - return nil, fmt.Errorf("%s@%s: entry %s: %w", repo, latest.Tag, manifest.Entry, err) + return nil, fmt.Errorf("%s@%s: asset %s: %w", repo, latest.Tag, asset.Name, err) } info, err := val.Validate(ctx, entry) if err != nil { return nil, fmt.Errorf("%s@%s: %s does not load: %w", repo, latest.Tag, manifest.Entry, err) } + if info.Runtime != "wasm" { + return nil, fmt.Errorf("%s@%s: %s is not a WebAssembly plugin (runtime %q)", repo, latest.Tag, manifest.Entry, info.Runtime) + } if info.Name != manifest.Name { return nil, fmt.Errorf("%s@%s: Name() is %q but the manifest says %q", repo, latest.Tag, info.Name, manifest.Name) } @@ -100,7 +118,7 @@ func ValidateEntry(ctx context.Context, src Source, val Validator, repo string) return &Validated{ Repo: repo, Owner: owner, Name: name, Manifest: manifest, Release: latest, Version: version, Releases: releases, - Entry: entry, SHA256: hex.EncodeToString(h[:]), + Asset: *asset, Entry: entry, SHA256: hex.EncodeToString(h[:]), }, nil } diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index b91b6b4..c1dadde 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -3,6 +3,7 @@ package registry import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -12,6 +13,7 @@ import ( type memSource struct { releases map[string][]Release // "owner/repo" → releases files map[string]string // "owner/repo@ref:path" → content + assets map[int64][]byte // asset id → bytes rendered int // RenderMarkdown call count stars map[string]int // "owner/repo" → stargazers_count starsErr error // when set, RepoStars fails for every repo @@ -32,6 +34,13 @@ func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]by return nil, ErrNotFound } +func (m *memSource) ReleaseAsset(_ context.Context, owner, repo string, id int64) ([]byte, error) { + if b, ok := m.assets[id]; ok { + return b, nil + } + return nil, fmt.Errorf("%s/%s: no asset %d", owner, repo, id) +} + func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (string, error) { if md == "" { return "", nil @@ -50,13 +59,17 @@ func (m *memSource) RepoStars(_ context.Context, owner, repo string) (int, error return 0, nil } -const helloSrc = "package main\n// hello plugin\n" +// helloWasm stands in for the plugin.wasm asset attached to hello v1.1.0. +var helloWasm = []byte("\x00asm hello v1.1.0") + +// helloAsset is the release asset the validator downloads for hello v1.1.0. +var helloAsset = Asset{ID: 11, Name: "plugin.wasm", Size: len(helloWasm), DownloadURL: "https://github.com/o/hello/releases/download/v1.1.0/plugin.wasm"} func helloSource() *memSource { return &memSource{ releases: map[string][]Release{ "o/hello": { - {Tag: "v1.1.0", Body: "Second", URL: "https://github.com/o/hello/releases/tag/v1.1.0", PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC)}, + {Tag: "v1.1.0", Body: "Second", URL: "https://github.com/o/hello/releases/tag/v1.1.0", PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC), Assets: []Asset{helloAsset}}, {Tag: "v2.0.0-rc1", Prerelease: true, PublishedAt: time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)}, {Tag: "v3.0.0", Draft: true, PublishedAt: time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC)}, {Tag: "v1.0.0", Body: "First", URL: "https://github.com/o/hello/releases/tag/v1.0.0", PublishedAt: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)}, @@ -64,16 +77,16 @@ func helloSource() *memSource { }, files: map[string]string{ "o/hello@v1.1.0:goblog-plugin.json": goodManifest, - "o/hello@v1.1.0:plugin.go": helloSrc, "o/hello@v1.1.0:README.md": "# Hello", "o/hello@v1.1.0:CHANGELOG.md": "## 1.1.0\n- second", }, - stars: map[string]int{"o/hello": 7}, + assets: map[int64][]byte{11: helloWasm}, + stars: map[string]int{"o/hello": 7}, } } func helloValidator() *FakeValidator { - return &FakeValidator{Infos: map[string]Info{sum([]byte(helloSrc)): {Name: "hello", DisplayName: "Hello", Version: "1.1.0"}}} + return &FakeValidator{Infos: map[string]Info{sum(helloWasm): {Name: "hello", DisplayName: "Hello", Version: "1.1.0", Runtime: "wasm"}}} } func TestValidateEntry_Good(t *testing.T) { @@ -87,8 +100,11 @@ func TestValidateEntry_Good(t *testing.T) { if len(v.Releases) != 2 || v.Releases[0].Tag != "v1.1.0" || v.Releases[1].Tag != "v1.0.0" { t.Errorf("releases should exclude drafts/prereleases, newest first: %+v", v.Releases) } - if string(v.Entry) != helloSrc || v.SHA256 != sum([]byte(helloSrc)) { - t.Errorf("entry/sha mismatch") + if string(v.Entry) != string(helloWasm) || v.SHA256 != sum(helloWasm) { + t.Errorf("entry/sha mismatch: entry=%q sha=%s", v.Entry, v.SHA256) + } + if v.Asset != helloAsset || v.Asset.DownloadURL != "https://github.com/o/hello/releases/download/v1.1.0/plugin.wasm" { + t.Errorf("asset = %+v, want %+v", v.Asset, helloAsset) } } @@ -146,20 +162,32 @@ func TestValidateEntry_Errors(t *testing.T) { "invalid manifest": {func(s *memSource, f *FakeValidator) { s.files["o/hello@v1.1.0:goblog-plugin.json"] = `{"name":"Bad"}` }, "goblog-plugin.json"}, - "missing entry": {func(s *memSource, f *FakeValidator) { - delete(s.files, "o/hello@v1.1.0:plugin.go") - }, "plugin.go"}, + "no asset": {func(s *memSource, f *FakeValidator) { + s.releases["o/hello"][0].Assets = nil + }, "no asset named plugin.wasm"}, + "wrong asset name": {func(s *memSource, f *FakeValidator) { + s.releases["o/hello"][0].Assets = []Asset{{ID: 11, Name: "hello.wasm", Size: len(helloWasm)}} + }, "no asset named plugin.wasm"}, + "asset too big": {func(s *memSource, f *FakeValidator) { + s.releases["o/hello"][0].Assets = []Asset{{ID: 11, Name: "plugin.wasm", Size: MaxAssetBytes + 1}} + }, "16"}, + "asset download fails": {func(s *memSource, f *FakeValidator) { + delete(s.assets, 11) + }, "no asset 11"}, + "not wasm runtime": {func(s *memSource, f *FakeValidator) { + f.Infos[sum(helloWasm)] = Info{Name: "hello", DisplayName: "Hello", Version: "1.1.0"} + }, "runtime"}, "missing readme": {func(s *memSource, f *FakeValidator) { delete(s.files, "o/hello@v1.1.0:README.md") }, "README.md"}, "does not load": {func(s *memSource, f *FakeValidator) { - f.Err = errors.New("yaegi: boom") + f.Err = errors.New("wasm: boom") }, "boom"}, "name mismatch": {func(s *memSource, f *FakeValidator) { - f.Infos[sum([]byte(helloSrc))] = Info{Name: "other", Version: "1.1.0"} + f.Infos[sum(helloWasm)] = Info{Name: "other", Version: "1.1.0", Runtime: "wasm"} }, "Name()"}, "version mismatch": {func(s *memSource, f *FakeValidator) { - f.Infos[sum([]byte(helloSrc))] = Info{Name: "hello", Version: "1.0.9"} + f.Infos[sum(helloWasm)] = Info{Name: "hello", Version: "1.0.9", Runtime: "wasm"} }, "Version()"}, } for name, c := range cases { diff --git a/internal/registry/validator.go b/internal/registry/validator.go index 8de9b56..f5d5906 100644 --- a/internal/registry/validator.go +++ b/internal/registry/validator.go @@ -15,18 +15,20 @@ import ( "time" ) -// Info is what `goblog validate-plugin` prints for a plugin file. +// Info is what `goblog validate-plugin` prints for a plugin file. Runtime is +// "wasm" for a WebAssembly module (goblog omits it for a .go file). type Info struct { Name string `json:"name"` DisplayName string `json:"display_name"` Version string `json:"version"` + Runtime string `json:"runtime"` } -// Validator loads a plugin source file the way goblog would and reports its +// Validator loads a plugin module the way goblog would and reports its // identity. The real one runs goblog's validate-plugin in Docker; tests use // a fake. type Validator interface { - Validate(ctx context.Context, src []byte) (Info, error) + Validate(ctx context.Context, module []byte) (Info, error) } // GoblogEntrypoint is the goblog binary inside the release image, whose @@ -37,9 +39,11 @@ const GoblogEntrypoint = "/go/src/github.com/compscidr/goblog/goblog" // take before its container is killed and the plugin is rejected. const defaultValidateTimeout = 120 * time.Second -// DockerValidator runs `goblog validate-plugin` inside the pinned goblog -// image with networking disabled: the file is interpreted, so it can run -// arbitrary Go, and this is the only sandbox the registry gives it. +// DockerValidator runs `goblog validate-plugin` on plugin.wasm inside the +// pinned goblog image with networking disabled. goblog already sandboxes the +// module (no filesystem, no network, memory cap, timeouts); the container is +// a second fence around goblog itself, so a module that finds a bug in the +// runtime still cannot reach the registry's CI environment. type DockerValidator struct { Image string // Timeout bounds a single validation run. Defaults to 120s in @@ -54,7 +58,7 @@ func NewDockerValidator(image string) *DockerValidator { func (d *DockerValidator) args(dir, name string) []string { return []string{"run", "--rm", "--network", "none", "--memory", "512m", "--pids-limit", "256", "--name", name, "-v", dir + ":/p:ro", - "--entrypoint", GoblogEntrypoint, d.Image, "validate-plugin", "/p/plugin.go"} + "--entrypoint", GoblogEntrypoint, d.Image, "validate-plugin", "/p/plugin.wasm"} } // containerName generates a unique name for the container running one @@ -68,13 +72,13 @@ func containerName() (string, error) { return "goblog-validate-" + hex.EncodeToString(b), nil } -func (d *DockerValidator) Validate(ctx context.Context, src []byte) (Info, error) { +func (d *DockerValidator) Validate(ctx context.Context, module []byte) (Info, error) { dir, err := os.MkdirTemp("", "goblog-plugin-") if err != nil { return Info{}, err } defer os.RemoveAll(dir) - if err := os.WriteFile(filepath.Join(dir, "plugin.go"), src, 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "plugin.wasm"), module, 0644); err != nil { return Info{}, err } diff --git a/internal/registry/validator_test.go b/internal/registry/validator_test.go index 0a9997f..925a877 100644 --- a/internal/registry/validator_test.go +++ b/internal/registry/validator_test.go @@ -13,7 +13,7 @@ import ( "time" ) -// FakeValidator answers by the sha256 of the source it is given. +// FakeValidator answers by the sha256 of the module bytes it is given. type FakeValidator struct { Infos map[string]Info Err error @@ -34,40 +34,45 @@ func (f *FakeValidator) Validate(_ context.Context, src []byte) (Info, error) { return Info{}, errors.New("fake: does not load") } -// TestDockerValidator_Real runs the actual goblog image; skipped unless -// docker is available and REGISTRY_DOCKER_TESTS=1 (it pulls ~100 MB). +// realImage is the goblog image the real Docker test runs; keep it equal to +// the default in cmd/registry and the workflows. +const realImage = "compscidr/goblog:v0.2.9" + +// echoWasmPath is goblog's committed echo fixture (identity echo/Echo/1.2.3), +// found when this registry is checked out next to goblog. +const echoWasmPath = "../../../goblog/plugin/wasm/testdata/echo.wasm" + +// TestDockerValidator_Real runs the actual goblog image against goblog's +// echo.wasm fixture; skipped unless docker is available, +// REGISTRY_DOCKER_TESTS=1 (it pulls ~100 MB) and the fixture is present. func TestDockerValidator_Real(t *testing.T) { if _, err := exec.LookPath("docker"); err != nil || os.Getenv("REGISTRY_DOCKER_TESTS") == "" { t.Skip("set REGISTRY_DOCKER_TESTS=1 with docker available") } - src := []byte(`package main -import "goblog/plugin" -type P struct{ plugin.BasePlugin } -func NewPlugin() plugin.Plugin { return &P{} } -func (P) Name() string { return "p" } -func (P) DisplayName() string { return "P" } -func (P) Version() string { return "1.2.3" } -`) - v := NewDockerValidator("compscidr/goblog:v0.2.7") - info, err := v.Validate(context.Background(), src) + module, err := os.ReadFile(echoWasmPath) + if err != nil { + t.Skipf("goblog's echo.wasm fixture not found at %s: %v", echoWasmPath, err) + } + v := NewDockerValidator(realImage) + info, err := v.Validate(context.Background(), module) if err != nil { t.Fatal(err) } - if info.Name != "p" || info.Version != "1.2.3" { + if info.Name != "echo" || info.DisplayName != "Echo" || info.Version != "1.2.3" || info.Runtime != "wasm" { t.Errorf("info = %+v", info) } - if _, err := v.Validate(context.Background(), []byte("package main\nfunc NewPlugin() int { return 1 ")); err == nil { - t.Error("broken source should fail") + if _, err := v.Validate(context.Background(), []byte("\x00asm not a module")); err == nil { + t.Error("a broken module should fail") } } func TestDockerValidator_CommandShape(t *testing.T) { - v := NewDockerValidator("compscidr/goblog:v0.2.7") + v := NewDockerValidator(realImage) args := v.args("/tmp/x", "goblog-validate-abc123") want := []string{"run", "--rm", "--network", "none", "--memory", "512m", "--pids-limit", "256", "--name", "goblog-validate-abc123", "-v", "/tmp/x:/p:ro", - "--entrypoint", "/go/src/github.com/compscidr/goblog/goblog", "compscidr/goblog:v0.2.7", - "validate-plugin", "/p/plugin.go"} + "--entrypoint", "/go/src/github.com/compscidr/goblog/goblog", "compscidr/goblog:v0.2.9", + "validate-plugin", "/p/plugin.wasm"} if len(args) != len(want) { t.Fatalf("args = %v", args) } @@ -104,7 +109,7 @@ func TestDockerValidator_Timeout(t *testing.T) { v := NewDockerValidator("img") v.Timeout = 50 * time.Millisecond - _, err := v.Validate(context.Background(), []byte("package main\n")) + _, err := v.Validate(context.Background(), []byte("\x00asm")) if err == nil || !strings.Contains(err.Error(), "timed out") { t.Errorf("want a timed out error, got %v", err) } @@ -116,3 +121,37 @@ func TestDockerValidator_Timeout(t *testing.T) { t.Errorf("want docker kill to have run against exactly one goblog-validate-* container, got %v", markers) } } + +// TestDockerValidator_WritesPluginWasm uses a fake "docker" that reads the +// mounted directory out of the -v argument and prints the sha256 of the +// plugin.wasm it finds there as the identity's name, so the test can check +// that the module bytes reach the container under the name validate-plugin +// is told to load. +func TestDockerValidator_WritesPluginWasm(t *testing.T) { + dir := t.TempDir() + script := `#!/bin/sh +prev="" +for a in "$@"; do + if [ "$prev" = "-v" ]; then mount="${a%%:*}"; fi + prev="$a" +done +last="" +for a in "$@"; do last="$a"; done +[ "$last" = "/p/plugin.wasm" ] || { echo "unexpected file $last" >&2; exit 1; } +sum=$(sha256sum "$mount/plugin.wasm" | cut -d' ' -f1) +printf '{"name":"%s","display_name":"X","version":"0.0.1","runtime":"wasm"}\n' "$sum" +` + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + module := []byte("\x00asm module bytes") + info, err := NewDockerValidator("img").Validate(context.Background(), module) + if err != nil { + t.Fatal(err) + } + if info.Name != sum(module) || info.Runtime != "wasm" { + t.Errorf("info = %+v, want name %s", info, sum(module)) + } +}