Skip to main content

coven_storage/providers/
http.rs

1use crate::{
2    ByteProgress, ByteRange, CloudProvider, HeldTokens, OAuthTokens, StorageError, StorageFailure,
3    TokenCustody,
4};
5use coven_crypto::SecretBytes;
6use coven_foundation::clock::ClockRef;
7use reqwest::{Method, Response};
8use serde_json::Value;
9use std::fmt;
10use std::sync::Arc;
11
12/// A device's provider sign-in and HTTP connection, injected into its provider.
13/// A token is used until the provider rejects it with HTTP 401; the request
14/// then refreshes once through the sign-in's custody and is retried once (§20.2).
15pub struct OAuthSession {
16    client: super::http_client::HttpClient,
17    provider: CloudProvider,
18    tokens: HeldTokens,
19    clock: ClockRef,
20}
21impl OAuthSession {
22    /// Sign in over `client`, which follows no redirect: bearer tokens must not
23    /// follow provider-returned URLs to other origins.
24    pub(crate) fn new(
25        provider: CloudProvider,
26        tokens: OAuthTokens,
27        custody: Arc<dyn TokenCustody>,
28        clock: ClockRef,
29        client: super::http_client::HttpClient,
30    ) -> Result<Self, StorageError> {
31        if !provider.signs_in_with_oauth() {
32            return Err(
33                StorageFailure::InvalidConfiguration.with_source("provider does not use OAuth")
34            );
35        }
36        Ok(Self { client, provider, tokens: HeldTokens::new(tokens, custody), clock })
37    }
38    /// Resolve the signed-in account through the provider's own identity API.
39    pub async fn account(&self) -> Result<String, StorageError> {
40        let api = super::oauth_api(self.provider).expect("checked OAuth provider");
41        self.account_at(api).await
42    }
43    pub(crate) async fn account_at(&self, api: &str) -> Result<String, StorageError> {
44        let (method, url, body) = match self.provider {
45            CloudProvider::Dropbox => (
46                Method::POST,
47                endpoint(api, &["users", "get_current_account"], &[])?,
48                Body::Json(Value::Null),
49            ),
50            CloudProvider::OneDrive => (
51                Method::GET,
52                endpoint(api, &["me"], &[("$select", "mail,userPrincipalName")])?,
53                Body::Empty,
54            ),
55            _ => unreachable!("checked OAuth provider"),
56        };
57        let value = json(self.provider, self.send(method, &url, &[], body, true).await?).await?;
58        let account = match self.provider {
59            CloudProvider::Dropbox => string(&value, "email")?,
60            CloudProvider::OneDrive => {
61                if value["mail"].is_null() {
62                    string(&value, "userPrincipalName")?
63                } else {
64                    string(&value, "mail")?
65                }
66            }
67            _ => unreachable!("checked OAuth provider"),
68        };
69        let account = coven_format::MemberAccess::normalized_account(account);
70        if account.is_empty() {
71            return Err(StorageFailure::Protocol.with_source("empty signed-in account"));
72        }
73        Ok(account)
74    }
75    /// Install tokens after their owner committed them to custody.
76    pub async fn set_tokens(&self, tokens: OAuthTokens) {
77        self.tokens.install(tokens).await;
78    }
79    pub(crate) fn provider(&self) -> CloudProvider {
80        self.provider
81    }
82    /// End once `duration` has passed, a deadline the work races.
83    pub(crate) async fn deadline(&self, duration: std::time::Duration) {
84        self.clock.deadline(duration).await;
85    }
86    /// Wait out a backoff or poll spacing before trying again.
87    pub(crate) async fn wait_out(&self, wait: std::time::Duration) {
88        self.clock.wait_out(wait).await;
89    }
90    pub(crate) async fn send(
91        &self,
92        method: Method,
93        url: &str,
94        headers: &[(&str, String)],
95        body: Body,
96        authenticated: bool,
97    ) -> Result<Response, StorageError> {
98        self.send_request(None, method, url, headers, body, authenticated).await
99    }
100    pub(crate) async fn publish(
101        &self,
102        publication: &crate::PublicationRequest,
103        method: Method,
104        url: &str,
105        headers: &[(&str, String)],
106        body: Body,
107        authenticated: bool,
108    ) -> Result<Response, StorageError> {
109        self.send_request(Some(publication), method, url, headers, body, authenticated).await
110    }
111    async fn send_request(
112        &self,
113        publication: Option<&crate::PublicationRequest>,
114        method: Method,
115        url: &str,
116        headers: &[(&str, String)],
117        body: Body,
118        authenticated: bool,
119    ) -> Result<Response, StorageError> {
120        let mut request = self.client.request(method, url);
121        for (name, value) in headers {
122            request = request.header(*name, value);
123        }
124        let (request, sent) = match body {
125            Body::Empty => (request, ByteProgress::unread()),
126            Body::Json(value) => (request.json(&value), ByteProgress::unread()),
127            Body::Bytes(bytes, sent) => (request.body(bytes), sent),
128        };
129        let request = request.build().map_err(|e| transport(self.provider, e))?;
130        if !authenticated {
131            return self.execute(publication, request, None, &sent).await;
132        }
133        // Buffered bodies clone without copying, so the one retry resends
134        // them; its bytes are heard again from the start.
135        let retry = request.try_clone();
136        let used = self.tokens.current().await;
137        let response = self.execute(publication, request, Some(&used), &sent).await?;
138        if response.status() != reqwest::StatusCode::UNAUTHORIZED {
139            return Ok(response);
140        }
141        let rejection = response_error(self.provider, response).await;
142        // Refreshing cannot cure a rejection other than the token's, such as
143        // Dropbox's missing scope or a suspended account.
144        if rejection.failure() != StorageFailure::Authentication {
145            return Err(rejection);
146        }
147        let Some(retry) = retry else {
148            return Err(rejection);
149        };
150        let replacement = self.tokens.rejected(&used).await?;
151        self.execute(publication, retry, Some(&replacement), &sent).await
152    }
153    /// Send one request with `tokens`, labelling the response with the access
154    /// they carried: a later replacement must not relabel it. `sent` hears
155    /// its body go.
156    async fn execute(
157        &self,
158        publication: Option<&crate::PublicationRequest>,
159        mut request: reqwest::Request,
160        tokens: Option<&OAuthTokens>,
161        sent: &ByteProgress,
162    ) -> Result<Response, StorageError> {
163        if let Some(tokens) = tokens {
164            let mut value = reqwest::header::HeaderValue::try_from(format!(
165                "Bearer {}",
166                tokens.access_token.as_str()
167            ))
168            .map_err(|error| StorageFailure::InvalidConfiguration.with_source(error))?;
169            value.set_sensitive(true);
170            request.headers_mut().insert(reqwest::header::AUTHORIZATION, value);
171        }
172        if let Some(publication) = publication {
173            publication.check()?;
174        }
175        let mut response = self
176            .client
177            .execute(request, sent)
178            .await
179            .map_err(|error| error.into_storage(self.provider))?;
180        if let Some(tokens) = tokens {
181            response.extensions_mut().insert(tokens.access);
182        }
183        Ok(response)
184    }
185}
186pub(crate) enum Body {
187    Empty,
188    Json(Value),
189    /// Bytes, and who hears them go: an object's, or none for a body that
190    /// carries no object's bytes.
191    Bytes(Vec<u8>, ByteProgress),
192}
193/// A request that got no reply. Its text names every cause, such as the DNS,
194/// TLS or connection failure behind reqwest's "error sending request". The
195/// kept error's URL is cut to its scheme and host first, so neither its text,
196/// its `Debug` nor a walk of its causes shows a path or query.
197pub(crate) fn transport(provider: CloudProvider, error: reqwest::Error) -> StorageError {
198    let failure = if error.is_builder() {
199        StorageFailure::InvalidConfiguration
200    } else {
201        StorageFailure::Network
202    };
203    StorageError::Provider {
204        provider,
205        failure,
206        source: Box::new(super::error_text::WithCauses(super::error_text::origin_only(error))),
207    }
208}
209
210/// The original error response. Its body is never printed; providers may echo secrets.
211pub struct ProviderResponse {
212    status: u16,
213    headers: reqwest::header::HeaderMap,
214    body: SecretBytes,
215}
216impl ProviderResponse {
217    /// The HTTP status returned by the provider.
218    pub fn status(&self) -> u16 {
219        self.status
220    }
221    /// Original response headers, including Retry-After. Inspect explicitly;
222    /// they are redacted from ordinary error formatting along with the body.
223    pub fn headers(&self) -> &reqwest::header::HeaderMap {
224        &self.headers
225    }
226    /// A response carrying `headers`, as a native adapter or fixture saw it.
227    pub(crate) fn new(status: u16, headers: reqwest::header::HeaderMap, body: Vec<u8>) -> Self {
228        Self { status, headers, body: SecretBytes::new(body) }
229    }
230    /// A `status` response, asking for `retry_after` seconds of
231    /// `Retry-After` when given, classified as `failure`, as the in-memory
232    /// provider injects it.
233    #[cfg(any(test, feature = "test-utils"))]
234    pub(crate) fn injected(
235        provider: CloudProvider,
236        failure: StorageFailure,
237        status: u16,
238        retry_after: Option<u64>,
239    ) -> StorageError {
240        let mut headers = reqwest::header::HeaderMap::new();
241        if let Some(seconds) = retry_after {
242            headers.insert(reqwest::header::RETRY_AFTER, seconds.into());
243        }
244        StorageError::Provider {
245            provider,
246            failure,
247            source: Box::new(Self::new(status, headers, Vec::new())),
248        }
249    }
250    /// The provider's requested wait: `Retry-After` as delay-seconds, or as an
251    /// HTTP-date less this response's own `Date` (§6.7). Dropbox also
252    /// states it in its error body.
253    pub(crate) fn retry_after(&self, provider: CloudProvider) -> Option<crate::RetryAfter> {
254        use reqwest::header::{DATE, RETRY_AFTER};
255        if let Some(value) = self.headers.get(RETRY_AFTER) {
256            return Some(retry_after_header(
257                value.to_str().unwrap_or_default(),
258                self.headers.get(DATE).and_then(|date| date.to_str().ok()),
259            ));
260        }
261        if provider == CloudProvider::Dropbox {
262            let value = self.json()?;
263            return value["error"]["retry_after"]
264                .as_u64()
265                .map(|seconds| crate::RetryAfter::Wait(std::time::Duration::from_secs(seconds)));
266        }
267        None
268    }
269    pub(crate) fn into_error(self, provider: CloudProvider) -> StorageError {
270        StorageError::Provider {
271            provider,
272            failure: classify(provider, self.status, self.body()),
273            source: Box::new(self),
274        }
275    }
276    /// Inspect the original provider response explicitly at the app boundary.
277    pub fn body(&self) -> &[u8] {
278        self.body.as_bytes()
279    }
280    /// The body as JSON, when it is JSON.
281    pub(crate) fn json(&self) -> Option<Value> {
282        serde_json::from_slice(self.body()).ok()
283    }
284}
285
286/// The provider response behind `error`, when a provider answered.
287pub(crate) fn provider_response(error: &StorageError) -> Option<&ProviderResponse> {
288    let StorageError::Provider { source, .. } = error else {
289        return None;
290    };
291    source.downcast_ref::<ProviderResponse>()
292}
293
294/// The JSON body of the provider response behind `error`, when it has one.
295pub(crate) fn provider_json(error: &StorageError) -> Option<Value> {
296    provider_response(error)?.json()
297}
298
299/// A Graph error and each error nested in it, under `innerError` or, in
300/// older responses, `innererror`.
301pub(crate) fn graph_errors(error: &Value) -> impl Iterator<Item = &Value> {
302    std::iter::successors(Some(error).filter(|error| error.is_object()), |error| {
303        error
304            .get("innerError")
305            .or_else(|| error.get("innererror"))
306            .filter(|inner| inner.is_object())
307    })
308}
309/// An HTTP-date header's time (RFC 9110 §5.6.7), as `Date` and
310/// `Retry-After` give it.
311pub(crate) fn http_date(value: &str) -> Option<aws_sdk_s3::primitives::DateTime> {
312    use aws_sdk_s3::primitives::{DateTime, DateTimeFormat};
313    DateTime::from_str(value.trim(), DateTimeFormat::HttpDate).ok()
314}
315/// Interpret HTTP retry timing using the response's clock, for both HTTP clients.
316pub(crate) fn retry_after_header(value: &str, date: Option<&str>) -> crate::RetryAfter {
317    use crate::RetryAfter;
318    use std::time::Duration;
319    if let Ok(seconds) = value.trim().parse::<u64>() {
320        return RetryAfter::Wait(Duration::from_secs(seconds));
321    }
322    let parse = |value: &str| http_date(value).map(|time| time.secs());
323    match (parse(value), date.and_then(parse)) {
324        (Some(retry), Some(basis)) => {
325            RetryAfter::Wait(Duration::from_secs(retry.saturating_sub(basis).max(0) as u64))
326        }
327        _ => RetryAfter::NoTimeBasis,
328    }
329}
330
331impl ProviderResponse {
332    /// The error codes the body states, from each provider's fixed
333    /// vocabulary: an OAuth `error` (RFC 6749 §5.2) and Microsoft's
334    /// `error_codes`, Dropbox's error tags, Microsoft Graph's `code` and its
335    /// inner codes, Google's `status` and `reason`s, Cloudflare's numeric
336    /// codes. Never a message, a description or anything else in the body:
337    /// those can echo credentials, paths or account names.
338    fn codes(&self) -> Vec<String> {
339        let Some(value) = self.json() else {
340            return Vec::new();
341        };
342        let mut codes = Vec::new();
343        let mut push = |code: Option<String>| {
344            if let Some(code) = code.filter(|code| !codes.contains(code)) {
345                codes.push(code);
346            }
347        };
348        // Dropbox's asynchronous job status is a tagged body of its own.
349        push(dropbox_tags(&value));
350        let error = &value["error"];
351        push(error.as_str().and_then(fixed_word));
352        push(dropbox_tags(error));
353        for inner in graph_errors(error) {
354            push(inner["code"].as_str().and_then(fixed_word));
355        }
356        push(error["status"].as_str().and_then(fixed_word));
357        for list in [&error["errors"], &error["details"]] {
358            for item in list.as_array().into_iter().flatten() {
359                push(item["reason"].as_str().and_then(fixed_word));
360            }
361        }
362        for code in value["error_codes"].as_array().into_iter().flatten() {
363            push(code.as_u64().map(|code| code.to_string()));
364        }
365        for item in value["errors"].as_array().into_iter().flatten() {
366            push(item["code"].as_u64().map(|code| code.to_string()));
367        }
368        codes
369    }
370}
371/// A word from a provider's fixed vocabulary: letters, digits, `_`, `.`,
372/// `-` and `/`, at most 64 of them. Any other text is not printed.
373pub(super) fn fixed_word(word: &str) -> Option<String> {
374    let fixed = (1..=64).contains(&word.len())
375        && word.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-' | '/'));
376    fixed.then(|| word.to_owned())
377}
378/// Dropbox's nested union tags, such as `path/not_found`.
379fn dropbox_tags(value: &Value) -> Option<String> {
380    let mut tags = Vec::new();
381    let mut union = value;
382    while let Some(tag) = union.get(".tag").and_then(Value::as_str).and_then(fixed_word) {
383        union = &union[tag.as_str()];
384        tags.push(tag);
385    }
386    (!tags.is_empty()).then(|| tags.join("/"))
387}
388impl fmt::Debug for ProviderResponse {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        f.debug_struct("ProviderResponse")
391            .field("status", &self.status)
392            .field("codes", &self.codes())
393            .finish_non_exhaustive()
394    }
395}
396/// The status and the body's fixed-vocabulary codes, such as "provider
397/// returned HTTP 409 (path/not_found)".
398impl fmt::Display for ProviderResponse {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        write!(f, "provider returned HTTP {}", self.status)?;
401        let codes = self.codes();
402        if !codes.is_empty() {
403            write!(f, " ({})", codes.join(", "))?;
404        }
405        Ok(())
406    }
407}
408impl std::error::Error for ProviderResponse {}
409
410pub(crate) fn invalid_response(
411    provider: CloudProvider,
412    status: u16,
413    headers: reqwest::header::HeaderMap,
414    body: Vec<u8>,
415) -> StorageError {
416    StorageError::Provider {
417        provider,
418        failure: StorageFailure::Protocol,
419        source: Box::new(ProviderResponse::new(status, headers, body)),
420    }
421}
422
423pub(crate) async fn checked(
424    provider: CloudProvider,
425    response: Response,
426) -> Result<Response, StorageError> {
427    if response.status().is_success() {
428        return Ok(response);
429    }
430    Err(response_error(provider, response).await)
431}
432pub(crate) async fn response_error(provider: CloudProvider, response: Response) -> StorageError {
433    let status = response.status().as_u16();
434    let headers = response.headers().clone();
435    let body = match response.bytes().await {
436        Ok(body) => body.to_vec(),
437        Err(error) => return transport(provider, error),
438    };
439    ProviderResponse::new(status, headers, body).into_error(provider)
440}
441pub(crate) fn container_error(mut error: StorageError) -> StorageError {
442    match &mut error {
443        StorageError::Provider { failure, .. } if *failure == StorageFailure::NotFound => {
444            *failure = StorageFailure::ContainerNotFound;
445        }
446        _ => {}
447    }
448    error
449}
450pub(crate) fn classify(provider: CloudProvider, status: u16, body: &[u8]) -> StorageFailure {
451    let parsed = match serde_json::from_slice::<Value>(body) {
452        Ok(value) => Some(value),
453        Err(error) => {
454            tracing::debug!(%error, "provider error response is not JSON");
455            None
456        }
457    };
458    if let Some(failure) = parsed
459        .as_ref()
460        .and_then(|value| value["error"].as_str())
461        .and_then(super::oauth::classify_error)
462    {
463        return failure;
464    }
465    let native = parsed.as_ref().and_then(|value| match provider {
466        CloudProvider::Dropbox => dropbox_failure(value),
467        CloudProvider::OneDrive => onedrive_failure(value),
468        _ => None,
469    });
470    if let Some(failure) = native {
471        return failure;
472    }
473    match status {
474        401 => StorageFailure::Authentication,
475        403 => StorageFailure::PermissionDenied,
476        404 => StorageFailure::NotFound,
477        409 if provider == CloudProvider::Dropbox => StorageFailure::Refused,
478        409 | 412 => StorageFailure::AlreadyExists,
479        416 => StorageFailure::InvalidRange,
480        // Another party's lock on the item lasts only while its work runs.
481        423 => StorageFailure::Conflict,
482        429 => StorageFailure::RateLimited,
483        507 => StorageFailure::QuotaExceeded,
484        408 | 500..=599 => StorageFailure::Network,
485        _ => StorageFailure::Refused,
486    }
487}
488fn onedrive_failure(value: &Value) -> Option<StorageFailure> {
489    Some(match value["error"]["code"].as_str()? {
490        "quotaLimitReached" => StorageFailure::QuotaExceeded,
491        "InvalidAuthenticationToken" => StorageFailure::Authentication,
492        "accessDenied" => StorageFailure::PermissionDenied,
493        "itemNotFound" => StorageFailure::NotFound,
494        "nameAlreadyExists" => StorageFailure::AlreadyExists,
495        "activityLimitReached" => StorageFailure::RateLimited,
496        "resourceLocked" => StorageFailure::Conflict,
497        _ => return None,
498    })
499}
500/// What Dropbox's answer to adding a folder member says that only a person
501/// can fix, on either side: the invited address, or the inviting account's
502/// team, plan, email or member limit (§13.2). A retry is answered the same.
503fn sharing_refusal(names: &[&str]) -> Option<coven_foundation::sharing::SharingRefusal> {
504    use coven_foundation::sharing::SharingRefusal as Refusal;
505    let named = |wanted: &[&str]| names.iter().any(|name| wanted.contains(name));
506    if named(&["invalid_email"]) {
507        Some(Refusal::InvalidAddress)
508    } else if named(&["email_unverified"]) {
509        Some(Refusal::EmailUnverified)
510    } else if named(&["cant_share_outside_team"]) {
511        Some(Refusal::TeamSharingOff)
512    } else if named(&["insufficient_plan"]) {
513        Some(Refusal::PlanWithoutSharing)
514    } else if named(&["too_many_members", "too_many_pending_invites"]) {
515        Some(Refusal::TooManyMembers)
516    } else {
517        None
518    }
519}
520fn dropbox_failure(value: &Value) -> Option<StorageFailure> {
521    fn tags<'a>(value: &'a Value, result: &mut Vec<&'a str>) {
522        if let Some(object) = value.as_object() {
523            if let Some(tag) = object.get(".tag").and_then(Value::as_str) {
524                result.push(tag);
525            }
526            for child in object.values().filter(|child| child.is_object()) {
527                tags(child, result);
528            }
529        }
530    }
531    let mut names = Vec::new();
532    if let Some(summary) = value["error_summary"].as_str() {
533        names.extend(summary.trim_end_matches('.').split('/'));
534    }
535    // Dropbox reports its monthly data-transport quota as this AccessError.
536    // Other invalid-account-type cases still describe unavailable permissions.
537    let transport_limit = names.starts_with(&["invalid_account_type", "feature"])
538        || (value["error"][".tag"].as_str() == Some("invalid_account_type")
539            && value["error"]["invalid_account_type"][".tag"].as_str() == Some("feature"));
540    tags(&value["error"], &mut names);
541    tags(&value["failed"], &mut names);
542    // Specific native failures take precedence over HTTP 409's many meanings.
543    if names.iter().any(|name| {
544        matches!(*name, "too_many_requests" | "too_many_write_operations" | "rate_limit")
545    }) {
546        Some(StorageFailure::RateLimited)
547    } else if let Some(refusal) = sharing_refusal(&names) {
548        Some(StorageFailure::SharingRefused(refusal))
549    } else if names.contains(&"missing_scope") {
550        // Refreshing the same authorization cannot add a required scope.
551        Some(StorageFailure::InvalidConfiguration)
552    } else if names
553        .iter()
554        .any(|name| matches!(*name, "expired_access_token" | "invalid_access_token"))
555    {
556        Some(StorageFailure::Authentication)
557    } else if names.iter().any(|name| {
558        matches!(
559            *name,
560            "no_permission"
561                | "no_write_permission"
562                | "access_denied"
563                | "not_a_member"
564                | "invalid_member"
565                | "user_suspended"
566        )
567    }) {
568        Some(StorageFailure::PermissionDenied)
569    } else if transport_limit
570        || names.iter().any(|name| {
571            matches!(
572                *name,
573                "insufficient_space"
574                    | "team_folder_insufficient_space"
575                    | "member_folder_insufficient_space"
576                    | "upload_traffic_limit_reached"
577            )
578        })
579    {
580        Some(StorageFailure::QuotaExceeded)
581    } else if names.contains(&"conflict") {
582        Some(StorageFailure::AlreadyExists)
583    } else if names.contains(&"invalid_root")
584        || names.contains(&"invalid_namespace_id")
585        || names.contains(&"invalid_id")
586    {
587        Some(StorageFailure::ContainerNotFound)
588    } else if names.contains(&"not_found") || names.contains(&"not_folder") {
589        // `not_folder`: a file holds the name of a folder the path needs, so
590        // nothing exists beneath it.
591        Some(StorageFailure::NotFound)
592    } else {
593        None
594    }
595}
596pub(crate) async fn json(
597    provider: CloudProvider,
598    response: Response,
599) -> Result<Value, StorageError> {
600    Ok(json_response(provider, response).await?.0)
601}
602#[expect(
603    clippy::map_err_ignore,
604    reason = "the response is kept as the cause; the parse error adds nothing to it"
605)]
606pub(crate) async fn json_response(
607    provider: CloudProvider,
608    response: Response,
609) -> Result<(Value, ProviderResponse), StorageError> {
610    let response = checked(provider, response).await?;
611    let status = response.status().as_u16();
612    let headers = response.headers().clone();
613    let body = response.bytes().await.map_err(|error| transport(provider, error))?;
614    let value = serde_json::from_slice(&body)
615        .map_err(|_| invalid_response(provider, status, headers.clone(), body.to_vec()))?;
616    Ok((value, ProviderResponse::new(status, headers, body.to_vec())))
617}
618pub(crate) fn string<'a>(value: &'a Value, field: &str) -> Result<&'a str, StorageError> {
619    value[field]
620        .as_str()
621        .filter(|s| !s.is_empty())
622        .ok_or(StorageFailure::Protocol.with_source("missing string field"))
623}
624/// The cursor a Dropbox page names for the next: none, absent or null, on
625/// its last page; an empty one or one that isn't text is a protocol
626/// failure.
627pub(crate) fn page_cursor(value: &Value) -> Result<Option<&str>, StorageError> {
628    match optional_string(value, "cursor")? {
629        Some("") => Err(StorageFailure::Protocol.with_source("empty Dropbox page cursor")),
630        cursor => Ok(cursor),
631    }
632}
633/// The next page a Graph listing names in `@odata.nextLink`: none on its
634/// last page; a link that isn't text is a protocol failure.
635pub(crate) fn next_link(value: &Value) -> Result<Option<&str>, StorageError> {
636    match value.get("@odata.nextLink") {
637        None => Ok(None),
638        Some(_) => string(value, "@odata.nextLink").map(Some),
639    }
640}
641/// An optional nullable provider field must still be text when present.
642pub(crate) fn optional_string<'a>(
643    value: &'a Value,
644    field: &str,
645) -> Result<Option<&'a str>, StorageError> {
646    match value.get(field) {
647        None | Some(Value::Null) => Ok(None),
648        Some(Value::String(value)) => Ok(Some(value)),
649        Some(_) => Err(StorageFailure::Protocol.with_source("non-string optional field")),
650    }
651}
652pub(crate) fn timestamp(
653    value: &Value,
654    field: &str,
655) -> Result<coven_format::value::StorageTime, StorageError> {
656    use aws_sdk_s3::primitives::{DateTime, DateTimeFormat};
657    let timestamp = DateTime::from_str(string(value, field)?, DateTimeFormat::DateTimeWithOffset)
658        .map_err(|error| StorageFailure::Protocol.with_source(error))?;
659    storage_time(timestamp)
660}
661/// A provider's date with every returned digit; a time before the Unix epoch
662/// is a protocol error.
663#[expect(
664    clippy::map_err_ignore,
665    reason = "a time before the epoch is the protocol failure this names"
666)]
667pub(crate) fn storage_time(
668    time: aws_sdk_s3::primitives::DateTime,
669) -> Result<coven_format::value::StorageTime, StorageError> {
670    let seconds = u64::try_from(time.secs())
671        .map_err(|_| StorageFailure::Protocol.with_source("provider time precedes the epoch"))?;
672    coven_format::value::StorageTime::new(seconds, time.subsec_nanos())
673        .map_err(|error| StorageFailure::Protocol.with_source(error))
674}
675pub(crate) fn array<'a>(value: &'a Value, field: &str) -> Result<&'a Vec<Value>, StorageError> {
676    value[field].as_array().ok_or(StorageFailure::Protocol.with_source("missing array field"))
677}
678pub(crate) fn endpoint(
679    base: &str,
680    segments: &[&str],
681    query: &[(&str, &str)],
682) -> Result<String, StorageError> {
683    let mut url = url::Url::parse(base)
684        .map_err(|error| StorageFailure::InvalidConfiguration.with_source(error))?;
685    url.path_segments_mut()
686        .map_err(|()| {
687            StorageFailure::InvalidConfiguration.with_source("provider endpoint cannot hold paths")
688        })?
689        .pop_if_empty()
690        .extend(segments);
691    if !query.is_empty() {
692        url.query_pairs_mut().extend_pairs(query.iter().copied());
693    }
694    Ok(url.into())
695}
696pub(crate) fn same_origin(base: &str, target: &str) -> Result<(), StorageError> {
697    let base = url::Url::parse(base)
698        .map_err(|error| StorageFailure::InvalidConfiguration.with_source(error))?;
699    let target =
700        url::Url::parse(target).map_err(|error| StorageFailure::Protocol.with_source(error))?;
701    if target.origin() != base.origin()
702        || !target.username().is_empty()
703        || target.password().is_some()
704    {
705        return Err(StorageFailure::Protocol.with_source("provider URL crossed origins"));
706    }
707    Ok(())
708}
709pub(crate) fn validate_content_range(header: &str, range: ByteRange) -> Result<u64, StorageError> {
710    let (bounds, total) = header
711        .strip_prefix("bytes ")
712        .and_then(|h| h.split_once('/'))
713        .ok_or(StorageFailure::Protocol.with_source("invalid Content-Range"))?;
714    let total: u64 = total.parse().map_err(|error| StorageFailure::Protocol.with_source(error))?;
715    if range.start() < total
716        && range.end() > total
717        && bounds == format!("{}-{}", range.start(), total - 1)
718    {
719        return Err(StorageFailure::InvalidRange.into());
720    }
721    if bounds != format!("{}-{}", range.start(), range.end() - 1) || range.end() > total {
722        return Err(StorageFailure::Protocol.with_source("Content-Range disagrees with request"));
723    }
724    Ok(total)
725}
726
727pub(crate) async fn verify_published_part(
728    storage: &dyn crate::ProviderOps,
729    session: &mut crate::UploadSession,
730    bytes: &[u8],
731    end: u64,
732) -> Result<(), StorageError> {
733    compare_published_part(storage, session, bytes, end).await?;
734    session.confirmed = end;
735    if end == session.total {
736        session.state = crate::session::SessionState::Complete;
737    }
738    Ok(())
739}
740
741pub(crate) async fn compare_published_part(
742    storage: &dyn crate::ProviderOps,
743    session: &crate::UploadSession,
744    bytes: &[u8],
745    end: u64,
746) -> Result<(), StorageError> {
747    let range = ByteRange::new(session.confirmed, end)?;
748    let actual = storage.read_object(session.path(), Some(range)).await?;
749    actual.check(session.path(), Some(range))?;
750    if actual.metadata.size != session.total || actual.bytes != bytes {
751        return Err(StorageFailure::AlreadyExists.into());
752    }
753    Ok(())
754}
755
756#[cfg(test)]
757#[path = "http_tests.rs"]
758mod tests;