diff --git a/README.md b/README.md index 4fee09e..0ed5d47 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,19 @@ The same works from the command line: ```sh termcade signup # create an account (or: termcade login) termcade add aviorstudio/brickough # add straight from the marketplace -termcade add aviorstudio/brickough@0.0.1 # or pin a version termcade add .tcade # or from a package you have (also signed in) termcade list # what's here termcade remove author/slug # take one off (updates your library too) ``` +There is no way to install an older version, and that is deliberate. A game +is not a dependency — nothing builds against one — so the reasons package +managers pin (reproducible builds, lockfiles, a transitive bump breaking you) +do not apply. `add` gives you what the author currently ships. The one thing +that does narrow the choice is the ABI: the registry picks the newest release +your arcade can actually run, so a game that has moved on to a later ABI tells +you to update termcade instead of handing you a package the host would refuse. + The registry defaults to local dev (`http://127.0.0.1:8080`, the termcade-be stack) and will point at `https://api.termca.de` once that is deployed. `TERMCADE_REGISTRY` overrides it either way — which is how you reach a local @@ -130,10 +137,16 @@ TERMCADE_REGISTRY=http://127.0.0.1:8080 termcade add you/mygame **The registry stores no packages.** It is an index: a game's releases live on its GitHub releases, and the registry records where each one is and what it -hashed to when the registry fetched and validated it. `termcade add` asks the -registry where a version lives, downloads it from GitHub, and refuses to -install anything whose sha256 does not match what the registry recorded — so a -release asset swapped after publishing fails rather than reaching a player. +hashed to when it fetched and validated them. Publishing is open source only +for now — the registry checks that a repository is publicly visible before it +will serve anything from it. + +Packages come back through the API rather than from GitHub directly. That is +what lets an install require an account and lets the arcade and the app share +one path. `termcade add` asks the registry which release to install, streams +it from the registry, and refuses to install anything whose sha256 does not +match what the registry recorded — so a release asset swapped after publishing +fails rather than reaching a player. Installed games are WebAssembly modules that run sandboxed (no filesystem, no network) under [wazero](https://wazero.io). A broken install shows up dimmed diff --git a/cli.go b/cli.go index 71cbed9..771d5f7 100644 --- a/cli.go +++ b/cli.go @@ -21,9 +21,8 @@ const usage = `termcade — an arcade in your terminal usage: termcade play (marketplace included — press m) - termcade add add a game: author/slug from the marketplace - (pin a version with @1.2.3), or a .tcade file - or URL + termcade add add a game: author/slug from the marketplace, + or a .tcade file or URL (needs an account) termcade remove remove an added game (id is author/slug) termcade list list the games in your arcade @@ -97,6 +96,15 @@ func cmdAdd(args []string) error { } src := args[0] + // Pinning is gone. Someone with the old spelling in their fingers or in a + // script should be told that, not handed a missing-file error for what is + // obviously a marketplace id. Before the account check, because this is + // the command being wrong rather than the caller being anonymous — being + // told to sign in and then told the syntax changed is two trips. + if id, _, pinned := strings.Cut(src, "@"); pinned && gameIDRe.MatchString(id) { + return fmt.Errorf("versions cannot be pinned — `termcade add %s` installs what %s currently ships", id, id) + } + session, err := registry.LoadSession() if err != nil { return err @@ -108,11 +116,9 @@ func cmdAdd(args []string) error { return fmt.Errorf("installing a game requires an account — run `termcade login` (or `termcade signup`)") } - // Marketplace id (optionally pinned, author/slug@1.2.3) → fetch through - // the registry and sync the library. - id, version, _ := strings.Cut(src, "@") - if _, statErr := os.Stat(src); gameIDRe.MatchString(id) && statErr != nil { - return addFromRegistry(session, id, version) + // Marketplace id → fetch through the registry and sync the library. + if _, statErr := os.Stat(src); gameIDRe.MatchString(src) && statErr != nil { + return addFromRegistry(session, src) } var raw []byte @@ -133,21 +139,17 @@ func cmdAdd(args []string) error { // addFromRegistry installs a marketplace id. The session is never nil: cmdAdd // turns a signed-out install away before it gets here. -func addFromRegistry(session *registry.Session, id, version string) error { +func addFromRegistry(session *registry.Session, id string) error { author, slug, _ := strings.Cut(id, "/") client := registry.New(registry.URL(session), session.Token) - resolved, err := client.Resolve(author, slug, version) + path, err := client.Download(author, slug) if errors.Is(err, registry.ErrLoginRequired) { return fmt.Errorf("your session has expired — run `termcade login`") } if err != nil { return err } - path, err := client.Fetch(resolved, slug) - if err != nil { - return err - } defer os.Remove(path) raw, err := os.ReadFile(path) diff --git a/internal/registry/client.go b/internal/registry/client.go index 9503856..586c02f 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -55,9 +55,10 @@ type Game struct { SHA256 string `json:"sha256"` } -// Resolved is where one release actually lives and what it must hash to. The -// registry hosts no packages: it hands out a GitHub asset URL plus the digest -// it recorded when it fetched and validated that package itself. +// Resolved is which release to install and what it must hash to. The registry +// stores no packages — a game's releases live on its GitHub releases — but +// the bytes come back through the registry rather than from there, so URL is +// provenance rather than somewhere this client fetches from. type Resolved struct { ID string `json:"id"` Name string `json:"name"` @@ -158,59 +159,71 @@ func (c *Client) Games() ([]Game, error) { return games, c.do(http.MethodGet, "/v1/games", nil, &games) } -// Resolve asks the registry where a version lives. An empty version means -// the newest one. +// Resolve asks the registry which release to install: the newest one this +// arcade can run. +// +// There is no way to ask for an older version. A game is not a dependency — +// nothing builds against one — so the only version worth installing is what +// the author currently ships. // // The ABI this arcade speaks goes with the request, so the registry can pick // the newest release this binary can actually run rather than the newest one // that exists. Without it a game that has moved on to a later ABI would // resolve, download, and only then be refused by the host. -func (c *Client) Resolve(author, slug, version string) (Resolved, error) { +func (c *Client) Resolve(author, slug string) (Resolved, error) { q := url.Values{} q.Set("abi", strconv.Itoa(sdk.ABIVersion)) - if version != "" { - q.Set("version", version) - } var out Resolved return out, c.do(http.MethodGet, "/v1/games/"+author+"/"+slug+"/resolve?"+q.Encode(), nil, &out) } -// Download resolves a game and fetches its package to a temp file. The caller -// removes the returned path. +// Download resolves a game and fetches its package to a temp file, verifying +// it against the digest the registry attested. The caller removes the +// returned path. +// +// The bytes come from the registry, not from GitHub. That is what lets an +// install require an account and lets the arcade and the app take the same +// path; the games are open source and their assets are public, so it is a +// product boundary rather than one that keeps anybody out. +// +// The digest still does real work: it is the tie between what arrives and +// what the registry reviewed at publish time, across a hop the registry does +// not control. A mismatch or a missing digest is fatal. func (c *Client) Download(author, slug string) (string, error) { - resolved, err := c.Resolve(author, slug, "") + resolved, err := c.Resolve(author, slug) if err != nil { return "", err } - return c.Fetch(resolved, slug) -} - -// Fetch downloads a resolved package and verifies it against the digest the -// registry attested. -// -// The bytes come from GitHub, not from the registry, so the digest is the -// only thing tying what arrives to what the registry reviewed — a release -// asset can be deleted and re-uploaded under the same tag. Unlike the old -// download path, where the checksum was a header the sender could simply -// omit, a mismatch or a missing digest here is fatal. -// -// Nothing authenticates this request: the registry token is for the registry, -// and must not be sent to a third-party host. -func (c *Client) Fetch(resolved Resolved, slug string) (string, error) { - if !strings.HasPrefix(resolved.URL, "https://") { - return "", fmt.Errorf("registry returned a non-https package url for %s", resolved.ID) - } if len(resolved.SHA256) != 64 { return "", fmt.Errorf("registry published no checksum for %s %s", resolved.ID, resolved.Version) } - resp, err := c.http.Get(resolved.URL) + q := url.Values{} + q.Set("abi", strconv.Itoa(sdk.ABIVersion)) + req, err := http.NewRequest(http.MethodGet, + c.baseURL+"/v1/games/"+author+"/"+slug+"/download?"+q.Encode(), nil) if err != nil { - return "", fmt.Errorf("downloading %s: %w", resolved.URL, err) + return "", err + } + if c.token != "" { + req.Header.Set("Authorization", c.token) + } + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("registry unreachable: %w", err) } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return "", ErrLoginRequired + } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("downloading %s: %s", resolved.URL, resp.Status) + var msg apiMessage + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + if json.Unmarshal(raw, &msg) == nil && msg.Message != "" { + return "", fmt.Errorf("downloading %s: %s", resolved.ID, msg.Message) + } + return "", fmt.Errorf("downloading %s: HTTP %d", resolved.ID, resp.StatusCode) } tmp, err := os.CreateTemp("", slug+"-*.tcade") diff --git a/internal/registry/client_test.go b/internal/registry/client_test.go new file mode 100644 index 0000000..4a20439 --- /dev/null +++ b/internal/registry/client_test.go @@ -0,0 +1,159 @@ +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + + "github.com/aviorstudio/termcade/sdk" +) + +const pkg = "a package, as far as this test is concerned" + +func digestOf(b string) string { + sum := sha256.Sum256([]byte(b)) + return hex.EncodeToString(sum[:]) +} + +// stubRegistry answers resolve and download the way the API does. body is what +// /download returns; sha is what /resolve claims it hashes to, so a test can +// make the two disagree. +type stubRegistry struct { + sha string + body string + // downloadAuth records the Authorization header the download arrived with. + downloadAuth string + // resolveQuery records the query resolve arrived with. + resolveQuery url.Values + // assetHits counts requests to the GitHub URL resolve hands out. It must + // stay zero: packages come through the registry now. + assetHits int + status int +} + +func (s *stubRegistry) serve(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/v1/games/aviorstudio/brickough/resolve", func(w http.ResponseWriter, r *http.Request) { + s.resolveQuery = r.URL.Query() + json.NewEncoder(w).Encode(Resolved{ + ID: "aviorstudio/brickough", Name: "Brickough", Version: "1.0.0", + Asset: "brickough.tcade", SHA256: s.sha, ABI: sdk.ABIVersion, + // Deliberately pointing back at this same server, so a client + // that still fetched from here would be caught by assetHits + // rather than by a network error that looks like a flake. + URL: "https://" + r.Host + "/asset", + }) + }) + mux.HandleFunc("/v1/games/aviorstudio/brickough/download", func(w http.ResponseWriter, r *http.Request) { + s.downloadAuth = r.Header.Get("Authorization") + if s.status != 0 { + w.WriteHeader(s.status) + json.NewEncoder(w).Encode(apiMessage{Message: "nope"}) + return + } + w.Write([]byte(s.body)) + }) + mux.HandleFunc("/asset", func(w http.ResponseWriter, r *http.Request) { + s.assetHits++ + w.Write([]byte(s.body)) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// The whole point of the change: a package arrives through the registry, with +// the session attached, and the GitHub URL resolve mentions is never fetched. +func TestDownloadComesThroughTheRegistry(t *testing.T) { + stub := &stubRegistry{sha: digestOf(pkg), body: pkg} + srv := stub.serve(t) + + path, err := New(srv.URL, "session-token").Download("aviorstudio", "brickough") + if err != nil { + t.Fatalf("download: %v", err) + } + defer os.Remove(path) + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != pkg { + t.Errorf("downloaded %q, want the package", got) + } + if stub.downloadAuth != "session-token" { + t.Errorf("download sent Authorization %q; installing is supposed to require an account", stub.downloadAuth) + } + if stub.assetHits != 0 { + t.Errorf("the client fetched the GitHub asset %d times; packages come through the registry now", stub.assetHits) + } +} + +// The digest is the tie between what arrives and what the registry validated +// at publish time. A package that does not match it must not reach the disk. +func TestDownloadRefusesAMismatchedDigest(t *testing.T) { + stub := &stubRegistry{sha: digestOf("what was published"), body: "something else entirely"} + srv := stub.serve(t) + + path, err := New(srv.URL, "session-token").Download("aviorstudio", "brickough") + if err == nil { + os.Remove(path) + t.Fatal("a package that did not match its digest was installed") + } + if path != "" { + t.Errorf("a rejected download left %s behind", path) + } + if got := err.Error(); !strings.Contains(got, "checksum mismatch") { + t.Errorf("error does not say what went wrong: %q", got) + } +} + +// A registry that publishes no digest leaves nothing to verify against, which +// is worse than refusing. +func TestDownloadRefusesAMissingDigest(t *testing.T) { + stub := &stubRegistry{sha: "", body: pkg} + srv := stub.serve(t) + + if _, err := New(srv.URL, "session-token").Download("aviorstudio", "brickough"); err == nil { + t.Fatal("a package with no attested digest was installed") + } +} + +// An expired or absent session has to be legible as one, so the CLI and the +// TUI can say "sign in" rather than "HTTP 401". +func TestDownloadReportsAnUnauthorizedResponse(t *testing.T) { + stub := &stubRegistry{sha: digestOf(pkg), body: pkg, status: http.StatusUnauthorized} + srv := stub.serve(t) + + _, err := New(srv.URL, "").Download("aviorstudio", "brickough") + if !errors.Is(err, ErrLoginRequired) { + t.Fatalf("401 gave %v, want ErrLoginRequired", err) + } +} + +// Resolve sends the ABI so the registry can pick a release this binary can +// run, and sends no version at all — pinning is gone. +func TestResolveSendsTheABIAndNoVersion(t *testing.T) { + stub := &stubRegistry{sha: digestOf(pkg), body: pkg} + srv := stub.serve(t) + + if _, err := New(srv.URL, "").Resolve("aviorstudio", "brickough"); err != nil { + t.Fatalf("resolve: %v", err) + } + if got := stub.resolveQuery.Get("abi"); got != strconv.Itoa(sdk.ABIVersion) { + t.Errorf("abi = %q, want %d", got, sdk.ABIVersion) + } + if stub.resolveQuery.Has("version") { + t.Errorf("resolve still asks for a version: %v", stub.resolveQuery) + } +}