commit feb508df949b89fcb4d5657355742041eddae7d3 from: Murilo Ijanc date: Mon Apr 20 02:08:59 2026 UTC Initial import. 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' + +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' +# +# 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' +.\" +.\" 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' +// +// 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, + web: Option, + } +} + +json_struct! { + struct TokenResponse { + access_token: String, + expires_in: i64, + refresh_token: Option, + scope: Option, + token_type: Option, + id_token: Option, + } +} + +json_struct! { + struct TokenError { + error: String, + error_description: Option, + } +} + +#[derive(Copy, Clone, PartialEq)] +enum Mode { + Help, + Version, + Init, + Refresh, + Print, + Revoke, +} + +struct Opts { + mode: Option, + client_secret: Option, + token_file: Option, + force: bool, + scopes: Vec, +} + +////////////////////////////////////////////////////////////////////////////// +// Entry +////////////////////////////////////////////////////////////////////////////// + +fn main() { + process::exit(run()); +} + +fn run() -> i32 { + let args: Vec = 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 { + 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 = 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, 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 ")?; + let token_path = opts + .token_file + .as_deref() + .ok_or("init: missing -f ")?; + 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 ")?; + 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 ")?; + 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 ")?; + 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 { + 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 { + 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 { + 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 { + 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 = None; + let mut state: Option = None; + let mut err: Option = 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, 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 { + 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 { + 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 { + 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 = 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, 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 { + 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, 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 { + 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' +// +// 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), + 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 { + Parser::new(s).parse_root() + } +} + +impl Value { + /// Serialise this value as a JSON document. + pub fn stringify(&self) -> Result { + let mut out = String::new(); + write_value(self, &mut out)?; + Ok(out) + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_number(&self) -> Option { + 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 for Value { + fn from(b: bool) -> Self { + Value::Bool(b) + } +} + +impl From 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 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 { + self.src.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::() + .map_err(|_| Error::new("invalid number", start)) + } + + fn parse_array(&mut self) -> Result { + 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 { + 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; + + /// Produce a value when the corresponding field is absent from the + /// parent object. The default rejects; `Option` overrides to + /// return `None`. + fn missing_field() -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 ToJson for Option { + fn to_json(&self) -> Value { + match self { + Some(v) => v.to_json(), + None => Value::Null, + } + } +} + +impl FromJson for Option { + fn from_json(v: &Value) -> Result { + match v { + Value::Null => Ok(None), + _ => T::from_json(v).map(Some), + } + } + + fn missing_field() -> Result { + Ok(None) + } +} + +impl ToJson for Vec { + fn to_json(&self) -> Value { + Value::Array(self.iter().map(T::to_json).collect()) + } +} + +impl FromJson for Vec { + fn from_json(v: &Value) -> Result { + 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 { + 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::().unwrap() + } + + fn parse_err(s: &str) { + assert!(s.parse::().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::().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, + } + } + + #[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, + counts: Vec, + } + } + + #[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); + } +}