Commit Diff


commit - /dev/null
commit + feb508df949b89fcb4d5657355742041eddae7d3
blob - /dev/null
blob + dcd748f5cb23857dbeb8e28649dfbddab2aa3c18 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,3 @@
+build
+client_secret.json
+token.json
blob - /dev/null
blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644)
--- /dev/null
+++ .rustfmt.toml
@@ -0,0 +1 @@
+max_width = 80
blob - /dev/null
blob + 32f3292e31bbd5acf75b7671b679e9017828078c (mode 644)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,13 @@
+Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
blob - /dev/null
blob + e1b6dae06e0523fa1a9527d0cc380d1d948ae53e (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,85 @@
+#
+# Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc)
+RUSTFLAGS ?= -C opt-level=2 -C strip=symbols
+VERSION = 0.1.0
+PREFIX ?= /usr/local
+MANDIR ?= $(PREFIX)/share/man
+
+# Prefer LibreSSL over the system libtls (which on Arch Linux is the
+# libretls shim over OpenSSL).  Override LIBRESSL_PREFIX to point at a
+# non-default LibreSSL install, or set it empty to use whatever the
+# linker finds in the standard search path (e.g. on OpenBSD).
+LIBRESSL_PREFIX ?= /usr/lib/libressl
+ifneq ($(wildcard $(LIBRESSL_PREFIX)/libtls.so),)
+LIBRESSL_LDFLAGS = -C link-arg=-L$(LIBRESSL_PREFIX) \
+		   -C link-arg=-Wl,-rpath,$(LIBRESSL_PREFIX)
+else
+LIBRESSL_LDFLAGS =
+endif
+
+BUILD = build
+BIN = $(BUILD)/gst
+MAIN = gst.rs
+JACKSON = vendor/jackson.rs
+JACKSON_LIB = $(BUILD)/libjackson.rlib
+
+CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null)
+RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null)
+
+.PHONY: all clean install ci fmt-check clippy
+
+all: $(BIN)
+
+$(JACKSON_LIB): $(JACKSON)
+	mkdir -p $(BUILD)
+	$(RUSTC) --edition 2024 \
+		--crate-type rlib --crate-name jackson $(RUSTFLAGS) \
+		-o $@ $<
+
+$(BIN): $(MAIN) $(JACKSON_LIB)
+	mkdir -p $(BUILD)
+	GST_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \
+		--crate-type bin --crate-name gst $(RUSTFLAGS) \
+		$(LIBRESSL_LDFLAGS) \
+		-C link-arg=-ltls \
+		-C link-arg=-lcrypto \
+		--extern jackson=$(JACKSON_LIB) \
+		-o $@ $<
+
+clean:
+	rm -rf $(BUILD)
+
+install: $(BIN)
+	install -d $(PREFIX)/bin $(MANDIR)/man1
+	install -m 755 $(BIN) $(PREFIX)/bin/gst
+	install -m 644 gst.1 $(MANDIR)/man1/gst.1
+
+fmt-check:
+	$(RUSTFMT) --edition 2024 --check $(MAIN)
+
+clippy: $(JACKSON_LIB)
+	GST_VERSION=$(VERSION) TMPDIR=/tmp $(CLIPPY) --edition 2024 \
+		--crate-type bin --crate-name gst \
+		$(LIBRESSL_LDFLAGS) \
+		-C link-arg=-ltls \
+		-C link-arg=-lcrypto \
+		--extern jackson=$(JACKSON_LIB) \
+		-W clippy::all -o /tmp/gst.clippy $(MAIN)
+	@rm -f /tmp/gst.clippy
+
+ci: fmt-check clippy $(BIN)
blob - /dev/null
blob + cd78baf0760c4f8712d6b0f3bd15d33b85a15698 (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,82 @@
+gst - Google Shit Token
+=======================
+gst is a minimal binary to obtain and refresh Google OAuth 2.0 user
+tokens from the command line.
+
+
+Requirements
+------------
+In order to build gst you need rustc and libtls (LibreSSL).
+gst also links against libcrypto for SHA-256 (PKCE) and uses
+arc4random_buf(3) for randomness.
+
+On systems that ship LibreSSL alongside OpenSSL (e.g. Arch Linux,
+where LibreSSL lives at /usr/lib/libressl to avoid conflicts),
+the Makefile picks up the LibreSSL prefix automatically.  Set
+LIBRESSL_PREFIX to override.
+
+
+Installation
+------------
+Edit Makefile to match your local setup (gst is installed into
+the /usr/local/bin namespace by default).
+
+Afterwards enter the following command to build and install gst:
+
+    make clean install
+
+
+First run
+---------
+See SETUP.md for the one-time Google Cloud setup, the initial
+authorization flow, multiple-account and revocation workflows,
+and troubleshooting.
+
+The short version:
+
+    gst -i -c client_secret.json -f token.json \
+        https://www.googleapis.com/auth/gmail.send
+
+
+Daily use
+---------
+Print the current access token, refreshing it transparently when
+fewer than 60 seconds remain until expiry:
+
+    gst -p -f token.json
+
+The expected pattern is to call gst -p inline in scripts:
+
+    curl -H "Authorization: Bearer $(gst -p -f token.json)" \
+        https://www.googleapis.com/gmail/v1/users/me/profile
+
+Refresh the token without printing it; with -F the refresh is
+forced regardless of remaining lifetime:
+
+    gst -r -f token.json
+    gst -rF -f token.json
+
+Revoke the refresh token at the authorization server and delete
+the token file:
+
+    gst -R -f token.json
+
+Print the version:
+
+    gst -V
+
+
+PKCE
+----
+gst always uses Proof Key for Code Exchange (RFC 7636) with the
+S256 challenge method.  No flag turns it off.
+
+
+Download
+--------
+    got clone ssh://ijanc@ijanc.org/gst
+
+
+License
+-------
+ISC — see LICENSE.
blob - /dev/null
blob + bf27b63556deaf73b49e30df2de70dd7f916bf02 (mode 644)
--- /dev/null
+++ SETUP.md
@@ -0,0 +1,181 @@
+gst setup
+=========
+This document walks through registering an OAuth 2.0 client in
+Google Cloud and completing the initial authorization with gst.
+For everyday use after the token file exists, see README.md.
+
+
+Google Cloud
+------------
+Before running gst you need an OAuth 2.0 client of type "Desktop
+app" registered in Google Cloud.  This is a one-time setup per
+project.
+
+1. Create or pick a project.
+
+       https://console.cloud.google.com/projectcreate
+
+2. Enable the API you intend to use.  Open
+
+       https://console.cloud.google.com/apis/library
+
+   and enable, for example, "Gmail API", "Google Drive API",
+   "Google Calendar API", or whatever you need.  Each API has its
+   own scopes; gst itself does not care which one.
+
+3. Configure the OAuth consent screen.
+
+       https://console.cloud.google.com/auth/overview
+
+   Choose user type "External", give the app a name, set your
+   email as developer contact, and save.  While the app is in
+   "Testing" mode (the default), add your own Google account
+   under "Audience" -> "Test users", otherwise the consent screen
+   will refuse to issue tokens.
+
+4. Create the OAuth client.
+
+       https://console.cloud.google.com/auth/clients
+
+   Click "Create client", pick "Desktop app", give it a name, and
+   confirm.  Use the "Download JSON" button on the client row to
+   save the client secrets file (referred to below as
+   client_secret.json).  It looks like:
+
+       {
+         "installed": {
+           "client_id": "....apps.googleusercontent.com",
+           "client_secret": "...",
+           "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+           "token_uri": "https://oauth2.googleapis.com/token",
+           ...
+         }
+       }
+
+   Treat client_secret.json as a secret.  It is per-application,
+   not per-user; you only download it once.
+
+5. Pick the scopes for the API you enabled in step 2.  The full
+   list lives at
+
+       https://developers.google.com/identity/protocols/oauth2/scopes
+
+
+Initial authorization
+---------------------
+Run gst -i once per Google account, passing the client secrets
+file, the path where the token will be written, and the scopes to
+request:
+
+    gst -i -c client_secret.json -f token.json \
+        https://www.googleapis.com/auth/gmail.send
+
+gst will:
+
+    1. Generate a PKCE verifier and a random state token.
+    2. Bind a TCP listener on 127.0.0.1 on a random port.
+    3. Print the authorization URL on standard error and wait
+       for the OAuth callback.
+
+Open the printed URL in a browser, sign in with the Google
+account that was added as a test user, and approve the requested
+scopes.  The browser is redirected to the loopback listener; gst
+exchanges the authorization code for an access and refresh token,
+writes the result to token.json with mode 0600, and exits.
+
+You only do this once.  As long as token.json is intact and the
+refresh token is not revoked, gst -p and gst -r take over for the
+lifetime of the application.
+
+
+Multiple accounts
+-----------------
+gst keeps no global state and reads no configuration files; the
+account is whatever -f points at.  To use several accounts (or
+several scope sets), give each one its own token file:
+
+    gst -i -c client_secret.json -f gmail.json \
+        https://www.googleapis.com/auth/gmail.send
+    gst -i -c client_secret.json -f drive.json \
+        https://www.googleapis.com/auth/drive.readonly
+
+    gst -p -f gmail.json
+    gst -p -f drive.json
+
+The same client_secret.json can be reused; only the token file
+differs per account or per scope set.
+
+
+Revocation
+----------
+There are two ways to revoke a grant.
+
+1. With gst.  This is the normal path: it POSTs the refresh token
+   to Google's revocation endpoint and removes the local token
+   file in one step.
+
+       gst -R -f token.json
+
+   After this the refresh token is dead at the server; gst -p and
+   gst -r will fail until you run gst -i again.  This is what you
+   want when retiring an account, rotating credentials, or wiping
+   a machine.
+
+2. From the Google account permissions page.  Use this when you
+   no longer have the token file (lost, deleted, on a different
+   host) or when gst -R cannot reach the network.
+
+       https://myaccount.google.com/permissions
+
+   Find the application by name (whatever you set in step 3 of
+   the Google Cloud setup), open it, and choose "Remove access".
+   This invalidates every refresh token that was ever issued to
+   that account+app combination, including ones held on other
+   machines.
+
+You also need step 2 to recover from "token endpoint returned no
+refresh_token": Google only issues a new refresh token on the
+first consent for a given account+client.  If a previous grant is
+still active server-side, a fresh gst -i call gets only an access
+token.  Revoke at the permissions page, then run gst -i again.
+
+Revocation is irreversible.  The next gst -i triggers the consent
+screen anew and any other process that was holding an access or
+refresh token from the same grant starts failing immediately.
+
+
+Token file
+----------
+The token file is a JSON document with the following fields:
+
+    access_token, refresh_token, token_uri, client_id,
+    client_secret, scope, expires_at
+
+expires_at is a Unix timestamp in seconds.  The file is written
+with mode 0600.  Do not commit it to source control.
+
+
+Troubleshooting
+---------------
+"Error 403: access_denied" on the consent screen
+    The Google account you signed in with is not on the test
+    user list.  Add it under "OAuth consent screen" -> "Test
+    users".
+
+"token endpoint returned no refresh_token"
+    See "Revocation" above; revoke the existing grant at
+    https://myaccount.google.com/permissions, then re-run gst -i.
+
+"tls_handshake: ..." or "connect ...: ..."
+    Network or TLS failure reaching accounts.google.com or
+    oauth2.googleapis.com.  Check the system clock; certificate
+    validation will fail if the clock is wrong.
+
+"state mismatch in callback"
+    The browser was redirected from a different gst -i run, or
+    something tampered with the redirect.  Re-run gst -i.
+
+"tls_read: ... unexpected eof while reading"
+    Linking against the libretls shim over OpenSSL 3 instead of
+    LibreSSL.  Build with LIBRESSL_PREFIX pointing at a real
+    LibreSSL install (see Makefile).
blob - /dev/null
blob + 6ea28da9c896479b809ef6aa53622c3119a56656 (mode 644)
--- /dev/null
+++ gst.1
@@ -0,0 +1,227 @@
+.\"
+.\" Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+.\"
+.\" Permission to use, copy, modify, and/or distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd $Mdocdate: April 19 2026 $
+.Dt GST 1
+.Os
+.Sh NAME
+.Nm gst
+.Nd obtain and refresh Google OAuth 2.0 tokens
+.Sh SYNOPSIS
+.Nm gst
+.Fl V
+.Nm gst
+.Fl h
+.Nm gst
+.Fl i
+.Fl c Ar client_secret
+.Fl f Ar token
+.Ar scope ...
+.Nm gst
+.Fl p
+.Fl f Ar token
+.Nm gst
+.Fl r
+.Op Fl F
+.Fl f Ar token
+.Nm gst
+.Fl R
+.Fl f Ar token
+.Sh DESCRIPTION
+.Nm
+runs the OAuth 2.0 authorization code flow against Google's
+authorization server, persists the resulting access and refresh
+tokens to
+.Ar token ,
+and prints a fresh access token on demand.
+The flow is the
+.Dq Installed Application
+variant: a loopback HTTP listener on
+.Li 127.0.0.1
+receives the authorization callback and PKCE
+.Pq RFC 7636, S256
+is always enabled.
+.Pp
+Exactly one mode flag must be given.
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl V
+Print the version and exit.
+.It Fl h
+Print a usage summary and exit.
+.It Fl i
+Init mode.
+Run the authorization flow with the credentials in
+.Ar client_secret
+and the listed
+.Ar scope
+arguments, then write the resulting tokens to
+.Ar token .
+.It Fl p
+Print mode.
+Read
+.Ar token ,
+refresh the access token if fewer than 60 seconds remain until
+expiry, and write the access token to standard output.
+.It Fl r
+Refresh mode.
+Refresh the access token in
+.Ar token
+if expiry is within 60 seconds, then exit.
+.It Fl R
+Revoke mode.
+Revoke the refresh token at
+.Li https://oauth2.googleapis.com/revoke
+and delete
+.Ar token .
+.It Fl F
+With
+.Fl r ,
+force the refresh even when the current access token is still
+valid.
+.It Fl c Ar client_secret
+Path to the client secrets JSON file downloaded from the Google
+Cloud Console.
+The file must contain an
+.Dq installed
+or
+.Dq web
+section with
+.Li client_id ,
+.Li client_secret ,
+.Li auth_uri ,
+and
+.Li token_uri .
+Required with
+.Fl i .
+.It Fl f Ar token
+Path to the persisted token file.
+Required with
+.Fl i ,
+.Fl p ,
+.Fl r ,
+and
+.Fl R .
+Created with mode 0600 by
+.Fl i
+and rewritten in place by
+.Fl r
+and
+.Fl p .
+.El
+.Sh AUTHORIZATION FLOW
+With
+.Fl i ,
+.Nm
+generates a random state token and a PKCE code verifier, binds a
+TCP listener on
+.Li 127.0.0.1
+on a kernel-assigned port, and prints the authorization URL on
+standard error.
+The user opens the URL in a browser and grants consent.
+The browser is then redirected to the loopback listener with the
+authorization code in the query string.
+.Nm
+verifies the state token, exchanges the code for an access and
+refresh token at the token endpoint, and writes the result to
+.Ar token .
+.Pp
+The
+.Li access_type=offline
+and
+.Li prompt=consent
+parameters are sent so that Google issues a refresh token.
+.Sh TOKEN FILE
+The token file is a single JSON object with the following fields:
+.Bl -tag -width "client_secret"
+.It Li access_token
+Bearer token to send in the
+.Li Authorization
+header.
+.It Li refresh_token
+Long-lived token used by
+.Fl r
+and
+.Fl p
+to mint new access tokens.
+.It Li token_uri
+Token endpoint URI, copied from the client secrets file.
+.It Li client_id
+OAuth client identifier.
+.It Li client_secret
+OAuth client secret.
+.It Li scope
+Space-separated list of granted scopes.
+.It Li expires_at
+Unix timestamp, in seconds, at which
+.Li access_token
+expires.
+.El
+.Pp
+The file is written with mode 0600 and is overwritten in place
+on every refresh.
+.Sh EXIT STATUS
+.Nm
+exits 0 on success and non-zero on failure.
+A diagnostic message is written to standard error.
+.Bl -tag -width Ds
+.It 0
+Success.
+.It 1
+Usage error.
+.It 2
+Runtime failure
+.Pq I/O, TLS, HTTP, or token endpoint error .
+.El
+.Sh EXAMPLES
+Run the initial authorization for the Gmail send scope:
+.Bd -literal -offset indent
+$ gst -i -c client_secret.json -f token.json \e
+    https://www.googleapis.com/auth/gmail.send
+.Ed
+.Pp
+Print a fresh access token, refreshing if needed:
+.Bd -literal -offset indent
+$ gst -p -f token.json
+.Ed
+.Pp
+Use the token in a request:
+.Bd -literal -offset indent
+$ curl -H "Authorization: Bearer $(gst -p -f token.json)" \e
+    https://www.googleapis.com/gmail/v1/users/me/profile
+.Ed
+.Pp
+Force a refresh even if the current token is still valid:
+.Bd -literal -offset indent
+$ gst -rF -f token.json
+.Ed
+.Pp
+Revoke the refresh token and delete the token file:
+.Bd -literal -offset indent
+$ gst -R -f token.json
+.Ed
+.Sh SEE ALSO
+.Xr arc4random_buf 3 ,
+.Xr SHA256 3 ,
+.Xr tls_init 3
+.Sh STANDARDS
+.Bl -tag -width Ds
+.It RFC 6749
+The OAuth 2.0 Authorization Framework.
+.It RFC 7636
+Proof Key for Code Exchange by OAuth Public Clients.
+.El
+.Sh AUTHORS
+.An Murilo Ijanc' Aq Mt murilo@ijanc.org
blob - /dev/null
blob + 6aa92d72a3da8353e39a6cb1c9759d12104e3fe0 (mode 644)
--- /dev/null
+++ gst.rs
@@ -0,0 +1,1080 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+use std::{
+    env,
+    ffi::{CStr, CString},
+    fmt::Write as _,
+    fs::{self, OpenOptions},
+    io::{Read, Write as IoWrite},
+    net::{TcpListener, TcpStream},
+    num::ParseIntError,
+    os::{fd::AsRawFd, raw::c_void, unix::fs::OpenOptionsExt},
+    path::Path,
+    process,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use jackson::{FromJson, ToJson, Value, json_struct};
+
+const REFRESH_LEAD_SECS: i64 = 60;
+const TIMEOUT_SECS: u64 = 30;
+const LOOPBACK_TIMEOUT_SECS: u64 = 300;
+const REVOKE_URL: &str = "https://oauth2.googleapis.com/revoke";
+const SUCCESS_BODY: &str =
+    "Authorization complete. You may close this window.\n";
+
+//////////////////////////////////////////////////////////////////////////////
+// Types
+//////////////////////////////////////////////////////////////////////////////
+
+json_struct! {
+    struct Token {
+        access_token: String,
+        refresh_token: String,
+        token_uri: String,
+        client_id: String,
+        client_secret: String,
+        scope: String,
+        expires_at: i64,
+    }
+}
+
+json_struct! {
+    struct ClientSecretInner {
+        client_id: String,
+        client_secret: String,
+        auth_uri: String,
+        token_uri: String,
+    }
+}
+
+json_struct! {
+    struct ClientSecretFile {
+        installed: Option<ClientSecretInner>,
+        web: Option<ClientSecretInner>,
+    }
+}
+
+json_struct! {
+    struct TokenResponse {
+        access_token: String,
+        expires_in: i64,
+        refresh_token: Option<String>,
+        scope: Option<String>,
+        token_type: Option<String>,
+        id_token: Option<String>,
+    }
+}
+
+json_struct! {
+    struct TokenError {
+        error: String,
+        error_description: Option<String>,
+    }
+}
+
+#[derive(Copy, Clone, PartialEq)]
+enum Mode {
+    Help,
+    Version,
+    Init,
+    Refresh,
+    Print,
+    Revoke,
+}
+
+struct Opts {
+    mode: Option<Mode>,
+    client_secret: Option<String>,
+    token_file: Option<String>,
+    force: bool,
+    scopes: Vec<String>,
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Entry
+//////////////////////////////////////////////////////////////////////////////
+
+fn main() {
+    process::exit(run());
+}
+
+fn run() -> i32 {
+    let args: Vec<String> = env::args().collect();
+    let opts = match parse_args(&args[1..]) {
+        Ok(o) => o,
+        Err(e) => {
+            eprintln!("gst: {e}");
+            usage();
+            return 1;
+        }
+    };
+    let mode = match opts.mode {
+        Some(m) => m,
+        None => {
+            usage();
+            return 1;
+        }
+    };
+    let r = match mode {
+        Mode::Help => {
+            usage();
+            Ok(())
+        }
+        Mode::Version => {
+            println!("gst {}", env!("GST_VERSION"));
+            Ok(())
+        }
+        Mode::Init => init_cmd(&opts),
+        Mode::Refresh => refresh_cmd(&opts),
+        Mode::Print => print_cmd(&opts),
+        Mode::Revoke => revoke_cmd(&opts),
+    };
+    match r {
+        Ok(()) => 0,
+        Err(e) => {
+            eprintln!("gst: {e}");
+            2
+        }
+    }
+}
+
+fn usage() {
+    eprintln!("usage: gst -V");
+    eprintln!("       gst -h");
+    eprintln!("       gst -i -c file -f file scope ...");
+    eprintln!("       gst -p -f file");
+    eprintln!("       gst -r [-F] -f file");
+    eprintln!("       gst -R -f file");
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Argument parsing
+//////////////////////////////////////////////////////////////////////////////
+
+fn parse_args(args: &[String]) -> Result<Opts, String> {
+    let mut opts = Opts {
+        mode: None,
+        client_secret: None,
+        token_file: None,
+        force: false,
+        scopes: Vec::new(),
+    };
+    let mut i = 0;
+    while i < args.len() {
+        let arg = &args[i];
+        if arg == "--" {
+            i += 1;
+            opts.scopes.extend_from_slice(&args[i..]);
+            return Ok(opts);
+        }
+        if !arg.starts_with('-') || arg.len() < 2 || arg == "-" {
+            opts.scopes.extend_from_slice(&args[i..]);
+            return Ok(opts);
+        }
+        let chars: Vec<char> = arg[1..].chars().collect();
+        let mut ci = 0;
+        while ci < chars.len() {
+            let c = chars[ci];
+            match c {
+                'V' => set_mode(&mut opts.mode, Mode::Version)?,
+                'h' => set_mode(&mut opts.mode, Mode::Help)?,
+                'i' => set_mode(&mut opts.mode, Mode::Init)?,
+                'r' => set_mode(&mut opts.mode, Mode::Refresh)?,
+                'p' => set_mode(&mut opts.mode, Mode::Print)?,
+                'R' => set_mode(&mut opts.mode, Mode::Revoke)?,
+                'F' => opts.force = true,
+                'c' | 'f' => {
+                    let val = if ci + 1 < chars.len() {
+                        let v: String = chars[ci + 1..].iter().collect();
+                        ci = chars.len();
+                        v
+                    } else {
+                        i += 1;
+                        if i >= args.len() {
+                            return Err(format!(
+                                "option -{c} requires argument"
+                            ));
+                        }
+                        args[i].clone()
+                    };
+                    if c == 'c' {
+                        opts.client_secret = Some(val);
+                    } else {
+                        opts.token_file = Some(val);
+                    }
+                }
+                _ => return Err(format!("unknown option: -{c}")),
+            }
+            ci += 1;
+        }
+        i += 1;
+    }
+    Ok(opts)
+}
+
+fn set_mode(slot: &mut Option<Mode>, m: Mode) -> Result<(), String> {
+    if slot.is_some() {
+        return Err("conflicting mode flags".into());
+    }
+    *slot = Some(m);
+    Ok(())
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Commands
+//////////////////////////////////////////////////////////////////////////////
+
+fn init_cmd(opts: &Opts) -> Result<(), String> {
+    let cs_path = opts
+        .client_secret
+        .as_deref()
+        .ok_or("init: missing -c <client_secret.json>")?;
+    let token_path = opts
+        .token_file
+        .as_deref()
+        .ok_or("init: missing -f <token.json>")?;
+    if opts.scopes.is_empty() {
+        return Err("init: at least one scope is required".into());
+    }
+
+    let cs = load_client_secret(cs_path)?;
+    let scopes = opts.scopes.join(" ");
+
+    let listener = TcpListener::bind("127.0.0.1:0")
+        .map_err(|e| format!("bind 127.0.0.1: {e}"))?;
+    let port = listener
+        .local_addr()
+        .map_err(|e| format!("local_addr: {e}"))?
+        .port();
+    let redirect_uri = format!("http://127.0.0.1:{port}/");
+
+    let (verifier, challenge) = generate_pkce()?;
+    let mut state_bytes = [0u8; 16];
+    random_bytes(&mut state_bytes);
+    let state = base64url_no_pad(&state_bytes);
+
+    let auth_url = build_auth_url(
+        &cs.auth_uri,
+        &cs.client_id,
+        &redirect_uri,
+        &scopes,
+        &state,
+        &challenge,
+    );
+
+    eprintln!("Open this URL in a browser:");
+    eprintln!();
+    eprintln!("  {auth_url}");
+    eprintln!();
+    eprintln!("Waiting for callback on {redirect_uri} ...");
+
+    let code = wait_for_code(&listener, &state)?;
+
+    let body = url_form(&[
+        ("grant_type", "authorization_code"),
+        ("client_id", &cs.client_id),
+        ("client_secret", &cs.client_secret),
+        ("code", &code),
+        ("code_verifier", &verifier),
+        ("redirect_uri", &redirect_uri),
+    ]);
+    let resp = https_post_form(&cs.token_uri, &body)?;
+    let tr = parse_token_response(&resp)?;
+
+    let refresh_token = tr.refresh_token.ok_or(
+        "token endpoint returned no refresh_token \
+         (the user may have already granted access; revoke and retry)",
+    )?;
+    let token = Token {
+        access_token: tr.access_token,
+        refresh_token,
+        token_uri: cs.token_uri,
+        client_id: cs.client_id,
+        client_secret: cs.client_secret,
+        scope: tr.scope.unwrap_or(scopes),
+        expires_at: now_unix() + tr.expires_in,
+    };
+    save_token(token_path, &token)?;
+    eprintln!("ok: token written to {token_path}");
+    Ok(())
+}
+
+fn refresh_cmd(opts: &Opts) -> Result<(), String> {
+    let token_path = opts
+        .token_file
+        .as_deref()
+        .ok_or("refresh: missing -f <token.json>")?;
+    let mut token = load_token(token_path)?;
+    if !opts.force && token.expires_at - now_unix() > REFRESH_LEAD_SECS {
+        return Ok(());
+    }
+    do_refresh(&mut token)?;
+    save_token(token_path, &token)?;
+    Ok(())
+}
+
+fn print_cmd(opts: &Opts) -> Result<(), String> {
+    let token_path = opts
+        .token_file
+        .as_deref()
+        .ok_or("print: missing -f <token.json>")?;
+    let mut token = load_token(token_path)?;
+    if token.expires_at - now_unix() <= REFRESH_LEAD_SECS {
+        do_refresh(&mut token)?;
+        save_token(token_path, &token)?;
+    }
+    println!("{}", token.access_token);
+    Ok(())
+}
+
+fn revoke_cmd(opts: &Opts) -> Result<(), String> {
+    let token_path = opts
+        .token_file
+        .as_deref()
+        .ok_or("revoke: missing -f <token.json>")?;
+    let token = load_token(token_path)?;
+    let url = format!(
+        "{REVOKE_URL}?token={}",
+        percent_encode(&token.refresh_token)
+    );
+    let resp = https_get(&url)?;
+    if resp.status >= 400 {
+        return Err(format!(
+            "revoke: HTTP {} {}",
+            resp.status,
+            resp.body.trim()
+        ));
+    }
+    fs::remove_file(token_path)
+        .map_err(|e| format!("remove {token_path}: {e}"))?;
+    Ok(())
+}
+
+fn do_refresh(token: &mut Token) -> Result<(), String> {
+    let body = url_form(&[
+        ("grant_type", "refresh_token"),
+        ("client_id", &token.client_id),
+        ("client_secret", &token.client_secret),
+        ("refresh_token", &token.refresh_token),
+    ]);
+    let resp = https_post_form(&token.token_uri, &body)?;
+    let tr = parse_token_response(&resp)?;
+    token.access_token = tr.access_token;
+    token.expires_at = now_unix() + tr.expires_in;
+    if let Some(rt) = tr.refresh_token {
+        token.refresh_token = rt;
+    }
+    if let Some(s) = tr.scope {
+        token.scope = s;
+    }
+    Ok(())
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Persistence
+//////////////////////////////////////////////////////////////////////////////
+
+fn load_client_secret(path: &str) -> Result<ClientSecretInner, String> {
+    let s =
+        fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?;
+    let v: Value = s
+        .parse()
+        .map_err(|e: jackson::Error| format!("parse {path}: {e}"))?;
+    let f = ClientSecretFile::from_json(&v)
+        .map_err(|e| format!("decode {path}: {e}"))?;
+    f.installed
+        .or(f.web)
+        .ok_or_else(|| format!("{path}: missing 'installed' or 'web' section"))
+}
+
+fn load_token(path: &str) -> Result<Token, String> {
+    let s =
+        fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?;
+    let v: Value = s
+        .parse()
+        .map_err(|e: jackson::Error| format!("parse {path}: {e}"))?;
+    Token::from_json(&v).map_err(|e| format!("decode {path}: {e}"))
+}
+
+fn save_token(path: &str, token: &Token) -> Result<(), String> {
+    let s = token
+        .to_json()
+        .stringify()
+        .map_err(|e| format!("encode token: {e}"))?;
+    if let Some(parent) = Path::new(path).parent()
+        && !parent.as_os_str().is_empty()
+    {
+        fs::create_dir_all(parent)
+            .map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
+    }
+    let mut f = OpenOptions::new()
+        .write(true)
+        .create(true)
+        .truncate(true)
+        .mode(0o600)
+        .open(path)
+        .map_err(|e| format!("open {path}: {e}"))?;
+    f.write_all(s.as_bytes())
+        .map_err(|e| format!("write {path}: {e}"))?;
+    f.write_all(b"\n").ok();
+    Ok(())
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// OAuth2 helpers
+//////////////////////////////////////////////////////////////////////////////
+
+fn build_auth_url(
+    auth_uri: &str,
+    client_id: &str,
+    redirect_uri: &str,
+    scopes: &str,
+    state: &str,
+    code_challenge: &str,
+) -> String {
+    let q = url_form(&[
+        ("response_type", "code"),
+        ("client_id", client_id),
+        ("redirect_uri", redirect_uri),
+        ("scope", scopes),
+        ("state", state),
+        ("access_type", "offline"),
+        ("prompt", "consent"),
+        ("code_challenge", code_challenge),
+        ("code_challenge_method", "S256"),
+    ]);
+    let sep = if auth_uri.contains('?') { '&' } else { '?' };
+    format!("{auth_uri}{sep}{q}")
+}
+
+fn parse_token_response(resp: &HttpResponse) -> Result<TokenResponse, String> {
+    let v: Value = resp
+        .body
+        .parse()
+        .map_err(|e: jackson::Error| format!("json: {e}"))?;
+    if resp.status >= 400 {
+        let te = TokenError::from_json(&v)
+            .map_err(|e| format!("decode error response: {e}"))?;
+        let mut m = format!("token endpoint: {}", te.error);
+        if let Some(d) = te.error_description {
+            let _ = write!(m, " ({d})");
+        }
+        return Err(m);
+    }
+    TokenResponse::from_json(&v)
+        .map_err(|e| format!("decode token response: {e}"))
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Loopback callback server
+//////////////////////////////////////////////////////////////////////////////
+
+fn wait_for_code(
+    listener: &TcpListener,
+    expected_state: &str,
+) -> Result<String, String> {
+    listener
+        .set_nonblocking(false)
+        .map_err(|e| format!("set_nonblocking: {e}"))?;
+    loop {
+        let (mut stream, _) =
+            listener.accept().map_err(|e| format!("accept: {e}"))?;
+        stream
+            .set_read_timeout(Some(Duration::from_secs(LOOPBACK_TIMEOUT_SECS)))
+            .ok();
+        stream
+            .set_write_timeout(Some(Duration::from_secs(TIMEOUT_SECS)))
+            .ok();
+        let req = match read_http_request(&mut stream) {
+            Ok(r) => r,
+            Err(_) => continue,
+        };
+        let path = match http_request_path(&req) {
+            Some(p) => p,
+            None => {
+                respond_text(&mut stream, 400, "bad request\n");
+                continue;
+            }
+        };
+        // Browsers fetch /favicon.ico, and may pre-fetch other paths;
+        // ignore anything that is not the redirect.
+        let query = match path.split_once('?') {
+            Some((_, q)) => q,
+            None => {
+                respond_text(&mut stream, 404, "not found\n");
+                continue;
+            }
+        };
+        let mut code: Option<String> = None;
+        let mut state: Option<String> = None;
+        let mut err: Option<String> = None;
+        for pair in query.split('&') {
+            if pair.is_empty() {
+                continue;
+            }
+            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
+            let v = percent_decode(v);
+            match k {
+                "code" => code = Some(v),
+                "state" => state = Some(v),
+                "error" => err = Some(v),
+                _ => {}
+            }
+        }
+        if let Some(e) = err {
+            respond_text(&mut stream, 200, "Authorization failed.\n");
+            return Err(format!("authorization error: {e}"));
+        }
+        let (Some(code), Some(state)) = (code, state) else {
+            respond_text(&mut stream, 400, "missing code or state\n");
+            continue;
+        };
+        if state != expected_state {
+            respond_text(&mut stream, 400, "state mismatch\n");
+            return Err("state mismatch in callback".into());
+        }
+        respond_text(&mut stream, 200, SUCCESS_BODY);
+        return Ok(code);
+    }
+}
+
+fn read_http_request(stream: &mut TcpStream) -> Result<Vec<u8>, String> {
+    let mut buf = Vec::with_capacity(2048);
+    let mut tmp = [0u8; 1024];
+    loop {
+        let n = stream.read(&mut tmp).map_err(|e| format!("read: {e}"))?;
+        if n == 0 {
+            break;
+        }
+        buf.extend_from_slice(&tmp[..n]);
+        if find(&buf, b"\r\n\r\n").is_some() {
+            break;
+        }
+        if buf.len() > 16 * 1024 {
+            return Err("request too large".into());
+        }
+    }
+    Ok(buf)
+}
+
+fn http_request_path(req: &[u8]) -> Option<&str> {
+    let end = find(req, b"\r\n")?;
+    let line = std::str::from_utf8(&req[..end]).ok()?;
+    line.split_whitespace().nth(1)
+}
+
+fn respond_text(stream: &mut TcpStream, status: u16, body: &str) {
+    let reason = match status {
+        200 => "OK",
+        400 => "Bad Request",
+        404 => "Not Found",
+        _ => "OK",
+    };
+    let resp = format!(
+        "HTTP/1.1 {status} {reason}\r\n\
+         Content-Type: text/plain; charset=utf-8\r\n\
+         Content-Length: {}\r\n\
+         Connection: close\r\n\
+         \r\n\
+         {body}",
+        body.len(),
+    );
+    let _ = stream.write_all(resp.as_bytes());
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// HTTPS client
+//////////////////////////////////////////////////////////////////////////////
+
+struct HttpResponse {
+    status: u16,
+    body: String,
+}
+
+fn https_post_form(url: &str, body: &str) -> Result<HttpResponse, String> {
+    let (host, port, path) = parse_https_url(url)?;
+    let conn = Conn::connect(host, port)?;
+    let req = format!(
+        "POST {path} HTTP/1.1\r\n\
+         Host: {host}\r\n\
+         Content-Type: application/x-www-form-urlencoded\r\n\
+         Content-Length: {len}\r\n\
+         Connection: close\r\n\
+         User-Agent: gst/{ver}\r\n\
+         Accept: application/json\r\n\
+         \r\n\
+         {body}",
+        ver = env!("GST_VERSION"),
+        len = body.len(),
+    );
+    conn.write_all(req.as_bytes())?;
+    parse_http_response(&conn.read_to_end()?)
+}
+
+fn https_get(url: &str) -> Result<HttpResponse, String> {
+    let (host, port, path) = parse_https_url(url)?;
+    let conn = Conn::connect(host, port)?;
+    let req = format!(
+        "GET {path} HTTP/1.1\r\n\
+         Host: {host}\r\n\
+         Connection: close\r\n\
+         User-Agent: gst/{ver}\r\n\
+         Accept: application/json\r\n\
+         \r\n",
+        ver = env!("GST_VERSION"),
+    );
+    conn.write_all(req.as_bytes())?;
+    parse_http_response(&conn.read_to_end()?)
+}
+
+fn parse_http_response(raw: &[u8]) -> Result<HttpResponse, String> {
+    let sep = find(raw, b"\r\n\r\n").ok_or("no header terminator")?;
+    let head = std::str::from_utf8(&raw[..sep])
+        .map_err(|e| format!("non-utf8 headers: {e}"))?;
+    let mut lines = head.lines();
+    let line = lines.next().ok_or("empty response")?;
+    let mut parts = line.split_whitespace();
+    parts.next().ok_or("malformed status line")?;
+    let status: u16 = parts
+        .next()
+        .ok_or("missing status code")?
+        .parse()
+        .map_err(|e: ParseIntError| e.to_string())?;
+
+    let mut chunked = false;
+    let mut content_length: Option<usize> = None;
+    for line in lines {
+        if let Some(v) = header_value(line, "transfer-encoding")
+            && v.eq_ignore_ascii_case("chunked")
+        {
+            chunked = true;
+        }
+        if let Some(v) = header_value(line, "content-length") {
+            content_length = v.parse().ok();
+        }
+    }
+
+    let raw_body = &raw[sep + 4..];
+    let body_bytes = if chunked {
+        decode_chunked(raw_body)?
+    } else if let Some(cl) = content_length {
+        if raw_body.len() < cl {
+            return Err(format!(
+                "short body: got {} of {}",
+                raw_body.len(),
+                cl
+            ));
+        }
+        raw_body[..cl].to_vec()
+    } else {
+        raw_body.to_vec()
+    };
+    let body = String::from_utf8(body_bytes)
+        .map_err(|e| format!("non-utf8 body: {e}"))?;
+    Ok(HttpResponse { status, body })
+}
+
+fn header_value<'a>(line: &'a str, name: &str) -> Option<&'a str> {
+    let (k, v) = line.split_once(':')?;
+    if k.trim().eq_ignore_ascii_case(name) {
+        Some(v.trim())
+    } else {
+        None
+    }
+}
+
+fn decode_chunked(body: &[u8]) -> Result<Vec<u8>, String> {
+    let mut out = Vec::with_capacity(body.len());
+    let mut i = 0;
+    loop {
+        let line_end = i + find(&body[i..], b"\r\n")
+            .ok_or("chunked: no CRLF after size")?;
+        let size_line = std::str::from_utf8(&body[i..line_end])
+            .map_err(|e| format!("chunked: bad size line: {e}"))?;
+        let size_str = size_line.split(';').next().unwrap_or("").trim();
+        let size = usize::from_str_radix(size_str, 16)
+            .map_err(|e| format!("chunked: bad size {size_str:?}: {e}"))?;
+        i = line_end + 2;
+        if size == 0 {
+            return Ok(out);
+        }
+        if i + size > body.len() {
+            return Err("chunked: short chunk".into());
+        }
+        out.extend_from_slice(&body[i..i + size]);
+        i += size;
+        if body.get(i..i + 2) != Some(b"\r\n") {
+            return Err("chunked: missing CRLF after chunk".into());
+        }
+        i += 2;
+    }
+}
+
+fn parse_https_url(url: &str) -> Result<(&str, u16, &str), String> {
+    let u = url.strip_prefix("https://").ok_or("not an https URL")?;
+    let (authority, path) = match u.find('/') {
+        Some(i) => (&u[..i], &u[i..]),
+        None => (u, "/"),
+    };
+    let (host, port) = match authority.split_once(':') {
+        Some((h, p)) => (
+            h,
+            p.parse()
+                .map_err(|e: ParseIntError| format!("bad port: {e}"))?,
+        ),
+        None => (authority, 443u16),
+    };
+    Ok((host, port, path))
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// libtls connection
+//////////////////////////////////////////////////////////////////////////////
+
+struct Conn {
+    ctx: *mut ffi::Tls,
+    cfg: *mut ffi::TlsConfig,
+    _sock: TcpStream,
+}
+
+impl Conn {
+    fn connect(host: &str, port: u16) -> Result<Self, String> {
+        unsafe {
+            if ffi::tls_init() != 0 {
+                return Err("tls_init failed".into());
+            }
+            let cfg = ffi::tls_config_new();
+            if cfg.is_null() {
+                return Err("tls_config_new failed".into());
+            }
+            let ctx = ffi::tls_client();
+            if ctx.is_null() {
+                ffi::tls_config_free(cfg);
+                return Err("tls_client failed".into());
+            }
+            if ffi::tls_configure(ctx, cfg) != 0 {
+                let e = tls_err(ctx);
+                ffi::tls_free(ctx);
+                ffi::tls_config_free(cfg);
+                return Err(format!("tls_configure: {e}"));
+            }
+            let sock = TcpStream::connect((host, port))
+                .map_err(|e| format!("connect {host}:{port}: {e}"))?;
+            let _ =
+                sock.set_read_timeout(Some(Duration::from_secs(TIMEOUT_SECS)));
+            let _ =
+                sock.set_write_timeout(Some(Duration::from_secs(TIMEOUT_SECS)));
+            let chost = CString::new(host).map_err(|e| e.to_string())?;
+            if ffi::tls_connect_socket(ctx, sock.as_raw_fd(), chost.as_ptr())
+                != 0
+            {
+                let e = tls_err(ctx);
+                ffi::tls_free(ctx);
+                ffi::tls_config_free(cfg);
+                return Err(format!("tls_connect_socket: {e}"));
+            }
+            loop {
+                let r = ffi::tls_handshake(ctx) as isize;
+                if r == 0 {
+                    break;
+                }
+                if r == ffi::TLS_WANT_POLLIN || r == ffi::TLS_WANT_POLLOUT {
+                    continue;
+                }
+                let e = tls_err(ctx);
+                ffi::tls_free(ctx);
+                ffi::tls_config_free(cfg);
+                return Err(format!("tls_handshake: {e}"));
+            }
+            Ok(Self {
+                ctx,
+                cfg,
+                _sock: sock,
+            })
+        }
+    }
+
+    fn write_all(&self, mut buf: &[u8]) -> Result<(), String> {
+        unsafe {
+            while !buf.is_empty() {
+                let r = ffi::tls_write(
+                    self.ctx,
+                    buf.as_ptr() as *const c_void,
+                    buf.len(),
+                );
+                if r == ffi::TLS_WANT_POLLIN || r == ffi::TLS_WANT_POLLOUT {
+                    continue;
+                }
+                if r < 0 {
+                    return Err(format!("tls_write: {}", tls_err(self.ctx)));
+                }
+                buf = &buf[r as usize..];
+            }
+            Ok(())
+        }
+    }
+
+    fn read_to_end(&self) -> Result<Vec<u8>, String> {
+        let mut out = Vec::with_capacity(4096);
+        let mut tmp = [0u8; 4096];
+        unsafe {
+            loop {
+                let r = ffi::tls_read(
+                    self.ctx,
+                    tmp.as_mut_ptr() as *mut c_void,
+                    tmp.len(),
+                );
+                if r == ffi::TLS_WANT_POLLIN || r == ffi::TLS_WANT_POLLOUT {
+                    continue;
+                }
+                if r < 0 {
+                    return Err(format!("tls_read: {}", tls_err(self.ctx)));
+                }
+                if r == 0 {
+                    break;
+                }
+                out.extend_from_slice(&tmp[..r as usize]);
+            }
+        }
+        Ok(out)
+    }
+}
+
+impl Drop for Conn {
+    fn drop(&mut self) {
+        unsafe {
+            if !self.ctx.is_null() {
+                loop {
+                    let r = ffi::tls_close(self.ctx) as isize;
+                    if r == 0
+                        || (r != ffi::TLS_WANT_POLLIN
+                            && r != ffi::TLS_WANT_POLLOUT)
+                    {
+                        break;
+                    }
+                }
+                ffi::tls_free(self.ctx);
+            }
+            if !self.cfg.is_null() {
+                ffi::tls_config_free(self.cfg);
+            }
+        }
+    }
+}
+
+unsafe fn tls_err(ctx: *mut ffi::Tls) -> String {
+    unsafe {
+        let p = ffi::tls_error(ctx);
+        if p.is_null() {
+            "(unknown)".into()
+        } else {
+            CStr::from_ptr(p).to_string_lossy().into_owned()
+        }
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// FFI
+//////////////////////////////////////////////////////////////////////////////
+
+mod ffi {
+    use std::os::raw::{c_char, c_int, c_void};
+
+    #[repr(C)]
+    pub struct Tls {
+        _p: [u8; 0],
+    }
+    #[repr(C)]
+    pub struct TlsConfig {
+        _p: [u8; 0],
+    }
+
+    pub const TLS_WANT_POLLIN: isize = -2;
+    pub const TLS_WANT_POLLOUT: isize = -3;
+
+    #[link(name = "tls")]
+    unsafe extern "C" {
+        pub fn tls_init() -> c_int;
+        pub fn tls_config_new() -> *mut TlsConfig;
+        pub fn tls_config_free(c: *mut TlsConfig);
+        pub fn tls_client() -> *mut Tls;
+        pub fn tls_configure(ctx: *mut Tls, cfg: *mut TlsConfig) -> c_int;
+        pub fn tls_connect_socket(
+            ctx: *mut Tls,
+            fd: c_int,
+            servername: *const c_char,
+        ) -> c_int;
+        pub fn tls_handshake(ctx: *mut Tls) -> c_int;
+        pub fn tls_write(ctx: *mut Tls, buf: *const c_void, n: usize) -> isize;
+        pub fn tls_read(ctx: *mut Tls, buf: *mut c_void, n: usize) -> isize;
+        pub fn tls_close(ctx: *mut Tls) -> c_int;
+        pub fn tls_free(ctx: *mut Tls);
+        pub fn tls_error(ctx: *mut Tls) -> *const c_char;
+    }
+
+    #[link(name = "crypto")]
+    unsafe extern "C" {
+        pub fn SHA256(d: *const u8, n: usize, md: *mut u8) -> *mut u8;
+    }
+
+    unsafe extern "C" {
+        pub fn arc4random_buf(buf: *mut c_void, n: usize);
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// PKCE / random / hashing
+//////////////////////////////////////////////////////////////////////////////
+
+fn random_bytes(buf: &mut [u8]) {
+    unsafe {
+        ffi::arc4random_buf(buf.as_mut_ptr() as *mut c_void, buf.len());
+    }
+}
+
+fn sha256(data: &[u8]) -> [u8; 32] {
+    let mut out = [0u8; 32];
+    unsafe {
+        ffi::SHA256(data.as_ptr(), data.len(), out.as_mut_ptr());
+    }
+    out
+}
+
+fn generate_pkce() -> Result<(String, String), String> {
+    let mut bytes = [0u8; 32];
+    random_bytes(&mut bytes);
+    let verifier = base64url_no_pad(&bytes);
+    let digest = sha256(verifier.as_bytes());
+    let challenge = base64url_no_pad(&digest);
+    Ok((verifier, challenge))
+}
+
+fn base64url_no_pad(input: &[u8]) -> String {
+    const ALPHA: &[u8; 64] =
+        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
+    let mut i = 0;
+    while i + 3 <= input.len() {
+        let n = ((input[i] as u32) << 16)
+            | ((input[i + 1] as u32) << 8)
+            | (input[i + 2] as u32);
+        out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
+        out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
+        out.push(ALPHA[((n >> 6) & 0x3F) as usize] as char);
+        out.push(ALPHA[(n & 0x3F) as usize] as char);
+        i += 3;
+    }
+    let rem = input.len() - i;
+    if rem == 1 {
+        let n = (input[i] as u32) << 16;
+        out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
+        out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
+    } else if rem == 2 {
+        let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8);
+        out.push(ALPHA[((n >> 18) & 0x3F) as usize] as char);
+        out.push(ALPHA[((n >> 12) & 0x3F) as usize] as char);
+        out.push(ALPHA[((n >> 6) & 0x3F) as usize] as char);
+    }
+    out
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// URL encoding
+//////////////////////////////////////////////////////////////////////////////
+
+fn percent_encode(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for &b in s.as_bytes() {
+        match b {
+            b'A'..=b'Z'
+            | b'a'..=b'z'
+            | b'0'..=b'9'
+            | b'-'
+            | b'.'
+            | b'_'
+            | b'~' => out.push(b as char),
+            _ => {
+                let _ = write!(out, "%{b:02X}");
+            }
+        }
+    }
+    out
+}
+
+fn percent_decode(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        match bytes[i] {
+            b'+' => {
+                out.push(b' ');
+                i += 1;
+            }
+            b'%' if i + 2 < bytes.len() => {
+                let hex = std::str::from_utf8(&bytes[i + 1..i + 3])
+                    .ok()
+                    .and_then(|h| u8::from_str_radix(h, 16).ok());
+                match hex {
+                    Some(b) => {
+                        out.push(b);
+                        i += 3;
+                    }
+                    None => {
+                        out.push(bytes[i]);
+                        i += 1;
+                    }
+                }
+            }
+            b => {
+                out.push(b);
+                i += 1;
+            }
+        }
+    }
+    String::from_utf8(out).unwrap_or_default()
+}
+
+fn url_form(pairs: &[(&str, &str)]) -> String {
+    let mut out = String::new();
+    for (i, (k, v)) in pairs.iter().enumerate() {
+        if i > 0 {
+            out.push('&');
+        }
+        out.push_str(&percent_encode(k));
+        out.push('=');
+        out.push_str(&percent_encode(v));
+    }
+    out
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Misc
+//////////////////////////////////////////////////////////////////////////////
+
+fn now_unix() -> i64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_secs() as i64)
+        .unwrap_or(0)
+}
+
+fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
+    hay.windows(needle.len()).position(|w| w == needle)
+}
blob - /dev/null
blob + 00bec12a3a74a65107a4d6ded02494e8fcd91c6f (mode 644)
--- /dev/null
+++ vendor/VENDOR
@@ -0,0 +1,6 @@
+jackson.rs
+	origin:	ssh://ijanc@ijanc.org/json/jackson
+	commit:	99beb4a5385f52e7b353501b0ee08f0e6855d8f2
+	date:	2026-04-18
+
+To update: copy jackson.rs from upstream, then update commit/date above.
blob - /dev/null
blob + 89d9655a02f2cd1a90920cd514f0428a57739453 (mode 644)
--- /dev/null
+++ vendor/jackson.rs
@@ -0,0 +1,1171 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+//! Parse and generate JSON documents.
+
+use std::fmt;
+use std::fmt::Write as _;
+use std::str::FromStr;
+
+/// A JSON value.
+pub enum Value {
+    Null,
+    Bool(bool),
+    Number(f64),
+    String(String),
+    Array(Vec<Value>),
+    Object(Vec<(String, Value)>),
+}
+
+/// A parse or serialise error.
+pub struct Error {
+    msg: &'static str,
+    pos: usize,
+}
+
+impl Error {
+    pub const fn new(msg: &'static str, pos: usize) -> Self {
+        Self { msg, pos }
+    }
+
+    /// Static error message.
+    pub const fn message(&self) -> &'static str {
+        self.msg
+    }
+
+    /// Byte offset into the input where the error was detected.
+    pub const fn position(&self) -> usize {
+        self.pos
+    }
+}
+
+impl fmt::Display for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{} at byte {}", self.msg, self.pos)
+    }
+}
+
+impl fmt::Debug for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Display::fmt(self, f)
+    }
+}
+
+impl std::error::Error for Error {}
+
+impl FromStr for Value {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Parser::new(s).parse_root()
+    }
+}
+
+impl Value {
+    /// Serialise this value as a JSON document.
+    pub fn stringify(&self) -> Result<String, Error> {
+        let mut out = String::new();
+        write_value(self, &mut out)?;
+        Ok(out)
+    }
+
+    pub fn as_bool(&self) -> Option<bool> {
+        match self {
+            Value::Bool(b) => Some(*b),
+            _ => None,
+        }
+    }
+
+    pub fn as_number(&self) -> Option<f64> {
+        match self {
+            Value::Number(n) => Some(*n),
+            _ => None,
+        }
+    }
+
+    pub fn as_str(&self) -> Option<&str> {
+        match self {
+            Value::String(s) => Some(s.as_str()),
+            _ => None,
+        }
+    }
+
+    pub fn as_array(&self) -> Option<&[Value]> {
+        match self {
+            Value::Array(a) => Some(a.as_slice()),
+            _ => None,
+        }
+    }
+
+    pub fn as_object(&self) -> Option<&[(String, Value)]> {
+        match self {
+            Value::Object(o) => Some(o.as_slice()),
+            _ => None,
+        }
+    }
+}
+
+impl From<bool> for Value {
+    fn from(b: bool) -> Self {
+        Value::Bool(b)
+    }
+}
+
+impl From<f64> for Value {
+    fn from(n: f64) -> Self {
+        Value::Number(n)
+    }
+}
+
+impl From<&str> for Value {
+    fn from(s: &str) -> Self {
+        Value::String(s.to_string())
+    }
+}
+
+impl From<String> for Value {
+    fn from(s: String) -> Self {
+        Value::String(s)
+    }
+}
+
+fn write_value(v: &Value, out: &mut String) -> Result<(), Error> {
+    match v {
+        Value::Null => out.push_str("null"),
+        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
+        Value::Number(n) => {
+            if !n.is_finite() {
+                return Err(Error::new("non-finite number", 0));
+            }
+            if *n != 0.0 && n.fract() == 0.0 && n.abs() < (1_i64 << 53) as f64 {
+                write!(out, "{}", *n as i64).unwrap();
+            } else {
+                write!(out, "{n}").unwrap();
+            }
+        }
+        Value::String(s) => write_string(s, out),
+        Value::Array(a) => {
+            out.push('[');
+            for (i, item) in a.iter().enumerate() {
+                if i > 0 {
+                    out.push(',');
+                }
+                write_value(item, out)?;
+            }
+            out.push(']');
+        }
+        Value::Object(o) => {
+            out.push('{');
+            for (i, (k, item)) in o.iter().enumerate() {
+                if i > 0 {
+                    out.push(',');
+                }
+                write_string(k, out);
+                out.push(':');
+                write_value(item, out)?;
+            }
+            out.push('}');
+        }
+    }
+    Ok(())
+}
+
+fn write_string(s: &str, out: &mut String) {
+    out.push('"');
+    let bytes = s.as_bytes();
+    let mut run_start = 0;
+    for (i, &b) in bytes.iter().enumerate() {
+        let esc: &str = match b {
+            b'"' => "\\\"",
+            b'\\' => "\\\\",
+            b'\n' => "\\n",
+            b'\r' => "\\r",
+            b'\t' => "\\t",
+            0x08 => "\\b",
+            0x0C => "\\f",
+            0..=0x1F => {
+                out.push_str(&s[run_start..i]);
+                write!(out, "\\u{:04x}", b).unwrap();
+                run_start = i + 1;
+                continue;
+            }
+            _ => continue,
+        };
+        out.push_str(&s[run_start..i]);
+        out.push_str(esc);
+        run_start = i + 1;
+    }
+    out.push_str(&s[run_start..]);
+    out.push('"');
+}
+
+const MAX_DEPTH: usize = 128;
+
+struct Parser<'a> {
+    src: &'a [u8],
+    pos: usize,
+    depth: usize,
+}
+
+impl<'a> Parser<'a> {
+    fn new(s: &'a str) -> Self {
+        Self {
+            src: s.as_bytes(),
+            pos: 0,
+            depth: 0,
+        }
+    }
+
+    fn err(&self, msg: &'static str) -> Error {
+        Error::new(msg, self.pos)
+    }
+
+    fn peek(&self) -> Option<u8> {
+        self.src.get(self.pos).copied()
+    }
+
+    fn bump(&mut self) -> Option<u8> {
+        let b = self.peek()?;
+        self.pos += 1;
+        Some(b)
+    }
+
+    fn skip_ws(&mut self) {
+        while let Some(b) = self.peek() {
+            if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
+                self.pos += 1;
+            } else {
+                break;
+            }
+        }
+    }
+
+    fn expect(&mut self, b: u8, msg: &'static str) -> Result<(), Error> {
+        if self.peek() == Some(b) {
+            self.pos += 1;
+            Ok(())
+        } else {
+            Err(self.err(msg))
+        }
+    }
+
+    fn expect_keyword(&mut self, kw: &[u8]) -> Result<(), Error> {
+        let end = self.pos + kw.len();
+        if end > self.src.len() || &self.src[self.pos..end] != kw {
+            return Err(self.err("expected keyword"));
+        }
+        self.pos = end;
+        Ok(())
+    }
+
+    fn enter(&mut self) -> Result<(), Error> {
+        if self.depth >= MAX_DEPTH {
+            return Err(self.err("max nesting depth exceeded"));
+        }
+        self.depth += 1;
+        Ok(())
+    }
+
+    fn parse_root(&mut self) -> Result<Value, Error> {
+        let v = self.parse_value()?;
+        self.skip_ws();
+        if self.pos < self.src.len() {
+            return Err(self.err("trailing garbage"));
+        }
+        Ok(v)
+    }
+
+    fn parse_value(&mut self) -> Result<Value, Error> {
+        self.skip_ws();
+        let b = self
+            .peek()
+            .ok_or_else(|| self.err("unexpected end of input"))?;
+        match b {
+            b'n' => {
+                self.expect_keyword(b"null")?;
+                Ok(Value::Null)
+            }
+            b't' => {
+                self.expect_keyword(b"true")?;
+                Ok(Value::Bool(true))
+            }
+            b'f' => {
+                self.expect_keyword(b"false")?;
+                Ok(Value::Bool(false))
+            }
+            b'"' => Ok(Value::String(self.parse_string()?)),
+            b'[' => self.parse_array(),
+            b'{' => self.parse_object(),
+            b'-' | b'0'..=b'9' => Ok(Value::Number(self.parse_number()?)),
+            _ => Err(self.err("unexpected character")),
+        }
+    }
+
+    fn parse_string(&mut self) -> Result<String, Error> {
+        self.pos += 1;
+        let mut out = String::new();
+        loop {
+            let run_start = self.pos;
+            while let Some(&b) = self.src.get(self.pos) {
+                if matches!(b, b'"' | b'\\') || b < 0x20 {
+                    break;
+                }
+                self.pos += 1;
+            }
+            let run = &self.src[run_start..self.pos];
+            // SAFETY: src is the byte view of a &str input; the scan
+            // only breaks on ASCII bytes ("", \\, < 0x20), so
+            // run_start..self.pos is always a valid UTF-8 substring.
+            let run_str = unsafe { std::str::from_utf8_unchecked(run) };
+            out.push_str(run_str);
+            match self.peek() {
+                None => return Err(self.err("unterminated string")),
+                Some(b'"') => {
+                    self.pos += 1;
+                    return Ok(out);
+                }
+                Some(b'\\') => {
+                    self.pos += 1;
+                    let esc =
+                        self.bump().ok_or_else(|| self.err("bad escape"))?;
+                    match esc {
+                        b'"' => out.push('"'),
+                        b'\\' => out.push('\\'),
+                        b'/' => out.push('/'),
+                        b'b' => out.push('\u{08}'),
+                        b'f' => out.push('\u{0C}'),
+                        b'n' => out.push('\n'),
+                        b'r' => out.push('\r'),
+                        b't' => out.push('\t'),
+                        b'u' => out.push(self.parse_u_escape()?),
+                        _ => return Err(self.err("invalid escape")),
+                    }
+                }
+                Some(_) => {
+                    return Err(self.err("control character in string"));
+                }
+            }
+        }
+    }
+
+    fn parse_u_escape(&mut self) -> Result<char, Error> {
+        let hi = self.parse_hex4()?;
+        if (0xD800..=0xDBFF).contains(&hi) {
+            if self.bump() != Some(b'\\') || self.bump() != Some(b'u') {
+                return Err(self.err("expected low surrogate"));
+            }
+            let lo = self.parse_hex4()?;
+            if !(0xDC00..=0xDFFF).contains(&lo) {
+                return Err(self.err("invalid low surrogate"));
+            }
+            let code = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
+            char::from_u32(code).ok_or_else(|| self.err("invalid codepoint"))
+        } else {
+            char::from_u32(hi).ok_or_else(|| self.err("invalid codepoint"))
+        }
+    }
+
+    fn parse_hex4(&mut self) -> Result<u32, Error> {
+        let mut v: u32 = 0;
+        for _ in 0..4 {
+            let b =
+                self.bump().ok_or_else(|| self.err("bad unicode escape"))?;
+            let d = match b {
+                b'0'..=b'9' => b - b'0',
+                b'a'..=b'f' => b - b'a' + 10,
+                b'A'..=b'F' => b - b'A' + 10,
+                _ => return Err(self.err("bad hex digit")),
+            };
+            v = v * 16 + d as u32;
+        }
+        Ok(v)
+    }
+
+    fn parse_number(&mut self) -> Result<f64, Error> {
+        let start = self.pos;
+        if self.peek() == Some(b'-') {
+            self.pos += 1;
+        }
+        match self.peek() {
+            Some(b'0') => self.pos += 1,
+            Some(b'1'..=b'9') => {
+                self.pos += 1;
+                while matches!(self.peek(), Some(b'0'..=b'9')) {
+                    self.pos += 1;
+                }
+            }
+            _ => return Err(self.err("expected digit")),
+        }
+        if self.peek() == Some(b'.') {
+            self.pos += 1;
+            if !matches!(self.peek(), Some(b'0'..=b'9')) {
+                return Err(self.err("expected digit after decimal point"));
+            }
+            while matches!(self.peek(), Some(b'0'..=b'9')) {
+                self.pos += 1;
+            }
+        }
+        if matches!(self.peek(), Some(b'e' | b'E')) {
+            self.pos += 1;
+            if matches!(self.peek(), Some(b'+' | b'-')) {
+                self.pos += 1;
+            }
+            if !matches!(self.peek(), Some(b'0'..=b'9')) {
+                return Err(self.err("expected digit in exponent"));
+            }
+            while matches!(self.peek(), Some(b'0'..=b'9')) {
+                self.pos += 1;
+            }
+        }
+        let slice = &self.src[start..self.pos];
+        let s = std::str::from_utf8(slice).unwrap();
+        s.parse::<f64>()
+            .map_err(|_| Error::new("invalid number", start))
+    }
+
+    fn parse_array(&mut self) -> Result<Value, Error> {
+        self.pos += 1;
+        self.enter()?;
+        let mut items = Vec::new();
+        self.skip_ws();
+        if self.peek() == Some(b']') {
+            self.pos += 1;
+            self.depth -= 1;
+            return Ok(Value::Array(items));
+        }
+        loop {
+            items.push(self.parse_value()?);
+            self.skip_ws();
+            match self.peek() {
+                Some(b',') => self.pos += 1,
+                Some(b']') => {
+                    self.pos += 1;
+                    self.depth -= 1;
+                    return Ok(Value::Array(items));
+                }
+                _ => return Err(self.err("expected ',' or ']'")),
+            }
+        }
+    }
+
+    fn parse_object(&mut self) -> Result<Value, Error> {
+        self.pos += 1;
+        self.enter()?;
+        let mut items = Vec::new();
+        self.skip_ws();
+        if self.peek() == Some(b'}') {
+            self.pos += 1;
+            self.depth -= 1;
+            return Ok(Value::Object(items));
+        }
+        loop {
+            if self.peek() != Some(b'"') {
+                return Err(self.err("expected string key"));
+            }
+            let key = self.parse_string()?;
+            self.skip_ws();
+            self.expect(b':', "expected ':'")?;
+            let v = self.parse_value()?;
+            items.push((key, v));
+            self.skip_ws();
+            match self.peek() {
+                Some(b',') => {
+                    self.pos += 1;
+                    self.skip_ws();
+                }
+                Some(b'}') => {
+                    self.pos += 1;
+                    self.depth -= 1;
+                    return Ok(Value::Object(items));
+                }
+                _ => return Err(self.err("expected ',' or '}'")),
+            }
+        }
+    }
+}
+
+/// Trait implemented by types that can be written to a [`Value`].
+pub trait ToJson {
+    fn to_json(&self) -> Value;
+}
+
+/// Trait implemented by types that can be read from a [`Value`].
+pub trait FromJson: Sized {
+    fn from_json(v: &Value) -> Result<Self, Error>;
+
+    /// Produce a value when the corresponding field is absent from the
+    /// parent object.  The default rejects; `Option<T>` overrides to
+    /// return `None`.
+    fn missing_field() -> Result<Self, Error> {
+        Err(Error::new("missing field", 0))
+    }
+}
+
+impl ToJson for bool {
+    fn to_json(&self) -> Value {
+        Value::Bool(*self)
+    }
+}
+
+impl FromJson for bool {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_bool().ok_or_else(|| Error::new("expected bool", 0))
+    }
+}
+
+impl ToJson for String {
+    fn to_json(&self) -> Value {
+        Value::String(self.clone())
+    }
+}
+
+impl FromJson for String {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_str()
+            .map(str::to_string)
+            .ok_or_else(|| Error::new("expected string", 0))
+    }
+}
+
+impl ToJson for f64 {
+    fn to_json(&self) -> Value {
+        Value::Number(*self)
+    }
+}
+
+impl FromJson for f64 {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_number()
+            .ok_or_else(|| Error::new("expected number", 0))
+    }
+}
+
+impl ToJson for f32 {
+    fn to_json(&self) -> Value {
+        Value::Number(*self as f64)
+    }
+}
+
+impl FromJson for f32 {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_number()
+            .map(|n| n as f32)
+            .ok_or_else(|| Error::new("expected number", 0))
+    }
+}
+
+macro_rules! jackson_int_impls {
+    ($($t:ty),* $(,)?) => { $(
+        impl ToJson for $t {
+            fn to_json(&self) -> Value {
+                Value::Number(*self as f64)
+            }
+        }
+
+        impl FromJson for $t {
+            fn from_json(v: &Value) -> Result<Self, Error> {
+                let n = v
+                    .as_number()
+                    .ok_or_else(|| Error::new("expected number", 0))?;
+                if n.is_finite()
+                    && n.fract() == 0.0
+                    && n >= <$t>::MIN as f64
+                    && n <= <$t>::MAX as f64
+                {
+                    Ok(n as $t)
+                } else {
+                    Err(Error::new("integer out of range", 0))
+                }
+            }
+        }
+    )* };
+}
+
+jackson_int_impls!(i8, i16, i32, i64, u8, u16, u32, u64);
+
+impl<T: ToJson> ToJson for Option<T> {
+    fn to_json(&self) -> Value {
+        match self {
+            Some(v) => v.to_json(),
+            None => Value::Null,
+        }
+    }
+}
+
+impl<T: FromJson> FromJson for Option<T> {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        match v {
+            Value::Null => Ok(None),
+            _ => T::from_json(v).map(Some),
+        }
+    }
+
+    fn missing_field() -> Result<Self, Error> {
+        Ok(None)
+    }
+}
+
+impl<T: ToJson> ToJson for Vec<T> {
+    fn to_json(&self) -> Value {
+        Value::Array(self.iter().map(T::to_json).collect())
+    }
+}
+
+impl<T: FromJson> FromJson for Vec<T> {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        let a = v
+            .as_array()
+            .ok_or_else(|| Error::new("expected array", 0))?;
+        a.iter().map(T::from_json).collect()
+    }
+}
+
+/// Define a struct together with [`ToJson`] and [`FromJson`] impls.
+#[macro_export]
+macro_rules! json_struct {
+    (
+        $(#[$meta:meta])*
+        $vis:vis struct $name:ident {
+            $(
+                $(#[$fmeta:meta])*
+                $fvis:vis $field:ident : $ty:ty
+            ),* $(,)?
+        }
+    ) => {
+        $(#[$meta])*
+        $vis struct $name {
+            $(
+                $(#[$fmeta])*
+                $fvis $field: $ty,
+            )*
+        }
+
+        impl $crate::ToJson for $name {
+            fn to_json(&self) -> $crate::Value {
+                $crate::Value::Object(vec![
+                    $(
+                        (
+                            stringify!($field).to_string(),
+                            <$ty as $crate::ToJson>::to_json(&self.$field),
+                        ),
+                    )*
+                ])
+            }
+        }
+
+        impl $crate::FromJson for $name {
+            fn from_json(
+                v: &$crate::Value,
+            ) -> ::std::result::Result<Self, $crate::Error> {
+                let obj = v.as_object().ok_or_else(|| {
+                    $crate::Error::new("expected object", 0)
+                })?;
+                $( let mut $field: Option<&$crate::Value> = None; )*
+                for (k, val) in obj {
+                    match k.as_str() {
+                        $( stringify!($field) => $field = Some(val), )*
+                        _ => {}
+                    }
+                }
+                Ok(Self {
+                    $(
+                        $field: match $field {
+                            Some(val) => {
+                                <$ty as $crate::FromJson>::from_json(val)?
+                            }
+                            None => <$ty as $crate::FromJson>::missing_field()
+                                .map_err(|_| $crate::Error::new(
+                                    concat!(
+                                        "missing field: ",
+                                        stringify!($field),
+                                    ),
+                                    0,
+                                ))?,
+                        },
+                    )*
+                })
+            }
+        }
+    };
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn value_accessors() {
+        assert_eq!(Value::Bool(true).as_bool(), Some(true));
+        assert_eq!(Value::Null.as_bool(), None);
+        assert_eq!(Value::Number(1.5).as_number(), Some(1.5));
+        assert_eq!(Value::Null.as_number(), None);
+        assert_eq!(Value::String("hi".into()).as_str(), Some("hi"));
+        assert_eq!(Value::Null.as_str(), None);
+        assert!(Value::Array(Vec::new()).as_array().is_some());
+        assert!(Value::Null.as_array().is_none());
+        assert!(Value::Object(Vec::new()).as_object().is_some());
+        assert!(Value::Null.as_object().is_none());
+    }
+
+    #[test]
+    fn value_from_impls() {
+        assert!(matches!(Value::from(true), Value::Bool(true)));
+        assert!(matches!(Value::from(1.5), Value::Number(n) if n == 1.5));
+        match Value::from("hello") {
+            Value::String(s) => assert_eq!(s, "hello"),
+            _ => panic!(),
+        }
+        match Value::from(String::from("world")) {
+            Value::String(s) => assert_eq!(s, "world"),
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn error_accessors() {
+        let e = Error::new("boom", 42);
+        assert_eq!(e.message(), "boom");
+        assert_eq!(e.position(), 42);
+    }
+
+    #[test]
+    fn value_variants_construct() {
+        let _ = Value::Null;
+        let _ = Value::Bool(true);
+        let _ = Value::Number(0.0);
+        let _ = Value::String(String::new());
+        let _ = Value::Array(Vec::new());
+        let _ = Value::Object(Vec::new());
+    }
+
+    fn parse(s: &str) -> Value {
+        s.parse::<Value>().unwrap()
+    }
+
+    fn parse_err(s: &str) {
+        assert!(s.parse::<Value>().is_err(), "expected error for {s:?}");
+    }
+
+    #[test]
+    fn parse_primitives() {
+        assert!(matches!(parse("null"), Value::Null));
+        assert!(matches!(parse("true"), Value::Bool(true)));
+        assert!(matches!(parse("false"), Value::Bool(false)));
+        assert!(matches!(parse("  null  "), Value::Null));
+    }
+
+    #[test]
+    fn parse_numbers() {
+        let cases: &[(&str, f64)] = &[
+            ("0", 0.0),
+            ("-0", 0.0),
+            ("42", 42.0),
+            ("-42", -42.0),
+            ("3.14", 3.14),
+            ("-2.5", -2.5),
+            ("1e10", 1e10),
+            ("1E10", 1e10),
+            ("1.5e2", 150.0),
+            ("1.5e+2", 150.0),
+            ("2.5e-1", 0.25),
+        ];
+        for (s, v) in cases {
+            match parse(s) {
+                Value::Number(n) => assert_eq!(n, *v, "{s}"),
+                _ => panic!("not a number: {s}"),
+            }
+        }
+    }
+
+    #[test]
+    fn parse_number_errors() {
+        parse_err("01");
+        parse_err("+1");
+        parse_err(".5");
+        parse_err("1.");
+        parse_err("1e");
+        parse_err("1e+");
+        parse_err("-");
+    }
+
+    #[test]
+    fn parse_strings() {
+        match parse(r#""hello""#) {
+            Value::String(s) => assert_eq!(s, "hello"),
+            _ => panic!(),
+        }
+        match parse(r#""a\"b\\c\/d""#) {
+            Value::String(s) => assert_eq!(s, "a\"b\\c/d"),
+            _ => panic!(),
+        }
+        match parse(r#""\n\r\t\b\f""#) {
+            Value::String(s) => assert_eq!(s, "\n\r\t\u{08}\u{0C}"),
+            _ => panic!(),
+        }
+        match parse(r#""\u0041""#) {
+            Value::String(s) => assert_eq!(s, "A"),
+            _ => panic!(),
+        }
+        match parse(r#""\uD834\uDD1E""#) {
+            Value::String(s) => assert_eq!(s, "\u{1D11E}"),
+            _ => panic!(),
+        }
+        match parse("\"é\"") {
+            Value::String(s) => assert_eq!(s, "é"),
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_string_errors() {
+        parse_err(r#""unterminated"#);
+        parse_err("\"embedded\nnewline\"");
+        parse_err(r#""\x""#);
+        parse_err(r#""\uD800""#);
+        parse_err(r#""\uDC00""#);
+        parse_err(r#""\uD800\u0041""#);
+    }
+
+    #[test]
+    fn parse_arrays() {
+        assert!(matches!(parse("[]"), Value::Array(a) if a.is_empty()));
+        match parse("[1, 2, 3]") {
+            Value::Array(a) => {
+                assert_eq!(a.len(), 3);
+                assert!(matches!(a[0], Value::Number(n) if n == 1.0));
+            }
+            _ => panic!(),
+        }
+        match parse("[[1], [2, 3]]") {
+            Value::Array(a) => assert_eq!(a.len(), 2),
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_array_errors() {
+        parse_err("[");
+        parse_err("[1,");
+        parse_err("[1 2]");
+        parse_err("[,]");
+    }
+
+    #[test]
+    fn parse_objects() {
+        assert!(matches!(parse("{}"), Value::Object(m) if m.is_empty()));
+        match parse(r#"{"a": 1, "b": true}"#) {
+            Value::Object(m) => {
+                assert_eq!(m.len(), 2);
+                assert_eq!(m[0].0, "a");
+                assert!(matches!(m[0].1, Value::Number(n) if n == 1.0));
+                assert_eq!(m[1].0, "b");
+                assert!(matches!(m[1].1, Value::Bool(true)));
+            }
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_object_errors() {
+        parse_err("{");
+        parse_err(r#"{"a""#);
+        parse_err(r#"{"a":}"#);
+        parse_err(r#"{"a":1"#);
+        parse_err(r#"{a:1}"#);
+        parse_err(r#"{"a":1,}"#);
+    }
+
+    #[test]
+    fn parse_duplicate_keys_kept() {
+        match parse(r#"{"a":1,"a":2}"#) {
+            Value::Object(m) => {
+                assert_eq!(m.len(), 2);
+                assert_eq!(m[0].0, "a");
+                assert!(matches!(m[0].1, Value::Number(n) if n == 1.0));
+                assert_eq!(m[1].0, "a");
+                assert!(matches!(m[1].1, Value::Number(n) if n == 2.0));
+            }
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_trailing_garbage_fails() {
+        parse_err("null null");
+        parse_err("1 2");
+        parse_err("[] x");
+    }
+
+    #[test]
+    fn parse_empty_fails() {
+        parse_err("");
+        parse_err("   ");
+    }
+
+    #[test]
+    fn round_trip() {
+        let cases = &[
+            "null",
+            "true",
+            "false",
+            "0",
+            "-1.5",
+            r#""hello""#,
+            "[]",
+            "[1,2,3]",
+            "{}",
+            r#"{"a":1,"b":[true,null]}"#,
+        ];
+        for s in cases {
+            let v: Value = s.parse().unwrap();
+            assert_eq!(&v.stringify().unwrap(), s, "round trip {s}");
+        }
+    }
+
+    #[test]
+    fn parse_rejects_deep_nesting() {
+        let s: String = "[".repeat(200);
+        parse_err(&s);
+    }
+
+    #[test]
+    fn stringify_primitives() {
+        assert_eq!(Value::Null.stringify().unwrap(), "null");
+        assert_eq!(Value::Bool(true).stringify().unwrap(), "true");
+        assert_eq!(Value::Bool(false).stringify().unwrap(), "false");
+        assert_eq!(Value::Number(0.0).stringify().unwrap(), "0");
+        assert_eq!(Value::Number(-42.5).stringify().unwrap(), "-42.5");
+    }
+
+    #[test]
+    fn stringify_integer_values() {
+        assert_eq!(Value::Number(42.0).stringify().unwrap(), "42");
+        assert_eq!(Value::Number(-1000.0).stringify().unwrap(), "-1000");
+        assert_eq!(Value::Number(1.5).stringify().unwrap(), "1.5");
+        // Beyond 2^53 falls back to the f64 formatter.
+        let big = (1_i64 << 54) as f64;
+        let s = Value::Number(big).stringify().unwrap();
+        assert_eq!(s.parse::<f64>().unwrap(), big);
+    }
+
+    #[test]
+    fn stringify_non_finite_errors() {
+        assert!(Value::Number(f64::NAN).stringify().is_err());
+        assert!(Value::Number(f64::INFINITY).stringify().is_err());
+        assert!(Value::Number(f64::NEG_INFINITY).stringify().is_err());
+    }
+
+    #[test]
+    fn stringify_string_escapes() {
+        assert_eq!(Value::String("hi".into()).stringify().unwrap(), "\"hi\"");
+        assert_eq!(
+            Value::String("a\"b\\c".into()).stringify().unwrap(),
+            "\"a\\\"b\\\\c\""
+        );
+        assert_eq!(
+            Value::String("\n\r\t\u{08}\u{0C}".into())
+                .stringify()
+                .unwrap(),
+            "\"\\n\\r\\t\\b\\f\""
+        );
+        assert_eq!(
+            Value::String("\x01\x1f".into()).stringify().unwrap(),
+            "\"\\u0001\\u001f\""
+        );
+        assert_eq!(Value::String("é".into()).stringify().unwrap(), "\"é\"");
+    }
+
+    #[test]
+    fn stringify_array() {
+        assert_eq!(Value::Array(Vec::new()).stringify().unwrap(), "[]");
+        let a = Value::Array(vec![
+            Value::Number(1.0),
+            Value::Null,
+            Value::Bool(true),
+        ]);
+        assert_eq!(a.stringify().unwrap(), "[1,null,true]");
+    }
+
+    #[test]
+    fn stringify_object_preserves_insertion_order() {
+        let o = vec![
+            ("b".into(), Value::Number(2.0)),
+            ("a".into(), Value::Number(1.0)),
+        ];
+        assert_eq!(Value::Object(o).stringify().unwrap(), r#"{"b":2,"a":1}"#);
+    }
+
+    #[test]
+    fn stringify_nested() {
+        let inner = vec![("x".into(), Value::Array(vec![Value::Number(3.0)]))];
+        let outer = vec![("obj".into(), Value::Object(inner))];
+        assert_eq!(
+            Value::Object(outer).stringify().unwrap(),
+            r#"{"obj":{"x":[3]}}"#
+        );
+    }
+
+    #[test]
+    fn stringify_array_propagates_error() {
+        let a = Value::Array(vec![Value::Number(f64::NAN)]);
+        assert!(a.stringify().is_err());
+    }
+
+    json_struct! {
+        struct SimpleUser {
+            name: String,
+            age: i64,
+            active: bool,
+        }
+    }
+
+    #[test]
+    fn derive_simple_round_trip() {
+        let u = SimpleUser {
+            name: "ada".into(),
+            age: 36,
+            active: true,
+        };
+        let s = u.to_json().stringify().unwrap();
+        let v: Value = s.parse().unwrap();
+        let back = SimpleUser::from_json(&v).unwrap();
+        assert_eq!(back.name, "ada");
+        assert_eq!(back.age, 36);
+        assert!(back.active);
+    }
+
+    json_struct! {
+        struct OptionalFields {
+            required: String,
+            maybe: Option<i64>,
+        }
+    }
+
+    #[test]
+    fn derive_option_none_serialises_as_null() {
+        let u = OptionalFields {
+            required: "x".into(),
+            maybe: None,
+        };
+        let s = u.to_json().stringify().unwrap();
+        assert_eq!(s, r#"{"required":"x","maybe":null}"#);
+        let back = OptionalFields::from_json(&s.parse().unwrap()).unwrap();
+        assert!(back.maybe.is_none());
+    }
+
+    #[test]
+    fn derive_absent_option_is_none() {
+        let v: Value = r#"{"required":"x"}"#.parse().unwrap();
+        let back = OptionalFields::from_json(&v).unwrap();
+        assert_eq!(back.required, "x");
+        assert!(back.maybe.is_none());
+    }
+
+    #[test]
+    fn derive_option_some_round_trips() {
+        let u = OptionalFields {
+            required: "x".into(),
+            maybe: Some(42),
+        };
+        let s = u.to_json().stringify().unwrap();
+        let back = OptionalFields::from_json(&s.parse().unwrap()).unwrap();
+        assert_eq!(back.maybe, Some(42));
+    }
+
+    json_struct! {
+        struct Container {
+            tags: Vec<String>,
+            counts: Vec<i32>,
+        }
+    }
+
+    #[test]
+    fn derive_vec_round_trip() {
+        let c = Container {
+            tags: vec!["a".into(), "b".into()],
+            counts: vec![1, 2, 3],
+        };
+        let s = c.to_json().stringify().unwrap();
+        let back = Container::from_json(&s.parse().unwrap()).unwrap();
+        assert_eq!(back.tags, vec!["a".to_string(), "b".to_string()]);
+        assert_eq!(back.counts, vec![1, 2, 3]);
+    }
+
+    json_struct! {
+        struct Inner {
+            value: i32,
+        }
+    }
+
+    json_struct! {
+        struct Outer {
+            name: String,
+            inner: Inner,
+        }
+    }
+
+    #[test]
+    fn derive_nested_struct() {
+        let o = Outer {
+            name: "n".into(),
+            inner: Inner { value: 7 },
+        };
+        let s = o.to_json().stringify().unwrap();
+        assert_eq!(s, r#"{"name":"n","inner":{"value":7}}"#);
+        let back = Outer::from_json(&s.parse().unwrap()).unwrap();
+        assert_eq!(back.name, "n");
+        assert_eq!(back.inner.value, 7);
+    }
+
+    #[test]
+    fn derive_missing_field_errors() {
+        let v: Value = r#"{"name":"ada","active":true}"#.parse().unwrap();
+        let e = SimpleUser::from_json(&v).err().unwrap();
+        assert_eq!(e.message(), "missing field: age");
+    }
+
+    #[test]
+    fn derive_wrong_type_errors() {
+        let v: Value =
+            r#"{"name":"ada","age":"old","active":true}"#.parse().unwrap();
+        assert!(SimpleUser::from_json(&v).is_err());
+    }
+
+    #[test]
+    fn derive_expects_object() {
+        let v = Value::Array(Vec::new());
+        assert!(SimpleUser::from_json(&v).is_err());
+    }
+
+    #[test]
+    fn derive_integer_out_of_range_errors() {
+        assert!(i32::from_json(&Value::Number(1e20)).is_err());
+    }
+
+    #[test]
+    fn derive_non_integer_errors() {
+        assert!(i64::from_json(&Value::Number(1.5)).is_err());
+    }
+
+    #[test]
+    fn derive_duplicate_keys_last_wins() {
+        let v: Value =
+            r#"{"name":"a","age":1,"active":true,"age":2}"#.parse().unwrap();
+        let back = SimpleUser::from_json(&v).unwrap();
+        assert_eq!(back.age, 2);
+    }
+}