Skip to main content

sharepoint_cli/graph/
mod.rs

1//! Microsoft Graph client.
2//!
3//! Centralizes HTTP, auth header injection, error mapping, retry/backoff for
4//! 429/5xx, and paging continuation. Sub-modules (sites, drives, search,
5//! download) call `GraphClient::get_json` / `iterator` etc. — they don't
6//! build their own clients.
7
8use std::time::Duration;
9
10use reqwest::{Method, Response, StatusCode};
11use serde::de::DeserializeOwned;
12use tokio::time::sleep;
13
14use crate::auth::AuthContext;
15use crate::error::{CliError, Result};
16use crate::util;
17
18/// Maximum number of automatic retries on 429 / 5xx.
19const MAX_RETRIES: u32 = 3;
20
21#[derive(Clone)]
22pub struct GraphClient {
23    auth: AuthContext,
24}
25
26impl GraphClient {
27    pub fn new(auth: AuthContext) -> Self {
28        Self { auth }
29    }
30
31    /// Build a fully-qualified URL from a path. Accepts both absolute URLs
32    /// (used when following `@odata.nextLink`) and bare paths.
33    pub(crate) async fn url(&self, path: &str) -> String {
34        if path.starts_with("http://") || path.starts_with("https://") {
35            return path.to_string();
36        }
37        let cfg = self.auth.config().await;
38        let base = cfg.graph_endpoint.trim_end_matches('/');
39        if let Some(rest) = path.strip_prefix('/') {
40            format!("{base}/{rest}")
41        } else {
42            format!("{base}/{path}")
43        }
44    }
45
46    /// Perform a GET and parse JSON, with retry/backoff and Graph error mapping.
47    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
48        let resp = self.send(Method::GET, path, None).await?;
49        let body = resp.text().await?;
50        serde_json::from_str(&body)
51            .map_err(|e| CliError::Other(format!("graph response not JSON: {e}; body={body}")))
52    }
53
54    /// Perform a request that returns a streaming body (e.g., download).
55    /// `body` is cloned per retry attempt; pass `None` for GET/HEAD.
56    pub(crate) async fn send(
57        &self,
58        method: Method,
59        path: &str,
60        body: Option<Vec<u8>>,
61    ) -> Result<Response> {
62        let url = self.url(path).await;
63        let mut attempt: u32 = 0;
64        loop {
65            let token = self.auth.access_token().await?;
66            let http = self.auth.http().await;
67            let mut req = http
68                .request(method.clone(), &url)
69                .bearer_auth(&token)
70                .header("Accept", "application/json");
71            if let Some(b) = body.as_ref() {
72                req = req.body(b.clone());
73            }
74            let resp = req
75                .send()
76                .await
77                .map_err(|e| CliError::Http(format!("graph {method} {url}: {e}")))?;
78
79            let status = resp.status();
80            if status.is_success() {
81                return Ok(resp);
82            }
83
84            let retry_after = resp
85                .headers()
86                .get("Retry-After")
87                .and_then(|v| v.to_str().ok())
88                .and_then(util::parse_retry_after);
89
90            let body_text = resp.text().await.unwrap_or_default();
91            let cfg = self.auth.config().await;
92            let detail = if cfg.debug_http {
93                format!(": {body_text}")
94            } else {
95                String::new()
96            };
97
98            if status == StatusCode::TOO_MANY_REQUESTS && attempt < MAX_RETRIES {
99                let secs = retry_after
100                    .map(|d| d.as_secs())
101                    .unwrap_or_else(|| 2u64.pow(attempt));
102                sleep(Duration::from_secs(secs)).await;
103                attempt += 1;
104                continue;
105            }
106            if status.is_server_error()
107                && attempt < MAX_RETRIES
108                && matches!(method, Method::GET | Method::HEAD)
109            {
110                let secs = 2u64.pow(attempt);
111                sleep(Duration::from_secs(secs)).await;
112                attempt += 1;
113                continue;
114            }
115
116            return Err(map_status(status, &body_text, &detail));
117        }
118    }
119
120    /// Return the configured Graph API endpoint (e.g. `"https://graph.microsoft.com/v1.0"`).
121    pub(crate) async fn graph_endpoint(&self) -> String {
122        self.auth.config().await.graph_endpoint.clone()
123    }
124
125    /// Drain a paged collection by following `@odata.nextLink` until exhausted.
126    pub(crate) async fn page_all<T: DeserializeOwned>(&self, first_path: &str) -> Result<Vec<T>> {
127        let mut acc = Vec::new();
128        let mut next = Some(first_path.to_string());
129        while let Some(p) = next.take() {
130            let page: PagedResponse<T> = self.get_json(&p).await?;
131            acc.extend(page.value);
132            next = page.next_link;
133        }
134        Ok(acc)
135    }
136}
137
138#[derive(serde::Deserialize)]
139pub(crate) struct PagedResponse<T> {
140    pub(crate) value: Vec<T>,
141    #[serde(rename = "@odata.nextLink", default)]
142    pub(crate) next_link: Option<String>,
143}
144
145fn map_status(status: StatusCode, body: &str, detail: &str) -> CliError {
146    let primary = extract_graph_error_message(body).unwrap_or_else(|| status.to_string());
147    match status {
148        StatusCode::UNAUTHORIZED => CliError::Auth(format!("Graph 401: {primary}{detail}")),
149        StatusCode::FORBIDDEN => CliError::Auth(format!("Graph 403: {primary}{detail}")),
150        StatusCode::NOT_FOUND => CliError::NotFound(format!("{primary}{detail}")),
151        StatusCode::TOO_MANY_REQUESTS => CliError::RateLimit,
152        s => CliError::Api {
153            status: s.as_u16(),
154            message: format!("{primary}{detail}"),
155        },
156    }
157}
158
159/// A pagination cursor that tracks position across Graph pages.
160///
161/// Encoded as base64(JSON) in the `--page` / `next` fields so agents
162/// can resume exactly where they left off, even mid-page.
163#[derive(Debug, serde::Serialize, serde::Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct Cursor {
166    /// The Graph URL to fetch next. `None` means the collection is exhausted.
167    pub next: Option<String>,
168    /// Number of items to skip at the beginning of the page returned by `next`.
169    /// Non-zero only when `--limit` cut off partway through a Graph page.
170    #[serde(default)]
171    pub skip: usize,
172}
173
174/// Encode a `Cursor` as a base64url string suitable for the `--page` / `next`
175/// field. The encoding is base64(JSON) so the token is opaque to the caller.
176pub fn encode_cursor(cursor: &Cursor) -> String {
177    use base64::Engine as _;
178    let json = serde_json::to_string(cursor).expect("Cursor serialization is infallible");
179    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
180}
181
182/// Decode a `--page` token back to a `Cursor`, validating that any URL in
183/// `cursor.next` has a host matching the configured Graph endpoint.
184/// Tokens whose host does not match are rejected to prevent bearer-token
185/// leakage to untrusted hosts.
186pub(crate) fn decode_cursor(graph_endpoint: &str, token: &str) -> Result<Cursor> {
187    use base64::Engine as _;
188    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
189        .decode(token.as_bytes())
190        .map_err(|_| CliError::Input("invalid page token (not base64)".into()))?;
191    let s = String::from_utf8(bytes)
192        .map_err(|_| CliError::Input("invalid page token (not utf-8)".into()))?;
193    let cursor: Cursor =
194        serde_json::from_str(&s).map_err(|_| CliError::Input("invalid page token".into()))?;
195    if let Some(ref url) = cursor.next {
196        validate_token_host(graph_endpoint, url)?;
197    }
198    Ok(cursor)
199}
200
201/// Validate that `candidate` URL has the same host as `graph_endpoint`.
202/// Rejects tokens that point at an unexpected host to prevent bearer-token
203/// leakage.
204fn validate_token_host(graph_endpoint: &str, candidate: &str) -> Result<()> {
205    let token_url = url::Url::parse(candidate)
206        .map_err(|_| CliError::Input("invalid page token (not a URL)".into()))?;
207    let allowed = url::Url::parse(graph_endpoint)
208        .map_err(|_| CliError::Other("invalid configured graph_endpoint".into()))?;
209    let allowed_host = allowed
210        .host_str()
211        .ok_or_else(|| CliError::Other("graph_endpoint must have a host".into()))?;
212    if token_url.host_str() != Some(allowed_host) {
213        return Err(CliError::Input(format!(
214            "page token host mismatch: token points at {:?}, expected {:?} — refusing to follow untrusted host",
215            token_url.host_str(),
216            allowed_host
217        )));
218    }
219    Ok(())
220}
221
222fn extract_graph_error_message(body: &str) -> Option<String> {
223    let v: serde_json::Value = serde_json::from_str(body).ok()?;
224    let err = v.get("error")?;
225    let code = err.get("code").and_then(|c| c.as_str()).unwrap_or("");
226    let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("");
227    Some(if code.is_empty() {
228        msg.to_string()
229    } else {
230        format!("{code}: {msg}")
231    })
232}
233
234pub mod download;
235pub(crate) mod drives;
236pub(crate) mod search;
237pub(crate) mod sites;
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn extract_graph_error_message_handles_well_formed_body() {
245        let body =
246            r#"{"error":{"code":"itemNotFound","message":"The resource could not be found."}}"#;
247        let msg = extract_graph_error_message(body).unwrap();
248        assert!(msg.contains("itemNotFound"));
249        assert!(msg.contains("could not be found"));
250    }
251
252    #[test]
253    fn extract_graph_error_message_returns_none_for_non_json() {
254        assert!(extract_graph_error_message("not json").is_none());
255    }
256
257    #[test]
258    fn map_status_404_is_not_found() {
259        let body = r#"{"error":{"code":"itemNotFound","message":"missing"}}"#;
260        let err = map_status(StatusCode::NOT_FOUND, body, "");
261        assert!(matches!(err, CliError::NotFound(_)));
262    }
263
264    #[test]
265    fn map_status_429_is_rate_limit() {
266        let err = map_status(StatusCode::TOO_MANY_REQUESTS, "", "");
267        assert!(matches!(err, CliError::RateLimit));
268    }
269
270    #[test]
271    fn map_status_500_is_api_error() {
272        let err = map_status(StatusCode::INTERNAL_SERVER_ERROR, "", "");
273        match err {
274            CliError::Api { status, .. } => assert_eq!(status, 500),
275            _ => panic!("expected API error"),
276        }
277    }
278
279    #[test]
280    fn cursor_round_trips_with_skip() {
281        let url = "https://graph.microsoft.com/v1.0/sites?$skiptoken=ABC";
282        let cursor = Cursor {
283            next: Some(url.to_string()),
284            skip: 42,
285        };
286        let encoded = encode_cursor(&cursor);
287        let decoded = decode_cursor("https://graph.microsoft.com/v1.0", &encoded).unwrap();
288        assert_eq!(decoded.next.as_deref(), Some(url));
289        assert_eq!(decoded.skip, 42);
290    }
291
292    #[test]
293    fn cursor_round_trips_exhausted() {
294        let cursor = Cursor {
295            next: None,
296            skip: 0,
297        };
298        let encoded = encode_cursor(&cursor);
299        let decoded = decode_cursor("https://graph.microsoft.com/v1.0", &encoded).unwrap();
300        assert!(decoded.next.is_none());
301        assert_eq!(decoded.skip, 0);
302    }
303
304    #[test]
305    fn decode_cursor_rejects_token_pointing_at_wrong_host() {
306        let cursor = Cursor {
307            next: Some("https://attacker.example/v1.0/sites?$skiptoken=evil".to_string()),
308            skip: 0,
309        };
310        let token = encode_cursor(&cursor);
311        let err = decode_cursor("https://graph.microsoft.com/v1.0", &token).unwrap_err();
312        assert!(matches!(err, CliError::Input(_)));
313        assert!(err.to_string().contains("host"));
314    }
315
316    #[test]
317    fn decode_cursor_rejects_invalid_base64() {
318        let err = decode_cursor("https://graph.microsoft.com/v1.0", "!!!").unwrap_err();
319        assert!(matches!(err, CliError::Input(_)));
320    }
321
322    #[test]
323    fn decode_cursor_rejects_non_json_content() {
324        use base64::Engine as _;
325        // Simulate an old-format token: base64-encoded plain URL (not JSON).
326        let old_token = base64::engine::general_purpose::URL_SAFE_NO_PAD
327            .encode(b"https://graph.microsoft.com/v1.0/sites?$skiptoken=old");
328        let err = decode_cursor("https://graph.microsoft.com/v1.0", &old_token).unwrap_err();
329        assert!(matches!(err, CliError::Input(_)));
330    }
331}