Skip to main content

grr_cli/
discovery.rs

1//! Discovery-driven API surface.
2//!
3//! Google's Discovery Service publishes a machine-readable description of
4//! every Workspace method: HTTP verb, path template, parameters and OAuth
5//! scopes. `scripts/fetch-discovery.mjs` distils those documents into a
6//! compact index (method id, verb, path, scopes, parameters) which this
7//! module embeds — 308 methods across ten services in ~350 KiB, versus
8//! several megabytes for the raw documents.
9//!
10//! This is the escape hatch that makes the curated commands unnecessary for
11//! coverage: `grr api call <service>.<resource>.<method>` reaches every
12//! method Google publishes, including ones that did not exist when grr was
13//! written, with no code change and no release.
14//!
15//! Two properties matter more than the coverage:
16//!
17//! 1. **One consent, everything works.** Every method carries its own scope
18//!    list — `grr api describe` reports it and a 403 names it — but the
19//!    login requests the union of all of them (`all_scopes` below), so a
20//!    method that exists is a method you can call without a second consent
21//!    round-trip. The index is the ceiling: whatever Google publishes next
22//!    is reachable, scope included, the day the discovery PR lands.
23//! 2. **Offline.** The index is compiled in, so `grr api list` and
24//!    `describe` work with no network and no cache directory.
25
26use serde::Deserialize;
27use std::collections::BTreeMap;
28use std::sync::OnceLock;
29
30/// `(filename, service name, api version, discovery url)` — the version is
31/// what callers need for display; the url is what `grr api refresh` fetches.
32const SERVICES: &[(&str, &str, &str, &str)] = &[
33    (
34        "gmail.json",
35        "gmail",
36        "v1",
37        "https://gmail.googleapis.com/$discovery/rest?version=v1",
38    ),
39    (
40        "calendar.json",
41        "calendar",
42        "v3",
43        "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
44    ),
45    (
46        "drive.json",
47        "drive",
48        "v3",
49        "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
50    ),
51    (
52        "people.json",
53        "people",
54        "v1",
55        "https://people.googleapis.com/$discovery/rest?version=v1",
56    ),
57    (
58        "chat.json",
59        "chat",
60        "v1",
61        "https://chat.googleapis.com/$discovery/rest?version=v1",
62    ),
63    (
64        "forms.json",
65        "forms",
66        "v1",
67        "https://forms.googleapis.com/$discovery/rest?version=v1",
68    ),
69    (
70        "tasks.json",
71        "tasks",
72        "v1",
73        "https://tasks.googleapis.com/$discovery/rest?version=v1",
74    ),
75    (
76        "docs.json",
77        "docs",
78        "v1",
79        "https://docs.googleapis.com/$discovery/rest?version=v1",
80    ),
81    (
82        "sheets.json",
83        "sheets",
84        "v4",
85        "https://sheets.googleapis.com/$discovery/rest?version=v4",
86    ),
87    (
88        "slides.json",
89        "slides",
90        "v1",
91        "https://slides.googleapis.com/$discovery/rest?version=v1",
92    ),
93    (
94        "script.json",
95        "script",
96        "v1",
97        "https://script.googleapis.com/$discovery/rest?version=v1",
98    ),
99    (
100        "analyticsadmin.json",
101        "analyticsadmin",
102        "v1beta",
103        "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta",
104    ),
105    (
106        "analyticsdata.json",
107        "analyticsdata",
108        "v1beta",
109        "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta",
110    ),
111    (
112        "searchconsole.json",
113        "searchconsole",
114        "v1",
115        "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
116    ),
117];
118
119#[derive(Debug, Clone, serde::Serialize, Deserialize)]
120pub struct Parameter {
121    pub name: String,
122    #[serde(rename = "type")]
123    pub kind: String,
124    pub required: bool,
125    #[serde(default)]
126    pub repeated: bool,
127    #[serde(default)]
128    pub location: Option<String>,
129    #[serde(default)]
130    pub description: String,
131    #[serde(default, rename = "enum")]
132    pub enum_values: Option<Vec<String>>,
133}
134
135#[derive(Debug, Clone, serde::Serialize, Deserialize)]
136pub struct Method {
137    pub id: String,
138    #[serde(rename = "httpMethod")]
139    pub http_method: String,
140    pub path: String,
141    #[serde(default)]
142    pub flat_path: Option<String>,
143    #[serde(default)]
144    pub description: String,
145    #[serde(default)]
146    pub scopes: Vec<String>,
147    #[serde(default)]
148    pub required: Vec<String>,
149    #[serde(default)]
150    pub parameters: Vec<Parameter>,
151}
152
153impl Method {
154    /// Narrowest scope this method accepts, or `None` for the public
155    /// methods that need no authorisation at all.
156    ///
157    /// Discovery lists scopes in descending breadth
158    /// (`https://mail.google.com/` first), and the trailing entries are the
159    /// narrow `.../auth/gmail.readonly` style. Preferring the last one keeps
160    /// a `describe` call from requesting full mailbox access.
161    pub fn least_privilege_scope(&self) -> Option<&str> {
162        self.scopes
163            .iter()
164            .rev()
165            .find(|s| s.starts_with("https://www.googleapis.com/auth/"))
166            .map(String::as_str)
167            .or_else(|| self.scopes.first().map(String::as_str))
168    }
169
170    pub fn parameter(&self, name: &str) -> Option<&Parameter> {
171        self.parameters.iter().find(|p| p.name == name)
172    }
173
174    /// Placeholders in the path template, e.g. `{userId}` in
175    /// `gmail/v1/users/{userId}/messages`.
176    ///
177    /// RFC 6570 reserved-expansion markers are stripped: `{+name}` still
178    /// refers to the parameter named `name` (Chat, People and Slides write
179    /// their resource-name paths that way), so the returned names line up
180    /// with Discovery's parameter list.
181    pub fn path_placeholders(&self) -> Vec<&str> {
182        let mut out = Vec::new();
183        let mut rest = self.path.as_str();
184        while let Some(start) = rest.find('{') {
185            let Some(end) = rest[start..].find('}') else {
186                break;
187            };
188            let mut name = &rest[start + 1..start + end];
189            if name.starts_with('+') {
190                name = &name[1..];
191            }
192            out.push(name);
193            rest = &rest[start + end + 1..];
194        }
195        out
196    }
197
198    /// The subset of [`Method::path_placeholders`] declared with RFC 6570
199    /// reserved expansion (`{+name}`): their values are full resource names
200    /// (`spaces/AAA/messages/BBB`) that must keep their slashes in the path.
201    pub fn reserved_path_placeholders(&self) -> Vec<&str> {
202        let mut out = Vec::new();
203        let mut rest = self.path.as_str();
204        while let Some(start) = rest.find('{') {
205            let Some(end) = rest[start..].find('}') else {
206                break;
207            };
208            let name = &rest[start + 1..start + end];
209            if let Some(stripped) = name.strip_prefix('+') {
210                out.push(stripped);
211            }
212            rest = &rest[start + end + 1..];
213        }
214        out
215    }
216}
217
218#[derive(Debug, Clone, serde::Serialize, Deserialize)]
219pub struct Service {
220    pub name: String,
221    pub version: String,
222    #[serde(default)]
223    pub revision: Option<String>,
224    #[serde(default)]
225    pub title: String,
226    #[serde(default, rename = "rootUrl")]
227    pub root_url: Option<String>,
228    #[serde(default, rename = "servicePath")]
229    pub service_path: String,
230    #[serde(default, rename = "basePath")]
231    pub base_path: String,
232    #[serde(default, rename = "batchPath")]
233    pub batch_path: Option<String>,
234    pub methods: Vec<Method>,
235}
236
237impl Service {
238    pub fn method(&self, id: &str) -> Option<&Method> {
239        self.methods.iter().find(|m| m.id == id)
240    }
241
242    /// Path prefix shared by every method, normalised to have **no** leading
243    /// slash.
244    ///
245    /// Discovery is inconsistent here: Gmail's `basePath` is `gmail/v1/`
246    /// while Drive's is `/drive/v3/`. Since every `rootUrl` ends in `/`,
247    /// concatenating naively yields `https://www.googleapis.com//drive/v3/...`
248    /// — a double slash that Google answers with a 404 rather than a
249    /// redirect. Strip it here, once, instead of at every call site.
250    pub fn base(&self) -> String {
251        let raw = if self.base_path.is_empty() {
252            self.service_path.as_str()
253        } else {
254            self.base_path.as_str()
255        };
256        raw.trim_start_matches('/').to_owned()
257    }
258}
259
260static SERVICES_CACHE: OnceLock<BTreeMap<&'static str, Service>> = OnceLock::new();
261
262/// Where `grr api refresh` stores fetched indexes, and where `services()`
263/// looks for a copy fresher than the embedded baseline.
264///
265/// `~/.grr/discovery/` — next to the config file, so uninstalling grr's
266/// data directory removes it too.
267pub fn cache_dir() -> Option<std::path::PathBuf> {
268    dirs::home_dir().map(|home| home.join(".grr").join("discovery"))
269}
270
271/// Google marks revisions with date-like strings (`20260923`), so a numeric
272/// comparison is exact; an unparseable revision ranks as zero, which makes a
273/// corrupt cache lose to any embedded baseline rather than win.
274fn revision_rank(revision: &str) -> u64 {
275    revision.trim().parse::<u64>().unwrap_or(0)
276}
277
278/// Every distinct scope the index names, sorted and deduped. The consent set
279/// is built from this (`core::auth::scopes`) instead of from a hand-typed
280/// list, so a scope Google adds arrives with the daily discovery PR rather
281/// than waiting for someone to notice a 403. Sorted, because the consent
282/// string must be byte-stable across runs.
283pub fn all_scopes() -> Vec<String> {
284    let mut scopes: Vec<String> = services()
285        .values()
286        .flat_map(|service| &service.methods)
287        .flat_map(|method| &method.scopes)
288        .cloned()
289        .collect();
290    scopes.sort_unstable();
291    scopes.dedup();
292    scopes
293}
294
295/// Every service, keyed by name: the embedded baseline overlaid with any
296/// fresher on-disk cache. Parsed once, on first use.
297///
298/// This is the "best of both worlds": the embedded index keeps `grr api`
299/// instant and offline-capable, while a cache written by `grr api refresh`
300/// (or by a nightly CI run that shipped in a newer release) is preferred
301/// whenever its revision is newer. No network I/O happens here — implicit
302/// use must never block or fail on connectivity — so a user who never
303/// refreshes still gets the compile-time baseline.
304pub fn services() -> &'static BTreeMap<&'static str, Service> {
305    SERVICES_CACHE.get_or_init(|| {
306        let mut map: BTreeMap<&'static str, Service> = BTreeMap::new();
307        for (file, name, version, _url) in SERVICES {
308            // Every SERVICES entry needs its own arm: the wildcard below is
309            // a tripwire, not a fallback — a new service added to SERVICES
310            // without an arm would otherwise silently load the LAST file's
311            // content (four services once presented as Google Slides).
312            let body = match *file {
313                "gmail.json" => include_str!("discovery/gmail.json"),
314                "calendar.json" => include_str!("discovery/calendar.json"),
315                "drive.json" => include_str!("discovery/drive.json"),
316                "people.json" => include_str!("discovery/people.json"),
317                "chat.json" => include_str!("discovery/chat.json"),
318                "forms.json" => include_str!("discovery/forms.json"),
319                "tasks.json" => include_str!("discovery/tasks.json"),
320                "docs.json" => include_str!("discovery/docs.json"),
321                "sheets.json" => include_str!("discovery/sheets.json"),
322                "slides.json" => include_str!("discovery/slides.json"),
323                "script.json" => include_str!("discovery/script.json"),
324                "analyticsadmin.json" => include_str!("discovery/analyticsadmin.json"),
325                "analyticsdata.json" => include_str!("discovery/analyticsdata.json"),
326                "searchconsole.json" => include_str!("discovery/searchconsole.json"),
327                _ => panic!("no embedded discovery index for {file}; add an include_str! arm"),
328            };
329            let embedded = match serde_json::from_str::<Service>(body) {
330                Ok(mut service) => {
331                    // The filename is the source of truth for the name and
332                    // version; trust it over whatever the document claimed so
333                    // a mislabelled upstream doc cannot desync the two.
334                    service.name = (*name).to_owned();
335                    service.version = (*version).to_owned();
336                    service
337                }
338                Err(error) => {
339                    // A malformed embedded index is a build defect, not a
340                    // runtime condition: fail loudly rather than silently
341                    // dropping a service.
342                    panic!("embedded discovery index {file} failed to parse: {error}");
343                }
344            };
345
346            let fresher = cache_dir().and_then(|dir| {
347                let path = dir.join(format!("{name}.json"));
348                let Ok(body) = std::fs::read_to_string(&path) else {
349                    return None;
350                };
351                match serde_json::from_str::<Service>(&body) {
352                    // A corrupt cache must never take the index down: fall
353                    // back to the embedded baseline and say so once.
354                    Ok(cached)
355                        if revision_rank(cached.revision.as_deref().unwrap_or(""))
356                            > revision_rank(embedded.revision.as_deref().unwrap_or("")) =>
357                    {
358                        Some(cached)
359                    }
360                    Ok(_) => None,
361                    Err(error) => {
362                        tracing::warn!(
363                            "discovery cache {} is corrupt ({error}); using the embedded index",
364                            path.display()
365                        );
366                        None
367                    }
368                }
369            });
370
371            map.insert(name, fresher.unwrap_or(embedded));
372        }
373        map
374    })
375}
376
377/// Resolve `service.resource.method` (or `service..method` for the few
378/// top-level methods that have no resource).
379pub fn resolve(id: &str) -> Result<(&'static Service, &'static Method), String> {
380    let id = id.trim().trim_start_matches('.').replace('/', ".");
381    let (service_name, method_id) = id.split_once('.').ok_or_else(|| {
382        format!("`{id}` is not a method id; expected <service>.<resource>.<method>")
383    })?;
384    let service = services().get(service_name).ok_or_else(|| {
385        let known: Vec<&str> = services().keys().copied().collect();
386        format!(
387            "unknown service `{service_name}`; known: {}",
388            known.join(", ")
389        )
390    })?;
391    let method = service.method(method_id).ok_or_else(|| {
392        let mut ids: Vec<&str> = service.methods.iter().map(|m| m.id.as_str()).collect();
393        ids.sort_unstable();
394        // A near-miss is nearly always a typo in the *method* name, not the
395        // resource path, so match on the last segment. Ranking matters:
396        // `users.messages.list` and `users.drafts.list` are both one edit
397        // from `lst`, and only the resource path tells them apart.
398        let wanted = method_id.rsplit('.').next().unwrap_or(method_id);
399        let mut scored: Vec<(usize, std::cmp::Reverse<usize>, &str)> = ids
400            .iter()
401            .copied()
402            .filter_map(|candidate| {
403                let actual = candidate.rsplit('.').next().unwrap_or(candidate);
404                let bucket = if actual == wanted {
405                    0
406                } else if actual.starts_with(wanted) || wanted.starts_with(actual) {
407                    1
408                } else if edit_distance_at_most(actual, wanted, 1) {
409                    // Limit 1, not 2: `lst` is one edit from `list` but two
410                    // from `get`, and admitting distance 2 turns every short
411                    // method name into a candidate.
412                    2
413                } else {
414                    return None;
415                };
416                // Prefer the candidate sharing the most of the resource path
417                // (Reverse: longer shared prefix sorts first).
418                Some((
419                    bucket,
420                    std::cmp::Reverse(shared_prefix(candidate, method_id)),
421                    candidate,
422                ))
423            })
424            .collect();
425        scored.sort();
426        let mut near: Vec<&str> = scored
427            .into_iter()
428            .map(|(_, _, candidate)| candidate)
429            .collect();
430        near.truncate(5);
431        if near.is_empty() {
432            format!(
433                "unknown method `{method_id}` in `{service_name}` ({} methods available)",
434                service.methods.len()
435            )
436        } else {
437            format!(
438                "unknown method `{method_id}`; did you mean: {}",
439                near.join(", ")
440            )
441        }
442    })?;
443    Ok((service, method))
444}
445
446/// Length of the longest common prefix.
447fn shared_prefix(left: &str, right: &str) -> usize {
448    left.chars()
449        .zip(right.chars())
450        .take_while(|(a, b)| a == b)
451        .count()
452}
453
454/// Distil a raw Google Discovery document into the compact index this module
455/// embeds.
456///
457/// This is the Rust twin of `collect()` in `scripts/fetch-discovery.mjs`,
458/// which produces the committed baseline. Both must follow the same rules or
459/// a refreshed cache would disagree with the embedded index in shape. The
460/// rules that matter:
461///   - walk `resources` ENTRIES (not values): the resource name prefixes the
462///     method id;
463///   - descriptions are whitespace-collapsed and truncated (the raw Gmail doc
464///     alone is ~1.5 MB);
465///   - required parameters are the ones `parameterOrder` names;
466///   - parameters sort by name, methods sort by id — determinism keeps the
467///     nightly refresh diff quiet.
468pub(crate) fn distill(doc: &serde_json::Value) -> Result<Vec<Method>, String> {
469    let mut out = Vec::new();
470    let resources = doc
471        .get("resources")
472        .and_then(|r| r.as_object())
473        .ok_or_else(|| "document has no resources object".to_owned())?;
474
475    fn walk(node: &serde_json::Value, path: &str, out: &mut Vec<Method>) {
476        let Some(object) = node.as_object() else {
477            return;
478        };
479        if let Some(methods) = object.get("methods").and_then(|m| m.as_object()) {
480            for (name, method) in methods {
481                let id = if path.is_empty() {
482                    name.clone()
483                } else {
484                    format!("{path}.{name}")
485                };
486                let http_method = method
487                    .get("httpMethod")
488                    .and_then(|v| v.as_str())
489                    .unwrap_or("GET")
490                    .to_owned();
491                let path_template = method
492                    .get("path")
493                    .and_then(|v| v.as_str())
494                    .unwrap_or("")
495                    .to_owned();
496                let flat_path = method
497                    .get("flatPath")
498                    .and_then(|v| v.as_str())
499                    .map(str::to_owned);
500                let description = method
501                    .get("description")
502                    .and_then(|v| v.as_str())
503                    .unwrap_or("")
504                    .split_whitespace()
505                    .collect::<Vec<_>>()
506                    .join(" ");
507                let description = description.chars().take(300).collect::<String>();
508                let scopes = method
509                    .get("scopes")
510                    .and_then(|v| v.as_array())
511                    .map(|a| {
512                        a.iter()
513                            .filter_map(|s| s.as_str().map(str::to_owned))
514                            .collect::<Vec<String>>()
515                    })
516                    .unwrap_or_default();
517                let required = method
518                    .get("parameterOrder")
519                    .and_then(|v| v.as_array())
520                    .map(|a| {
521                        a.iter()
522                            .filter_map(|s| s.as_str().map(str::to_owned))
523                            .collect::<Vec<String>>()
524                    })
525                    .unwrap_or_default();
526
527                let mut parameters: Vec<Parameter> = method
528                    .get("parameters")
529                    .and_then(|p| p.as_object())
530                    .map(|params| {
531                        params
532                            .iter()
533                            .map(|(pname, p)| Parameter {
534                                name: pname.clone(),
535                                kind: p
536                                    .get("type")
537                                    .and_then(|v| v.as_str())
538                                    .unwrap_or("string")
539                                    .to_owned(),
540                                required: required.iter().any(|r| r == pname),
541                                repeated: p
542                                    .get("repeated")
543                                    .and_then(|v| v.as_bool())
544                                    .unwrap_or(false),
545                                location: p
546                                    .get("location")
547                                    .and_then(|v| v.as_str())
548                                    .map(str::to_owned),
549                                description: {
550                                    let text = p
551                                        .get("description")
552                                        .and_then(|v| v.as_str())
553                                        .unwrap_or("")
554                                        .split_whitespace()
555                                        .collect::<Vec<_>>()
556                                        .join(" ");
557                                    text.chars().take(160).collect::<String>()
558                                },
559                                enum_values: p.get("enum").and_then(|v| v.as_array()).map(|a| {
560                                    a.iter()
561                                        .filter_map(|s| s.as_str().map(str::to_owned))
562                                        .collect()
563                                }),
564                            })
565                            .collect()
566                    })
567                    .unwrap_or_default();
568                parameters.sort_by(|a, b| a.name.cmp(&b.name));
569
570                out.push(Method {
571                    id,
572                    http_method,
573                    path: path_template,
574                    flat_path,
575                    description,
576                    scopes,
577                    required,
578                    parameters,
579                });
580            }
581        }
582        if let Some(nested) = object.get("resources").and_then(|r| r.as_object()) {
583            for (rname, resource) in nested {
584                let child = if path.is_empty() {
585                    rname.clone()
586                } else {
587                    format!("{path}.{rname}")
588                };
589                walk(resource, &child, out);
590            }
591        }
592    }
593
594    for (name, resource) in resources {
595        walk(resource, name, &mut out);
596    }
597    out.sort_by(|a, b| a.id.cmp(&b.id));
598    Ok(out)
599}
600
601/// Fetch fresh Discovery documents and write them to the cache directory.
602///
603/// The explicit, network path — `grr api refresh`. Everything else in this
604/// module works offline off the embedded baseline; this is how a user (or a
605/// nightly CI run) pulls the index forward between releases. On any failure
606/// for one service, the embedded baseline simply stays in place for it: a
607/// refresh must never leave the index WORSE than it was.
608pub async fn refresh(filter: Option<&str>) -> Result<Vec<RefreshReport>, String> {
609    use crate::core::http::build_http_client;
610
611    let client = build_http_client().map_err(|e| e.to_string())?;
612    let Some(dir) = cache_dir() else {
613        return Err("could not find your home directory".to_owned());
614    };
615    tokio::fs::create_dir_all(&dir)
616        .await
617        .map_err(|e| e.to_string())?;
618
619    // The services are independent: every fetch runs as its own task instead
620    // of serialising 14 round-trips. Reports are re-sorted into SERVICES
621    // order below, so the CLI output stays deterministic regardless of
622    // completion order.
623    let mut tasks = tokio::task::JoinSet::new();
624    let mut wanted = 0usize;
625    for (file, name, version, url) in SERVICES {
626        if let Some(wanted_name) = filter
627            && !name.eq_ignore_ascii_case(wanted_name)
628        {
629            continue;
630        }
631        wanted += 1;
632        let client = client.clone();
633        let dir = dir.clone();
634        tasks.spawn(async move { fetch_one(&client, file, name, version, url, &dir).await });
635    }
636    if wanted == 0 {
637        return Ok(Vec::new());
638    }
639
640    let mut reports = Vec::with_capacity(wanted);
641    while let Some(joined) = tasks.join_next().await {
642        match joined {
643            Ok(Ok(report)) => reports.push(report),
644            Ok(Err(e)) => {
645                // Preserve the historical all-or-nothing contract: a failed
646                // service aborts the refresh (the serial loop's `?` used to
647                // propagate it) and the remaining tasks are told to stop.
648                tasks.abort_all();
649                return Err(e);
650            }
651            Err(join_error) => {
652                tasks.abort_all();
653                return Err(format!("refresh task failed: {join_error}"));
654            }
655        }
656    }
657    reports.sort_by_key(|report| {
658        SERVICES
659            .iter()
660            .position(|(_, name, _, _)| *name == report.service)
661            .unwrap_or(usize::MAX)
662    });
663    Ok(reports)
664}
665
666/// Fetch, distil and cache one service. Split out of [`refresh`] so all the
667/// services can be in flight at once.
668async fn fetch_one(
669    client: &reqwest::Client,
670    file: &str,
671    name: &str,
672    version: &str,
673    url: &str,
674    dir: &std::path::Path,
675) -> Result<RefreshReport, String> {
676    let body = client
677        .get(url::Url::parse(url).map_err(|e| format!("{name}: bad discovery url: {e}"))?)
678        .send()
679        .await
680        .map_err(|e| format!("{name}: {e}"))?
681        .text()
682        .await
683        .map_err(|e| format!("{name}: {e}"))?;
684
685    let doc: serde_json::Value = serde_json::from_str(&body)
686        .map_err(|e| format!("{name}: discovery response is not JSON: {e}"))?;
687    let revision = doc
688        .get("revision")
689        .and_then(|v| v.as_str())
690        .unwrap_or("")
691        .to_owned();
692
693    // The embedded revision for this service, for the comparison report.
694    let embedded_revision = services()
695        .get(name)
696        .and_then(|s| s.revision.clone())
697        .unwrap_or_default();
698
699    // A stale or failed refresh must not take the index down: only write
700    // the cache when the fetched document distils cleanly AND is not
701    // older than what is already in use.
702    let methods = distill(&doc)?;
703    if revision_rank(&revision) < revision_rank(&embedded_revision) {
704        return Ok(RefreshReport {
705            service: name.to_owned(),
706            from: embedded_revision,
707            to: revision,
708            methods: methods.len(),
709            wrote_cache: false,
710            skipped_older: true,
711        });
712    }
713
714    let index = Service {
715        name: name.to_owned(),
716        version: version.to_owned(),
717        revision: Some(revision.clone()),
718        title: doc
719            .get("title")
720            .and_then(|v| v.as_str())
721            .unwrap_or(name)
722            .to_owned(),
723        root_url: doc
724            .get("rootUrl")
725            .and_then(|v| v.as_str())
726            .map(str::to_owned),
727        service_path: doc
728            .get("servicePath")
729            .and_then(|v| v.as_str())
730            .unwrap_or("")
731            .to_owned(),
732        base_path: doc
733            .get("basePath")
734            .and_then(|v| v.as_str())
735            .unwrap_or("")
736            .to_owned(),
737        batch_path: doc
738            .get("batchPath")
739            .and_then(|v| v.as_str())
740            .map(str::to_owned),
741        methods,
742    };
743    let json = serde_json::to_string(&index).map_err(|e| e.to_string())?;
744    let path = dir.join(file);
745    tokio::fs::write(&path, json)
746        .await
747        .map_err(|e| format!("{}: {e}", path.display()))?;
748
749    Ok(RefreshReport {
750        service: name.to_owned(),
751        from: embedded_revision,
752        to: revision,
753        methods: index.methods.len(),
754        wrote_cache: true,
755        skipped_older: false,
756    })
757}
758
759/// One service's refresh outcome, for the `grr api refresh` report.
760#[derive(Debug, Clone, serde::Serialize)]
761pub struct RefreshReport {
762    pub service: String,
763    pub from: String,
764    pub to: String,
765    pub methods: usize,
766    pub wrote_cache: bool,
767    pub skipped_older: bool,
768}
769
770/// Levenshtein distance, abandoned as soon as it exceeds `limit`.
771///
772/// Method names are short, so the quadratic table is free; the early exit
773/// keeps the whole 308-method scan cheap.
774fn edit_distance_at_most(left: &str, right: &str, limit: usize) -> bool {
775    let a: Vec<char> = left.chars().collect();
776    let b: Vec<char> = right.chars().collect();
777    if a.len().abs_diff(b.len()) > limit {
778        return false;
779    }
780    let mut previous: Vec<usize> = (0..=b.len()).collect();
781    let mut current = vec![0usize; b.len() + 1];
782    for i in 1..=a.len() {
783        current[0] = i;
784        let mut row_best = current[0];
785        for j in 1..=b.len() {
786            let cost = usize::from(a[i - 1] != b[j - 1]);
787            current[j] = (previous[j] + 1)
788                .min(current[j - 1] + 1)
789                .min(previous[j - 1] + cost);
790            row_best = row_best.min(current[j]);
791        }
792        if row_best > limit {
793            return false;
794        }
795        std::mem::swap(&mut previous, &mut current);
796    }
797    previous[b.len()] <= limit
798}
799
800/// Expand a discovery path template into a URL path, substituting `{...}`
801/// placeholders from the supplied parameters.
802///
803/// Returns an error naming the missing placeholder rather than sending a
804/// request with a literal `{userId}` in it — which Google answers with an
805/// opaque 404.
806///
807/// `{+name}` placeholders (RFC 6570 reserved expansion) keep their value's
808/// slashes and other path characters, per the expansion's meaning: a Chat
809/// space name like `spaces/AAA` must remain a two-segment path, not a
810/// percent-encoded blob.
811pub fn build_path(
812    service: &Service,
813    method: &Method,
814    params: &serde_json::Map<String, serde_json::Value>,
815) -> Result<String, String> {
816    let reserved = method.reserved_path_placeholders();
817    let mut path = method.path.clone();
818    for placeholder in method.path_placeholders() {
819        let Some(value) = params.get(placeholder) else {
820            let known: Vec<&str> = method
821                .parameters
822                .iter()
823                .filter(|p| p.required)
824                .map(|p| p.name.as_str())
825                .collect();
826            return Err(format!(
827                "`{}` needs the `{placeholder}` parameter (path). Required: {}",
828                method.id,
829                if known.is_empty() {
830                    "none".to_owned()
831                } else {
832                    known.join(", ")
833                }
834            ));
835        };
836        // Path values must be percent-encoded: ids legitimately contain
837        // spaces, slashes and unicode (Drive file names, Gmail labels).
838        // Reserved-expansion values are the documented exception — their
839        // `/` separators ARE the path structure.
840        let text = value_to_string(value);
841        let encoded = if reserved.contains(&placeholder) {
842            encode_path_reserved(&text)
843        } else {
844            urlencoding::encode(&text).into_owned()
845        };
846        path = path
847            .replace(&format!("{{{placeholder}}}"), &encoded)
848            .replace(&format!("{{+{placeholder}}}"), &encoded);
849    }
850    Ok(format!(
851        "{}{}",
852        service.base(),
853        path.trim_start_matches('/')
854    ))
855}
856
857/// Percent-encode a path value for RFC 6570 reserved expansion: keep every
858/// `pchar` (path character) — most importantly `/`, so Google's full
859/// resource names (`spaces/AAA/messages/BBB`) stay a real path — and
860/// encode the rest (spaces, `?`, `#`, `%`, non-ASCII, …).
861fn encode_path_reserved(value: &str) -> String {
862    let mut out = String::with_capacity(value.len());
863    for byte in value.bytes() {
864        match byte {
865            b'A'..=b'Z'
866            | b'a'..=b'z'
867            | b'0'..=b'9'
868            | b'-'
869            | b'.'
870            | b'_'
871            | b'~'
872            | b'/'
873            | b':'
874            | b'@'
875            | b'!'
876            | b'$'
877            | b'&'
878            | b'\''
879            | b'('
880            | b')'
881            | b'*'
882            | b'+'
883            | b','
884            | b';'
885            | b'=' => {
886                out.push(byte as char);
887            }
888            _ => {
889                // Hand-rolled hex instead of format!(): this runs for every
890                // non-pchar byte of every encoded path value.
891                const HEX: &[u8; 16] = b"0123456789ABCDEF";
892                out.push('%');
893                out.push(HEX[(byte >> 4) as usize] as char);
894                out.push(HEX[(byte & 0x0F) as usize] as char);
895            }
896        }
897    }
898    out
899}
900
901pub fn value_to_string(value: &serde_json::Value) -> String {
902    match value {
903        serde_json::Value::String(s) => s.clone(),
904        serde_json::Value::Null => String::new(),
905        other => other.to_string(),
906    }
907}
908
909/// Render a parameter map as a query string, skipping path parameters
910/// (already substituted) and honouring `repeated: true` for multi-valued
911/// params such as Gmail's `labelIds`.
912pub fn build_query(method: &Method, params: &serde_json::Map<String, serde_json::Value>) -> String {
913    let placeholders = method.path_placeholders();
914    let mut pairs: Vec<(String, String)> = Vec::new();
915    for (key, value) in params {
916        if placeholders.contains(&key.as_str()) {
917            continue;
918        }
919        if let Some(param) = method.parameter(key)
920            && param.location.as_deref() == Some("path")
921        {
922            continue;
923        }
924        match value {
925            serde_json::Value::Array(items) => {
926                for item in items {
927                    pairs.push((key.clone(), value_to_string(item)));
928                }
929            }
930            serde_json::Value::Null => {}
931            other => pairs.push((key.clone(), value_to_string(other))),
932        }
933    }
934    if pairs.is_empty() {
935        return String::new();
936    }
937    pairs.sort();
938    // One pre-sized buffer instead of N format! strings plus a Vec plus
939    // join's result — this runs per request URL.
940    let mut query = String::with_capacity(pairs.len() * 16);
941    for (k, v) in &pairs {
942        if !query.is_empty() {
943            query.push('&');
944        }
945        query.push_str(&urlencoding::encode(k));
946        query.push('=');
947        query.push_str(&urlencoding::encode(v));
948    }
949    format!("?{query}")
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    #[test]
957    fn every_service_parses_and_has_methods() {
958        let all = services();
959        assert_eq!(all.len(), SERVICES.len());
960        for (name, service) in all {
961            assert!(!service.methods.is_empty(), "{name} has no methods");
962        }
963    }
964
965    #[test]
966    fn the_index_covers_the_whole_workspace_surface() {
967        // The point of the dynamic path is coverage. If Google's docs change
968        // shape and we silently parse zero methods, this is what catches it.
969        let total: usize = services().values().map(|s| s.methods.len()).sum();
970        assert!(
971            total >= 300,
972            "expected 300+ methods across the Workspace APIs, found {total}"
973        );
974    }
975
976    #[test]
977    fn every_service_embeds_its_own_index_not_its_neighbors() {
978        // The include_str! match arms must cover every SERVICES entry. The
979        // wildcard once mapped the four newer services onto the Slides
980        // content — inflating the total and only surfacing when `grr ask`
981        // derived per-service rubrics. Distinct method-id sets catch it.
982        let all = services();
983        let distinct: std::collections::BTreeSet<String> = all
984            .values()
985            .map(|s| {
986                s.methods
987                    .iter()
988                    .map(|m| m.id.as_str())
989                    .collect::<Vec<_>>()
990                    .join(",")
991            })
992            .collect();
993        assert_eq!(
994            distinct.len(),
995            all.len(),
996            "two services share a method-id set — an include_str! arm is missing"
997        );
998    }
999
1000    #[test]
1001    fn resolves_a_nested_method_id() {
1002        let (service, method) =
1003            resolve("gmail.users.messages.list").expect("gmail.users.messages.list");
1004        assert_eq!(service.name, "gmail");
1005        assert_eq!(method.http_method, "GET");
1006        assert_eq!(method.path, "gmail/v1/users/{userId}/messages");
1007    }
1008
1009    #[test]
1010    fn resolves_a_method_without_an_intermediate_resource() {
1011        // `users.getProfile` sits directly on the users resource, so there is
1012        // only one dot before the method name.
1013        let (service, method) = resolve("gmail.users.getProfile").expect("gmail.users.getProfile");
1014        assert_eq!(service.name, "gmail");
1015        assert_eq!(method.http_method, "GET");
1016    }
1017
1018    #[test]
1019    fn accepts_a_leading_dot_and_slashes() {
1020        assert!(resolve(".gmail.users.messages.list").is_ok());
1021        assert!(resolve("gmail/users/messages/list").is_ok());
1022    }
1023
1024    #[test]
1025    fn unknown_service_and_method_are_actionable() {
1026        // A typo close to a real method gets a concrete suggestion.
1027        let err = resolve("gmail.users.messages.lst").unwrap_err();
1028        assert!(err.contains("did you mean"), "unhelpful: {err}");
1029        let err = resolve("nope.thing.method").unwrap_err();
1030        assert!(err.contains("known:"), "should list services: {err}");
1031        let err = resolve("gmailonly").unwrap_err();
1032        assert!(err.contains("expected <service>"), "unhelpful: {err}");
1033    }
1034
1035    #[test]
1036    fn all_scopes_is_the_sorted_union_of_every_method() {
1037        let union = all_scopes();
1038        assert!(
1039            union.windows(2).all(|pair| pair[0] < pair[1]),
1040            "the union must be sorted and deduped"
1041        );
1042        for service in services().values() {
1043            for method in &service.methods {
1044                for scope in &method.scopes {
1045                    assert!(
1046                        union.binary_search(scope).is_ok(),
1047                        "{scope} is not in the union"
1048                    );
1049                }
1050            }
1051        }
1052    }
1053
1054    #[test]
1055    fn the_consent_set_contains_every_indexed_scope() {
1056        // The login requests this set in full, so a scope that is indexed but
1057        // not consented is a method that 403s on first use — the exact
1058        // failure mode the whole design exists to prevent.
1059        let consent = crate::core::auth::scopes();
1060        for scope in all_scopes() {
1061            assert!(
1062                consent.contains(&scope),
1063                "{scope} is indexed but not consented"
1064            );
1065        }
1066        // The curated floor survives alongside the index union.
1067        for scope in [
1068            "https://mail.google.com/",
1069            "https://www.googleapis.com/auth/gmail.readonly",
1070            "https://www.googleapis.com/auth/webmasters.readonly",
1071        ] {
1072            assert!(
1073                consent.iter().any(|consented| consented == scope),
1074                "{scope} was dropped from the consent set"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn least_privilege_scope_prefers_the_narrow_one() {
1081        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1082        // gmail.users.messages.list accepts full-mailbox access first in the
1083        // discovery list; we must not pick that.
1084        let scope = method.least_privilege_scope().expect("a scope");
1085        assert!(scope.ends_with("gmail.readonly"), "picked {scope}");
1086    }
1087
1088    #[test]
1089    fn required_parameters_are_flagged() {
1090        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1091        let user_id = method.parameter("userId").expect("userId param");
1092        assert!(user_id.required);
1093        let max = method.parameter("maxResults").expect("maxResults param");
1094        assert!(!max.required);
1095    }
1096
1097    #[test]
1098    fn builds_a_path_and_reports_missing_placeholders() {
1099        let (service, method) = resolve("gmail.users.messages.get").unwrap();
1100        let mut params = serde_json::Map::new();
1101        params.insert("id".into(), serde_json::json!("m1"));
1102
1103        // A missing path parameter must name itself rather than produce a
1104        // request with a literal brace in the URL.
1105        let err = build_path(service, method, &params).unwrap_err();
1106        assert!(err.contains("userId"), "unhelpful: {err}");
1107
1108        params.insert("userId".into(), serde_json::json!("me"));
1109        assert_eq!(
1110            build_path(service, method, &params).unwrap(),
1111            "gmail/v1/users/me/messages/m1"
1112        );
1113    }
1114
1115    #[test]
1116    fn path_values_are_percent_encoded() {
1117        let (service, method) = resolve("drive.files.get").unwrap();
1118        let mut params = serde_json::Map::new();
1119        params.insert("fileId".into(), serde_json::json!("a b/c+d"));
1120        let path = build_path(service, method, &params).unwrap();
1121        assert!(!path.contains(' '), "space leaked into {path}");
1122        assert!(path.contains("a%20b%2Fc%2Bd"), "not encoded: {path}");
1123    }
1124
1125    #[test]
1126    fn query_excludes_path_params_and_expands_arrays() {
1127        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1128        let mut params = serde_json::Map::new();
1129        params.insert("userId".into(), serde_json::json!("me"));
1130        params.insert("q".into(), serde_json::json!("is:unread"));
1131        params.insert("labelIds".into(), serde_json::json!(["INBOX", "UNREAD"]));
1132
1133        let query = build_query(method, &params);
1134        assert!(!query.contains("userId"), "path param leaked: {query}");
1135        assert!(query.contains("q=is%3Aunread"), "{query}");
1136        assert!(query.contains("labelIds=INBOX"), "{query}");
1137        assert!(query.contains("labelIds=UNREAD"), "{query}");
1138    }
1139
1140    #[test]
1141    fn query_is_empty_when_only_path_params_are_given() {
1142        let (_, method) = resolve("gmail.users.messages.list").unwrap();
1143        let mut params = serde_json::Map::new();
1144        params.insert("userId".into(), serde_json::json!("me"));
1145        assert!(build_query(method, &params).is_empty());
1146    }
1147
1148    #[test]
1149    fn reserved_expansion_placeholders_match_their_parameters() {
1150        // Chat writes paths as `v1/{+name}` while the parameter is `name`:
1151        // the placeholder must resolve by its real name, not `+name`.
1152        let (service, method) = resolve("chat.spaces.get").unwrap();
1153        assert_eq!(method.path_placeholders(), ["name"]);
1154        assert_eq!(method.reserved_path_placeholders(), ["name"]);
1155
1156        let mut params = serde_json::Map::new();
1157        params.insert("name".into(), serde_json::json!("spaces/AAA"));
1158        let path = build_path(service, method, &params).unwrap();
1159        // The slash IS the path structure: reserved expansion keeps it.
1160        assert_eq!(path, "v1/spaces/AAA");
1161    }
1162
1163    #[test]
1164    fn reserved_expansion_only_encodes_non_path_characters() {
1165        let (service, method) = resolve("chat.spaces.get").unwrap();
1166        let mut params = serde_json::Map::new();
1167        params.insert("name".into(), serde_json::json!("spaces/A B?C"));
1168        let path = build_path(service, method, &params).unwrap();
1169        assert_eq!(path, "v1/spaces/A%20B%3FC");
1170    }
1171
1172    #[test]
1173    fn reserved_expansion_placeholders_do_not_leak_into_the_query() {
1174        // `name` was consumed by the path, so it must not also appear as a
1175        // query pair (a stale `{+name}` key used to hide it from the check).
1176        let (_, method) = resolve("chat.spaces.get").unwrap();
1177        let mut params = serde_json::Map::new();
1178        params.insert("name".into(), serde_json::json!("spaces/AAA"));
1179        assert!(build_query(method, &params).is_empty());
1180    }
1181
1182    #[test]
1183    fn plain_placeholders_still_percent_encode_slashes() {
1184        // Non-reserved placeholders keep the strict component encoding:
1185        // a Drive-style id with a slash must never smuggle in extra path
1186        // segments.
1187        let (service, method) = resolve("gmail.users.messages.get").unwrap();
1188        let mut params = serde_json::Map::new();
1189        params.insert("userId".into(), serde_json::json!("me"));
1190        params.insert("id".into(), serde_json::json!("a/b"));
1191        assert!(
1192            build_path(service, method, &params)
1193                .unwrap()
1194                .ends_with("a%2Fb")
1195        );
1196    }
1197
1198    #[test]
1199    fn every_service_base_has_no_leading_slash() {
1200        // rootUrl always ends in '/', so a leading slash on basePath yields
1201        // '//' and a 404 from Google. Gmail and Drive disagree here, which is
1202        // exactly why this is asserted rather than assumed.
1203        for (name, service) in services() {
1204            assert!(
1205                !service.base().starts_with('/'),
1206                "{name} base starts with a slash: {:?}",
1207                service.base()
1208            );
1209            let root = service.root_url.clone().unwrap_or_default();
1210            let sample = format!("{}{}", root, service.base());
1211            // Skip the scheme: "https://" is a legitimate double slash.
1212            let after_scheme = sample
1213                .split_once("://")
1214                .map(|(_, rest)| rest)
1215                .unwrap_or(&sample);
1216            assert!(
1217                !after_scheme.starts_with('/'),
1218                "{name} would build {sample}"
1219            );
1220        }
1221    }
1222
1223    #[test]
1224    fn method_ids_include_their_resource_path() {
1225        // Regression: the distiller once dropped the root resource name,
1226        // producing `messages.list` instead of `users.messages.list`.
1227        let (service, method) = resolve("gmail.users.messages.list").unwrap();
1228        assert_eq!(method.id, "users.messages.list");
1229        assert!(service.methods.iter().any(|m| m.id == "users.threads.get"));
1230    }
1231
1232    #[test]
1233    fn edit_distance_gate_behaves() {
1234        assert!(edit_distance_at_most("list", "lst", 2));
1235        assert!(edit_distance_at_most("files", "file", 2));
1236        assert!(!edit_distance_at_most("list", "trash", 2));
1237        // Length guard must reject before allocating.
1238        assert!(!edit_distance_at_most("a", "abcdefghij", 2));
1239    }
1240
1241    #[test]
1242    fn a_one_character_typo_ranks_the_real_method_first() {
1243        let err = resolve("gmail.users.messages.lst").unwrap_err();
1244        // Alphabetical ordering used to push the answer past the 5-item cut.
1245        let first = err
1246            .split("did you mean:")
1247            .nth(1)
1248            .expect("suggestion list")
1249            .trim()
1250            .lines()
1251            .next()
1252            .unwrap_or("")
1253            .trim()
1254            .trim_end_matches('.');
1255        assert!(
1256            first.contains("users.messages.list"),
1257            "best suggestion was `{first}` in: {err}"
1258        );
1259    }
1260
1261    #[test]
1262    fn most_methods_need_only_a_handful_of_scopes() {
1263        // This is why per-method authorisation is viable. Asking for the
1264        // union of everything would need 97 scopes and blow past the ~25
1265        // Google allows on an unverified app; asking for one method's scopes
1266        // needs a median of 3.
1267        let counts: Vec<usize> = services()
1268            .values()
1269            .flat_map(|s| s.methods.iter())
1270            .map(|m| m.scopes.len())
1271            .collect();
1272        let total = counts.len();
1273        let within_nine = counts.iter().filter(|c| **c <= 9).count();
1274        assert!(
1275            within_nine * 100 / total >= 90,
1276            "only {within_nine}/{total} methods need <=9 scopes"
1277        );
1278        // A handful of endpoints genuinely accept many (chat.spaceEvents.list
1279        // takes 16); the point is the tail, not the ceiling.
1280        let worst = counts.iter().max().copied().unwrap_or(0);
1281        assert!(worst <= 20, "a method accepts {worst} scopes");
1282    }
1283
1284    #[test]
1285    fn no_method_requests_the_full_workspace_surface_at_once() {
1286        // The reason per-method scopes exist: the union of every method's
1287        // scopes is far past what an unverified app may request at consent.
1288        let union: std::collections::BTreeSet<&str> = services()
1289            .values()
1290            .flat_map(|s| s.methods.iter())
1291            .flat_map(|m| m.scopes.iter().map(String::as_str))
1292            .collect();
1293        assert!(
1294            union.len() > 25,
1295            "expected the union to exceed Google's unverified-app ceiling, got {}",
1296            union.len()
1297        );
1298    }
1299}