tftio-org-gdocs 0.1.1

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! OAuth 2.0 authentication via the installed-app (loopback) flow.
//!
//! Token acquisition, on-disk caching, and silent refresh are delegated to
//! `yup-oauth2` — the maintained crate the Google API bindings themselves use —
//! so this crate never hand-rolls the OAuth dance (architecture decision A2).
//!
//! Scopes are least-privilege (D4): edit Google Docs, and access only the Drive
//! files this app creates or opens (sufficient to read and resolve comments on
//! app-created documents).

use std::path::Path;

use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use yup_oauth2::authenticator::Authenticator;
use yup_oauth2::{
    CustomHyperClientBuilder, InstalledFlowAuthenticator, InstalledFlowReturnMethod,
    read_application_secret,
};

use crate::error::{Error, Result};

/// OAuth scopes requested by `org-gdocs` (least privilege, D4).
pub const SCOPES: [&str; 2] = [
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/drive.file",
];

/// HTTPS connector shared by the authenticator and the API hubs.
pub type Connector = HttpsConnector<HttpConnector>;

/// A built OAuth authenticator over the shared HTTPS [`Connector`].
pub type GoogleAuthenticator = Authenticator<Connector>;

/// Build an HTTPS connector using rustls with the platform's native roots.
///
/// # Errors
///
/// Returns [`Error::Auth`] when the native root certificate store cannot be
/// loaded.
pub fn https_connector() -> Result<Connector> {
    // rustls 0.23 needs a process-default `CryptoProvider`; with both `ring` and
    // `aws-lc-rs` reachable in the tree it cannot pick one automatically. Install
    // `ring` (our selected backend) once — a later call returns the already-set
    // provider, which we ignore.
    let _ = rustls::crypto::ring::default_provider().install_default();

    let connector = hyper_rustls::HttpsConnectorBuilder::new()
        .with_native_roots()
        .map_err(|err| Error::Auth(format!("load native TLS roots: {err}")))?
        .https_or_http()
        .enable_http1()
        .enable_http2()
        .build();
    Ok(connector)
}

/// Build an authenticator for the given credential and token-cache paths.
///
/// The interactive loopback flow runs lazily on first token request (see
/// [`obtain_token`]); constructing the authenticator alone does not prompt.
///
/// # Errors
///
/// Returns [`Error::Auth`] when the client secret cannot be read or the
/// authenticator cannot be built.
pub async fn authenticator(
    credentials_path: &Path,
    token_path: &Path,
) -> Result<GoogleAuthenticator> {
    let secret = read_application_secret(credentials_path)
        .await
        .map_err(|err| {
            Error::Auth(format!(
                "read credentials {}: {err}",
                credentials_path.display()
            ))
        })?;

    // `persist_tokens_to_disk` writes the cache but does not create its parent
    // directory; ensure the data dir exists first (the Go reference did the same).
    ensure_parent_dir(token_path)?;

    let client = Client::builder(TokioExecutor::new()).build(https_connector()?);

    InstalledFlowAuthenticator::with_client(
        secret,
        InstalledFlowReturnMethod::HTTPRedirect,
        CustomHyperClientBuilder::from(client),
    )
    .persist_tokens_to_disk(token_path)
    .build()
    .await
    .map_err(|err| Error::Auth(format!("build authenticator: {err}")))
}

/// Ensure the parent directory of `path` exists, creating it if necessary.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be created.
fn ensure_parent_dir(path: &Path) -> Result<()> {
    let Some(parent) = path.parent() else {
        return Ok(());
    };
    std::fs::create_dir_all(parent).map_err(|source| Error::Io {
        path: parent.to_path_buf(),
        source,
    })
}

/// Force token acquisition for [`SCOPES`], running the interactive loopback flow
/// when no valid cached token exists and persisting the result.
///
/// # Errors
///
/// Returns [`Error::Auth`] when the flow fails or no token can be obtained.
pub async fn obtain_token(auth: &GoogleAuthenticator) -> Result<()> {
    auth.token(&SCOPES)
        .await
        .map(|_token| ())
        .map_err(|err| Error::Auth(format!("acquire token: {err}")))
}