use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::Mutex;
pub(crate) const LOGIN_ORIGIN: &str = "https://login.microsoftonline.com";
pub(crate) const GRAPH_ORIGIN: &str = "https://graph.microsoft.com";
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OnedriveOrigins {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
}
impl OnedriveOrigins {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
pub fn behind(host: &str) -> Self {
let h = host.trim_end_matches('/');
Self {
login: Some(format!("{h}/login")),
graph: Some(format!("{h}/graph")),
}
}
pub(crate) fn origin(over: &Option<String>, default: &str) -> String {
over.as_deref()
.unwrap_or(default)
.trim_end_matches('/')
.to_string()
}
}
#[derive(Clone)]
struct Endpoints {
token: String,
graph: String,
}
fn endpoints(o: &OnedriveOrigins) -> Endpoints {
Endpoints {
token: format!(
"{}/consumers/oauth2/v2.0/token",
OnedriveOrigins::origin(&o.login, LOGIN_ORIGIN)
),
graph: format!("{}/v1.0", OnedriveOrigins::origin(&o.graph, GRAPH_ORIGIN)),
}
}
const ITEM_FIELDS: &[&str] = &[
"id",
"name",
"size",
"lastModifiedDateTime",
"createdDateTime",
"file",
"folder",
"package",
"cTag",
"eTag",
];
const DOWNLOAD_URL_SELECT: &str = "content.downloadUrl";
pub(super) const DOWNLOAD_URL_KEY: &str = "@microsoft.graph.downloadUrl";
const MAX_PAGES: usize = 50;
const MAX_RETRIES: u32 = 5;
const MAX_BACKOFF: Duration = Duration::from_secs(16);
const JITTER_MAX_MS: u64 = 1000;
const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
pub(super) const MAX_BODY_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Clone, Serialize, Deserialize)]
pub struct OnedriveConfig {
pub client_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
pub refresh_token: String,
#[serde(default, skip_serializing_if = "OnedriveOrigins::is_default")]
pub origins: OnedriveOrigins,
}
impl std::fmt::Debug for OnedriveConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OnedriveConfig")
.field("client_id", &"<redacted>")
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "<redacted>"),
)
.field("refresh_token", &"<redacted>")
.field("origins_overridden", &!self.origins.is_default())
.finish()
}
}
pub struct OnedriveAccessor {
client: reqwest::Client,
config: OnedriveConfig,
urls: Endpoints,
access_token: Mutex<Option<(String, Instant)>>,
throttled_until: Mutex<Option<Instant>>,
}
impl OnedriveAccessor {
pub fn new(config: &OnedriveConfig) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Ok(Self {
client,
config: config.clone(),
urls: endpoints(&config.origins),
access_token: Mutex::new(None),
throttled_until: Mutex::new(None),
})
}
async fn token(&self) -> anyhow::Result<String> {
let mut guard = self.access_token.lock().await;
if let Some((t, exp)) = guard.as_ref()
&& *exp > Instant::now() + Duration::from_secs(60)
{
return Ok(t.clone());
}
let mut form: Vec<(&str, &str)> = vec![
("client_id", self.config.client_id.as_str()),
("refresh_token", self.config.refresh_token.as_str()),
("grant_type", "refresh_token"),
];
if let Some(secret) = self.config.client_secret.as_deref() {
form.push(("client_secret", secret));
}
let resp = self
.client
.post(&self.urls.token)
.form(&form)
.send()
.await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!(
"microsoft token exchange {status}: {}",
first_chars(&body, 300)
);
}
let v: Value = serde_json::from_str(&body)?;
let token = v
.get("access_token")
.and_then(|t| t.as_str())
.ok_or_else(|| anyhow::anyhow!("no access_token in response"))?
.to_string();
let expires_in = v.get("expires_in").and_then(|e| e.as_u64()).unwrap_or(3600);
*guard = Some((
token.clone(),
Instant::now() + Duration::from_secs(expires_in),
));
Ok(token)
}
async fn send_retrying(
&self,
build: impl Fn(&str) -> reqwest::RequestBuilder,
max_retries: u32,
) -> anyhow::Result<reqwest::Response> {
self.refuse_while_throttled().await?;
let mut token = self.token().await?;
let mut refreshed = false;
let mut retries = 0u32;
let mut slept = Duration::ZERO;
loop {
let resp = build(&token).send().await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED && !refreshed {
*self.access_token.lock().await = None;
token = self.token().await?;
refreshed = true;
continue;
}
let throttled = status == reqwest::StatusCode::TOO_MANY_REQUESTS;
let asked = retry_after(resp.headers());
if (throttled || status.is_server_error()) && retries < max_retries {
let Some(wait) = next_wait(asked, slept, retries) else {
self.pause_until(Instant::now() + asked.unwrap_or_default())
.await;
return Ok(resp);
};
retries += 1;
slept += wait;
tokio::time::sleep(wait).await;
continue;
}
if throttled && let Some(d) = asked {
self.pause_until(Instant::now() + d).await;
}
return Ok(resp);
}
}
async fn refuse_while_throttled(&self) -> anyhow::Result<()> {
let until = *self.throttled_until.lock().await;
if let Some(until) = until
&& let Some(left) = until.checked_duration_since(Instant::now())
{
anyhow::bail!("onedrive: throttled, {} s left to wait", left.as_secs());
}
Ok(())
}
async fn pause_until(&self, until: Instant) {
let mut guard = self.throttled_until.lock().await;
if guard.is_none_or(|current| until > current) {
*guard = Some(until);
}
}
async fn send_with_refresh(
&self,
build: impl Fn(&str) -> reqwest::RequestBuilder,
) -> anyhow::Result<reqwest::Response> {
self.send_retrying(build, MAX_RETRIES).await
}
async fn get_json(&self, url: &str) -> anyhow::Result<Value> {
let resp = self
.send_with_refresh(|t| self.client.get(url).bearer_auth(t))
.await?;
if let Err(failed) = resp.error_for_status_ref() {
let body = body_within(resp, MAX_BODY_BYTES, "error")
.await
.unwrap_or_default();
return Err(anyhow::Error::new(failed).context(format!(
"graph: {}",
first_chars(&String::from_utf8_lossy(&body), 300)
)));
}
let raw = body_within(resp, MAX_BODY_BYTES, "listing").await?;
Ok(serde_json::from_slice(&raw)?)
}
fn children_url(&self, path: &str) -> String {
let select = format!("{},{}", ITEM_FIELDS.join(","), DOWNLOAD_URL_SELECT);
let base = if path.is_empty() || path == "/" {
format!("{}/me/drive/root/children", self.urls.graph)
} else {
format!(
"{}/me/drive/root:/{}:/children",
self.urls.graph,
encode_path(path)
)
};
format!("{base}?$select={select}&$top=200")
}
pub async fn list_children(&self, path: &str, limit: usize) -> anyhow::Result<Vec<Value>> {
self.pages_from(self.children_url(path), limit).await
}
pub async fn list_children_of_id(&self, id: &str, limit: usize) -> anyhow::Result<Vec<Value>> {
let select = format!("{},{}", ITEM_FIELDS.join(","), DOWNLOAD_URL_SELECT);
let url = format!(
"{}/me/drive/items/{}/children?$select={select}&$top=200",
self.urls.graph,
encode_segment(id)
);
self.pages_from(url, limit).await
}
async fn pages_from(&self, url: String, limit: usize) -> anyhow::Result<Vec<Value>> {
let mut out: Vec<Value> = Vec::new();
let mut url = url;
for _ in 0..MAX_PAGES {
let v = self.get_json(&url).await?;
if let Some(items) = v.get("value").and_then(|f| f.as_array()) {
out.extend(items.iter().cloned());
}
if out.len() >= limit {
out.truncate(limit);
break;
}
match v.get("@odata.nextLink").and_then(|n| n.as_str()) {
Some(next) => url = next.to_string(),
None => break,
}
}
Ok(out)
}
pub async fn get_item_by_id(&self, id: &str) -> anyhow::Result<Value> {
let select = format!("{},{}", ITEM_FIELDS.join(","), DOWNLOAD_URL_SELECT);
let url = format!(
"{}/me/drive/items/{}?$select={select}",
self.urls.graph,
encode_segment(id)
);
self.get_json(&url).await
}
pub async fn download(
&self,
url: &str,
range: Option<std::ops::Range<u64>>,
) -> anyhow::Result<(u64, Vec<u8>)> {
let mut req = self.client.get(url);
if let Some(r) = &range {
if r.end <= r.start {
return Ok((r.start, Vec::new()));
}
req = req.header("Range", format!("bytes={}-{}", r.start, r.end - 1));
}
let resp = req.send().await.map_err(reqwest::Error::without_url)?;
let status = resp.status();
if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
return Ok((range.map(|r| r.start).unwrap_or(0), Vec::new()));
}
if !status.is_success() {
anyhow::bail!("onedrive download {status}");
}
let at = if range.is_some() {
if status == reqwest::StatusCode::PARTIAL_CONTENT {
content_range_start(resp.headers()).ok_or_else(|| {
anyhow::anyhow!("onedrive download: 206 without a usable Content-Range")
})?
} else {
0
}
} else {
0
};
let bytes = body_within(resp, MAX_BODY_BYTES, "file window").await?;
Ok((at, bytes))
}
}
fn first_chars(s: &str, n: usize) -> &str {
match s.char_indices().nth(n) {
Some((i, _)) => &s[..i],
None => s,
}
}
async fn body_within(
mut resp: reqwest::Response,
limit: u64,
what: &str,
) -> anyhow::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
while let Some(chunk) = resp.chunk().await? {
if out.len() as u64 + chunk.len() as u64 > limit {
anyhow::bail!("onedrive {what}: over {limit} bytes");
}
out.extend_from_slice(&chunk);
}
Ok(out)
}
fn content_range_start(headers: &reqwest::header::HeaderMap) -> Option<u64> {
let v = headers.get(reqwest::header::CONTENT_RANGE)?.to_str().ok()?;
v.trim()
.strip_prefix("bytes ")?
.split_once('-')?
.0
.trim()
.parse()
.ok()
}
pub(super) fn encode_path(path: &str) -> String {
path.trim_matches('/')
.split('/')
.map(encode_segment)
.collect::<Vec<_>>()
.join("/")
}
fn encode_segment(seg: &str) -> String {
let mut out = String::with_capacity(seg.len());
for b in seg.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn next_wait(asked: Option<Duration>, slept: Duration, retries: u32) -> Option<Duration> {
match asked {
Some(d) if slept + d > MAX_RETRY_AFTER => None,
Some(d) => Some(d),
None => Some(backoff_delay(retries)),
}
}
fn backoff_delay(n: u32) -> Duration {
let base = 1u64 << n.min(16);
let jitter = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| u64::from(d.subsec_nanos()) % (JITTER_MAX_MS + 1))
.unwrap_or(0);
Duration::from_secs(base)
.min(MAX_BACKOFF)
.saturating_add(Duration::from_millis(jitter))
}
fn retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
let secs: u64 = headers
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
(secs > 0).then(|| Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_service_keeps_its_official_path_under_any_origin() {
const TOKEN: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
let at = |o: OnedriveOrigins| {
let e = endpoints(&o);
(e.token, e.graph)
};
let graph = Some("http://127.0.0.1:9/g".to_string());
assert_eq!(
at(Default::default()),
(TOKEN.into(), "https://graph.microsoft.com/v1.0".into())
);
let moved = at(OnedriveOrigins {
graph,
..Default::default()
});
assert_eq!(moved, (TOKEN.into(), "http://127.0.0.1:9/g/v1.0".into()));
}
#[test]
fn a_path_is_encoded_by_segment() {
assert_eq!(encode_path("/a/b c/d"), "a/b%20c/d");
assert_eq!(
encode_path("보고/서.pdf"),
"%EB%B3%B4%EA%B3%A0/%EC%84%9C.pdf"
);
assert_eq!([encode_path("a?b"), encode_path("a#b")], ["a%3Fb", "a%23b"]);
}
#[test]
fn a_config_does_not_print_what_it_holds() {
let cfg = OnedriveConfig {
client_id: "the-client".into(),
client_secret: Some("the-secret".into()),
refresh_token: "the-refresh-token".into(),
origins: OnedriveOrigins::default(),
};
let shown = format!("{cfg:?}");
for secret in ["the-client", "the-secret", "the-refresh-token"] {
assert!(!shown.contains(secret), "{shown} leaks {secret}");
}
assert!(shown.contains("origins_overridden: false"));
}
#[test]
fn a_wait_is_taken_only_while_the_ladder_can_still_afford_it() {
let s = Duration::from_secs;
let all = MAX_RETRY_AFTER;
assert_eq!(next_wait(Some(s(10)), s(0), 0), Some(s(10)), "as asked");
assert_eq!(
next_wait(Some(all), s(0), 0),
Some(all),
"the whole budget at once"
);
assert_eq!(
next_wait(Some(all + s(1)), s(0), 0),
None,
"past it, refused outright"
);
assert_eq!(next_wait(Some(s(7)), s(21), 0), Some(s(7)), "28 <= 30");
assert_eq!(next_wait(Some(s(7)), s(28), 0), None, "35 > 30");
}
}