Skip to main content

solari_browser/
client.rs

1//! [`Client`] plus the `sessions` / `profiles` / `proxy` resource namespaces.
2//! Ports `sdk/src/index.ts` (the TypeScript reference) minus `launch()`.
3//!
4//! One deliberate divergence from the reference: the TS SDK wraps every session
5//! endpoint in a Node-side `LocalProxy` loopback rewrite. This crate returns the
6//! UPSTREAM `wsEndpoint` / `cdpEndpoint` verbatim — there is no proxy to route
7//! through, and callers connect straight to the gateway.
8
9use std::collections::BTreeMap;
10use std::time::Duration;
11
12use serde_json::{json, Map, Value};
13use url::Url;
14
15use crate::error::{api_error, SolariError, SolariErrorCode};
16use crate::http::HttpTransport;
17use crate::types::{
18    CreateSessionRequest, CreateSessionResponse, Profile, ProfileSaveResponse, ProfileSaveResult,
19    ProxyCountries, ProxySpec, ReplayUrl, ReplayUrlResponse, Session, SessionList, SessionView,
20    SolariRegion,
21    StorageState,
22};
23
24pub(crate) const DEFAULT_MAX_ATTEMPTS: u32 = 2;
25pub(crate) const DEFAULT_BACKOFF_MS: u64 = 500;
26pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 90_000;
27const STORAGE_STATE_FETCH_TIMEOUT: Duration = Duration::from_secs(8);
28
29/// Options for constructing a [`Client`].
30///
31/// There is no environment-variable fallback by design — the API key and base
32/// URL are always explicit constructor arguments.
33#[derive(Clone)]
34pub struct ClientOptions {
35    pub api_key: String,
36    pub base_url: String,
37    /// Total attempts per request (including the first). Default 2.
38    pub max_attempts: u32,
39    /// Fixed delay between attempts. Default 500ms.
40    pub backoff_ms: u64,
41    /// Per-request timeout. Default 90000ms.
42    pub timeout_ms: u64,
43}
44
45/// Hand-written so the API key is never printed. Do not derive this.
46impl std::fmt::Debug for ClientOptions {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("ClientOptions")
49            .field("api_key", &"<redacted>")
50            .field("base_url", &self.base_url)
51            .field("max_attempts", &self.max_attempts)
52            .field("backoff_ms", &self.backoff_ms)
53            .field("timeout_ms", &self.timeout_ms)
54            .finish()
55    }
56}
57
58impl ClientOptions {
59    /// Point the client at an explicit base URL (staging / self-hosted gateway).
60    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
61        ClientOptions {
62            api_key: api_key.into(),
63            base_url: base_url.into(),
64            max_attempts: DEFAULT_MAX_ATTEMPTS,
65            backoff_ms: DEFAULT_BACKOFF_MS,
66            timeout_ms: DEFAULT_TIMEOUT_MS,
67        }
68    }
69
70    /// Point the client at a region's public API. [`SolariRegion::UsWest`]
71    /// resolves to `https://api.getsolari.com`.
72    pub fn for_region(api_key: impl Into<String>, region: SolariRegion) -> Self {
73        ClientOptions::new(api_key, region.base_url())
74    }
75
76    /// Point the client at the default region (`us-west`).
77    pub fn default_region(api_key: impl Into<String>) -> Self {
78        ClientOptions::for_region(api_key, SolariRegion::default())
79    }
80
81    pub fn max_attempts(mut self, n: u32) -> Self {
82        self.max_attempts = n;
83        self
84    }
85
86    pub fn backoff_ms(mut self, ms: u64) -> Self {
87        self.backoff_ms = ms;
88        self
89    }
90
91    pub fn timeout_ms(mut self, ms: u64) -> Self {
92        self.timeout_ms = ms;
93        self
94    }
95}
96
97/// Options accepted by `POST /sessions`.
98///
99/// Falsy fields are omitted from the request body; if every field is falsy the
100/// request is sent with no body at all.
101#[derive(Debug, Clone, Default)]
102pub struct CreateSessionOptions {
103    /// Attach a stored browser profile (cookies + localStorage).
104    pub profile_id: Option<String>,
105    /// Enable session recording. Off by default.
106    pub recording: bool,
107    /// Enable the runtime stealth shim. Off by default.
108    pub stealth: bool,
109    /// Enable managed captcha solving. Requires `stealth`.
110    pub captcha: bool,
111    /// Request managed proxy egress. Requires `stealth`.
112    pub proxy: Option<ProxySpec>,
113    /// End the session earlier than the plan maximum. `None` leaves the plan
114    /// maximum; the gateway refuses anything under 60.
115    pub timeout_seconds: Option<u32>,
116    /// Hold the session after the client disconnects, until its deadline
117    /// (`timeout_seconds`, else an hour, never past the plan maximum) or until
118    /// it is released, so a dropped client can reconnect.
119    pub keep_alive: bool,
120    /// Free-form JSON stored with the session and returned by `get` and `list`.
121    pub user_metadata: Option<Map<String, Value>>,
122}
123
124impl CreateSessionOptions {
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    pub fn profile_id(mut self, id: impl Into<String>) -> Self {
130        self.profile_id = Some(id.into());
131        self
132    }
133
134    pub fn recording(mut self, on: bool) -> Self {
135        self.recording = on;
136        self
137    }
138
139    pub fn stealth(mut self, on: bool) -> Self {
140        self.stealth = on;
141        self
142    }
143
144    pub fn captcha(mut self, on: bool) -> Self {
145        self.captcha = on;
146        self
147    }
148
149    pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
150        self.proxy = Some(spec.into());
151        self
152    }
153
154    pub fn timeout_seconds(mut self, seconds: u32) -> Self {
155        self.timeout_seconds = Some(seconds);
156        self
157    }
158
159    pub fn keep_alive(mut self, on: bool) -> Self {
160        self.keep_alive = on;
161        self
162    }
163
164    pub fn user_metadata(mut self, metadata: Map<String, Value>) -> Self {
165        self.user_metadata = Some(metadata);
166        self
167    }
168}
169
170/// Filters and paging for [`Sessions::list`]. The default lists the first page.
171#[derive(Debug, Clone, Default)]
172pub struct ListSessionsOptions {
173    /// Page size; `None` uses the gateway default.
174    pub limit: Option<u32>,
175    /// Continue from a previous page's `next_cursor`.
176    pub cursor: Option<String>,
177    /// Keep sessions whose `user_metadata` has all these string values (at most
178    /// 10 pairs; keys and values cannot contain a single quote).
179    pub metadata: BTreeMap<String, String>,
180}
181
182/// Talks the Solari Browser control-plane REST API.
183#[derive(Debug, Clone)]
184pub struct Client {
185    http: HttpTransport,
186}
187
188impl Client {
189    pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
190        if opts.api_key.is_empty() {
191            return Err(SolariError::config("Solari: apiKey is required"));
192        }
193        if opts.base_url.is_empty() {
194            return Err(SolariError::config("Solari: baseUrl is required"));
195        }
196        let base_url = opts.base_url.trim_end_matches('/').to_string();
197        let http = HttpTransport::new(
198            opts.api_key,
199            base_url,
200            opts.max_attempts,
201            opts.backoff_ms,
202            opts.timeout_ms,
203        )?;
204        Ok(Client { http })
205    }
206
207    /// Session lifecycle: create / get / release / replay.
208    pub fn sessions(&self) -> Sessions<'_> {
209        Sessions { client: self }
210    }
211
212    /// Stored browser profiles.
213    pub fn profiles(&self) -> Profiles<'_> {
214        Profiles { client: self }
215    }
216
217    /// Managed proxy metadata.
218    pub fn proxy(&self) -> Proxy<'_> {
219        Proxy { client: self }
220    }
221
222    /// The resolved API base URL (trailing slash stripped).
223    pub fn base_url(&self) -> &str {
224        self.http.base_url()
225    }
226}
227
228// ---------------------------------------------------------------------------
229// sessions
230// ---------------------------------------------------------------------------
231
232/// The `sessions` namespace. Obtain via [`Client::sessions`].
233pub struct Sessions<'a> {
234    client: &'a Client,
235}
236
237impl Sessions<'_> {
238    /// `POST /sessions` — acquire a browser session.
239    pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
240        let req = CreateSessionRequest {
241            // TS: `if (options?.profileId)` — falsy (unset OR empty) is omitted.
242            profile_id: options
243                .profile_id
244                .clone()
245                .filter(|p| !p.is_empty()),
246            recording: options.recording,
247            stealth: options.stealth,
248            captcha: options.captcha,
249            proxy: options.proxy.clone(),
250            timeout_seconds: options.timeout_seconds,
251            keep_alive: options.keep_alive,
252            user_metadata: options.user_metadata.clone(),
253        };
254        let body = serde_json::to_value(&req)
255            .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
256        // Omit the body entirely when nothing was set.
257        let body = match body.as_object() {
258            Some(o) if o.is_empty() => None,
259            _ => Some(body),
260        };
261
262        let res = self
263            .client
264            .http
265            .request_with_key(
266                "POST",
267                "/sessions",
268                body.as_ref(),
269                // A create allocates a slot and launches a browser before it can
270                // answer, so a response lost after that point would make a retry
271                // a SECOND session.
272                Some(&crate::http::new_idempotency_key()),
273            )
274            .await?;
275        if !res.ok() {
276            return Err(api_error("POST", "/sessions", res.status, &res.body));
277        }
278
279        let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
280            SolariError::protocol(format!("Solari: unexpected session response: {e}"))
281        })?;
282
283        let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
284            (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
285            _ => {
286                return Err(SolariError::protocol(format!(
287                    "Solari: unexpected session response: {}",
288                    res.body
289                )))
290            }
291        };
292
293        let cdp_endpoint = data
294            .cdp_endpoint
295            .clone()
296            .filter(|c| !c.is_empty())
297            .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
298
299        let expires_at = data
300            .expires_at
301            .clone()
302            .filter(|e| !e.is_empty())
303            .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
304
305        let mut session = Session {
306            id: session_id,
307            ws_endpoint,
308            cdp_endpoint,
309            expires_at,
310            storage_state: None,
311            proxy: data.proxy,
312            user_metadata: data.user_metadata,
313        };
314
315        // Storage state is only populated when a profile was requested — TS
316        // gates on `options?.profileId !== undefined`.
317        if options.profile_id.is_some() {
318            session.storage_state = Some(match data.storage_state_url {
319                // `storageStateUrl` present → fetch the presigned object.
320                Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
321                // Explicit null → profile exists but is empty.
322                Some(None) => None,
323                // Absent → fall back to any inline state (TS: `?? null`).
324                None => data.storage_state.flatten(),
325            });
326        }
327
328        Ok(session)
329    }
330
331    /// `GET /sessions/:id`.
332    pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
333        let path = format!("/sessions/{}", enc(id));
334        let res = self.client.http.request("GET", &path, None).await?;
335        if !res.ok() {
336            return Err(api_error("GET", &path, res.status, &res.body));
337        }
338        serde_json::from_str(&res.body)
339            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
340    }
341
342    /// `GET /sessions` — one page of the organisation's sessions. Rows never
343    /// carry the websocket endpoints; call [`Sessions::get`] for those.
344    pub async fn list(&self, options: ListSessionsOptions) -> Result<SessionList, SolariError> {
345        let mut query: Vec<String> = Vec::new();
346        if let Some(limit) = options.limit.filter(|l| *l > 0) {
347            query.push(format!("limit={limit}"));
348        }
349        if let Some(cursor) = options.cursor.as_deref().filter(|c| !c.is_empty()) {
350            query.push(format!("cursor={}", enc(cursor)));
351        }
352        if !options.metadata.is_empty() {
353            query.push(format!("q={}", enc(&metadata_filter(&options.metadata)?)));
354        }
355        let path = if query.is_empty() {
356            "/sessions".to_string()
357        } else {
358            format!("/sessions?{}", query.join("&"))
359        };
360        let res = self.client.http.request("GET", &path, None).await?;
361        if !res.ok() {
362            return Err(api_error("GET", "/sessions", res.status, &res.body));
363        }
364        serde_json::from_str(&res.body)
365            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session list: {e}")))
366    }
367
368    /// `DELETE /sessions/:id` — release the session. A bare 404 is tolerated
369    /// (the session already ended).
370    ///
371    /// A 404 carrying [`SolariErrorCode::InvalidSessionId`] is an error. The
372    /// gateway acks 204 for any authentic session id, including one whose
373    /// session has already ended — the handler is idempotent by design and never
374    /// consults the pool before acking. So a 404 does not mean "already
375    /// released"; it means the gateway refused the id (malformed, forged, or
376    /// another org's) and released nothing, leaving the pool slot held until
377    /// orphan-grace. Treating that as success is what made these releases leak
378    /// slots silently.
379    ///
380    /// A bare 404 with no code stays tolerated — that is a pre-InvalidSessionId
381    /// gateway, where a 404 may legitimately mean the session is already gone.
382    /// Mirrors `releaseRejection` in sdk/src/index.ts.
383    ///
384    /// Unlike the TS SDK there is no fire-and-forget variant; this is the
385    /// equivalent of `releaseAndWait`. Spawn it yourself if you don't want to
386    /// await the round-trip.
387    pub async fn release(&self, id: &str) -> Result<(), SolariError> {
388        let path = format!("/sessions/{}", enc(id));
389        let res = self.client.http.request("DELETE", &path, None).await?;
390        if res.ok() {
391            return Ok(());
392        }
393        let err = api_error("DELETE", &path, res.status, &res.body);
394        if res.status == 404 && err.code() != Some(&SolariErrorCode::InvalidSessionId) {
395            return Ok(());
396        }
397        Err(err)
398    }
399
400    /// `GET /sessions/:id/replay-url` — presigned URL for the session's replay.
401    /// Available ~1-3s after [`Sessions::release`].
402    pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
403        let path = format!("/sessions/{}/replay-url", enc(id));
404        let res = self.client.http.request("GET", &path, None).await?;
405        if !res.ok() {
406            return Err(api_error("GET", &path, res.status, &res.body));
407        }
408        let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
409            SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
410        })?;
411        let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
412            SolariError::protocol(format!(
413                "Solari: unexpected replay-url response: {}",
414                res.body
415            ))
416        })?;
417        Ok(ReplayUrl {
418            url,
419            expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
420            content_encoding: data
421                .content_encoding
422                .unwrap_or_else(|| "gzip".to_string()),
423        })
424    }
425
426    /// Download the session's replay as NDJSON bytes. May or may not
427    /// actually be gzip despite what was uploaded — GCS decompresses
428    /// transparently on an ordinary GET, so check `content_encoding` (now
429    /// provider-accurate) before attempting to decompress.
430    pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
431        let replay = self.replay_url(id).await?;
432        let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
433        if !(200..300).contains(&status) {
434            return Err(SolariError::Api {
435                status,
436                code: None,
437                message: format!("Solari: replay download failed: {status}"),
438            });
439        }
440        Ok(bytes)
441    }
442
443    /// GET a presigned storage-state object. `None` url → `None` state.
444    async fn fetch_presigned_storage_state(
445        &self,
446        url: Option<&str>,
447    ) -> Result<Option<StorageState>, SolariError> {
448        let Some(url) = url.filter(|u| !u.is_empty()) else {
449            return Ok(None);
450        };
451        let res = self
452            .client
453            .http
454            .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
455            .await
456            .map_err(|e| {
457                SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
458            })?;
459        if !res.ok() {
460            let snippet: String = res.body.chars().take(256).collect();
461            return Err(SolariError::Api {
462                status: res.status,
463                code: None,
464                message: format!(
465                    "Solari: storageState fetch returned {}: {snippet}",
466                    res.status
467                ),
468            });
469        }
470        serde_json::from_str(&res.body).map(Some).map_err(|e| {
471            SolariError::protocol(format!(
472                "Solari: storageState response was not valid JSON: {e}"
473            ))
474        })
475    }
476}
477
478// ---------------------------------------------------------------------------
479// profiles
480// ---------------------------------------------------------------------------
481
482/// The `profiles` namespace. Obtain via [`Client::profiles`].
483pub struct Profiles<'a> {
484    client: &'a Client,
485}
486
487impl Profiles<'_> {
488    /// `GET /profiles`.
489    pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
490        let res = self.client.http.request("GET", "/profiles", None).await?;
491        if !res.ok() {
492            return Err(api_error("GET", "/profiles", res.status, &res.body));
493        }
494        serde_json::from_str(&res.body)
495            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
496    }
497
498    /// `POST /profiles`.
499    pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
500        let body = json!({ "name": name.into() });
501        let res = self
502            .client
503            .http
504            .request("POST", "/profiles", Some(&body))
505            .await?;
506        if !res.ok() {
507            return Err(api_error("POST", "/profiles", res.status, &res.body));
508        }
509        serde_json::from_str(&res.body)
510            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
511    }
512
513    /// `DELETE /profiles/:id`. 404 is tolerated (already gone).
514    pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
515        let path = format!("/profiles/{}", enc(id));
516        let res = self.client.http.request("DELETE", &path, None).await?;
517        if !res.ok() && res.status != 404 {
518            return Err(api_error("DELETE", &path, res.status, &res.body));
519        }
520        Ok(())
521    }
522
523    /// `POST /profiles/:id/save` — persist a storage state onto the profile.
524    pub async fn save(
525        &self,
526        id: &str,
527        storage_state: &StorageState,
528    ) -> Result<ProfileSaveResult, SolariError> {
529        let path = format!("/profiles/{}/save", enc(id));
530        let state = serde_json::to_value(storage_state)
531            .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
532        let body = json!({ "storageState": state });
533        let res = self.client.http.request("POST", &path, Some(&body)).await?;
534        if !res.ok() {
535            return Err(api_error("POST", &path, res.status, &res.body));
536        }
537        let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
538            SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
539        })?;
540        Ok(ProfileSaveResult {
541            version: data.version.unwrap_or(0),
542            size_bytes: data.size_bytes.unwrap_or(0),
543        })
544    }
545}
546
547// ---------------------------------------------------------------------------
548// proxy
549// ---------------------------------------------------------------------------
550
551/// The `proxy` namespace. Obtain via [`Client::proxy`].
552pub struct Proxy<'a> {
553    client: &'a Client,
554}
555
556impl Proxy<'_> {
557    /// `GET /proxy/countries` — supported egress countries, and whether managed
558    /// proxy egress is configured on this gateway at all.
559    pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
560        let res = self
561            .client
562            .http
563            .request("GET", "/proxy/countries", None)
564            .await?;
565        if !res.ok() {
566            return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
567        }
568        serde_json::from_str(&res.body).map_err(|e| {
569            SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
570        })
571    }
572}
573
574// ---------------------------------------------------------------------------
575// helpers
576// ---------------------------------------------------------------------------
577
578/// The most key/value pairs the gateway accepts in a list filter.
579const MAX_METADATA_CLAUSES: usize = 10;
580
581/// Render the gateway's list filter grammar: `user_metadata['key']:'value'`
582/// clauses joined by `AND`, in key order. A pair it cannot express is refused
583/// before any request is sent.
584fn metadata_filter(m: &BTreeMap<String, String>) -> Result<String, SolariError> {
585    if m.len() > MAX_METADATA_CLAUSES {
586        return Err(SolariError::config(format!(
587            "Solari: at most {MAX_METADATA_CLAUSES} metadata filter pairs"
588        )));
589    }
590    let mut clauses = Vec::with_capacity(m.len());
591    for (k, v) in m {
592        if k.is_empty() || k.chars().count() > 128 || k.contains('\'') || v.contains('\'') {
593            return Err(SolariError::config(format!(
594                "Solari: metadata filter key {k:?} or its value cannot be expressed (1-128 characters, no single quote)"
595            )));
596        }
597        clauses.push(format!("user_metadata['{k}']:'{v}'"));
598    }
599    Ok(clauses.join(" AND "))
600}
601
602/// Percent-encode a path segment. Session ids are HMAC composites (`.`-joined
603/// base64url) and profile ids are opaque, so only a conservative set of
604/// unreserved characters is passed through.
605fn enc(s: &str) -> String {
606    let mut out = String::with_capacity(s.len());
607    for b in s.bytes() {
608        match b {
609            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
610            | b'\'' | b'(' | b')' => out.push(b as char),
611            _ => out.push_str(&format!("%{b:02X}")),
612        }
613    }
614    out
615}
616
617/// Rewrite a Playwright ws endpoint into its raw-CDP sibling: `/ws/<id>` →
618/// `/cdp/<id>`. Mirrors `deriveCdpFromWs`; returns the input unchanged when it
619/// does not parse or does not match.
620pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
621    match Url::parse(ws_endpoint) {
622        Ok(mut u) => {
623            let path = u.path().to_string();
624            if let Some(rest) = path.strip_prefix("/ws/") {
625                u.set_path(&format!("/cdp/{rest}"));
626                u.to_string()
627            } else {
628                ws_endpoint.to_string()
629            }
630        }
631        Err(_) => ws_endpoint.to_string(),
632    }
633}
634
635/// An ISO-8601 UTC timestamp `d` from now — the fallback when the API omits
636/// `expiresAt` (TS: `new Date(Date.now() + 60*60_000).toISOString()`).
637fn iso8601_utc_in(d: Duration) -> String {
638    let now = std::time::SystemTime::now()
639        .duration_since(std::time::UNIX_EPOCH)
640        .map(|x| x.as_secs())
641        .unwrap_or(0);
642    iso8601_utc(now as i64 + d.as_secs() as i64)
643}
644
645/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SS.000Z`.
646pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
647    let days = unix_secs.div_euclid(86_400);
648    let sod = unix_secs.rem_euclid(86_400);
649    let (y, m, d) = civil_from_days(days);
650    format!(
651        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
652        y,
653        m,
654        d,
655        sod / 3600,
656        (sod % 3600) / 60,
657        sod % 60
658    )
659}
660
661/// Howard Hinnant's `civil_from_days`: days-since-epoch → (year, month, day).
662fn civil_from_days(z: i64) -> (i64, u32, u32) {
663    let z = z + 719_468;
664    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
665    let doe = z - era * 146_097; // [0, 146096]
666    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
667    let y = yoe + era * 400;
668    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
669    let mp = (5 * doy + 2) / 153; // [0, 11]
670    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
671    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
672    (if m <= 2 { y + 1 } else { y }, m, d)
673}