sharepoint_cli/graph/
mod.rs1use 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
18const 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 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 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 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 pub(crate) async fn graph_endpoint(&self) -> String {
122 self.auth.config().await.graph_endpoint.clone()
123 }
124
125 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#[derive(Debug, serde::Serialize, serde::Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct Cursor {
166 pub next: Option<String>,
168 #[serde(default)]
171 pub skip: usize,
172}
173
174pub 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
182pub(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
201fn 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 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}