| // Copyright 2021 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| //! Provide Google Cloud Storage (GCS) access. |
| |
| use { |
| anyhow::{bail, Context, Result}, |
| async_lock::Mutex, |
| fuchsia_hyper::HttpsClient, |
| http::{request, StatusCode}, |
| hyper::{Body, Method, Request, Response}, |
| once_cell::sync::OnceCell, |
| regex::Regex, |
| serde::{Deserialize, Serialize}, |
| serde_json, |
| std::{ |
| fmt, fs, |
| io::{self, BufRead, BufReader, Read, Write}, |
| path::Path, |
| string::String, |
| }, |
| thiserror::Error, |
| url::{form_urlencoded, Url}, |
| }; |
| |
| /// For a web site, a client secret is kept locked away in a secure server. This |
| /// is not a web site and the value is needed, so a non-secret "secret" is used. |
| /// |
| /// These values (and the quote following) are taken form |
| /// https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/c6a2ee693093926868170f678d8d290bf0de0c15/third_party/gsutil/oauth2_plugin/oauth2_helper.py |
| /// and |
| /// https://chromium.googlesource.com/catapult/+/2c541cdf008959bc9813c641cc4ecd0194979486/third_party/gsutil/gslib/utils/system_util.py#177 |
| /// |
| /// "Google OAuth2 clients always have a secret, even if the client is an |
| /// installed application/utility such as gsutil. Of course, in such cases the |
| /// "secret" is actually publicly known; security depends entirely on the |
| /// secrecy of refresh tokens, which effectively become bearer tokens." |
| const GSUTIL_CLIENT_ID: &str = "909320924072.apps.googleusercontent.com"; |
| const GSUTIL_CLIENT_SECRET: &str = "p3RlpR10xMFh9ZXBS/ZNLYUu"; |
| |
| const APPROVE_AUTH_CODE_URL: &str = "\ |
| https://accounts.google.com/o/oauth2/auth?\ |
| scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform\ |
| &redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob\ |
| &response_type=code\ |
| &access_type=offline\ |
| &client_id="; |
| |
| /// Base URL for JSON API access. |
| const API_BASE: &str = "https://www.googleapis.com/storage/v1"; |
| |
| /// Base URL for reading (blob) objects. |
| const STORAGE_BASE: &str = "https://storage.googleapis.com"; |
| |
| /// URL used to exchange an auth_code for a refresh_token. |
| const EXCHANGE_AUTH_CODE_URL: &str = "https://oauth2.googleapis.com/token"; |
| |
| /// URL used for gaining a new access token. |
| /// |
| /// See RefreshTokenRequest, OauthTokenResponse. |
| const OAUTH_REFRESH_TOKEN_ENDPOINT: &str = "https://www.googleapis.com/oauth2/v3/token"; |
| |
| /// Errors specific to this lib, some may be addressed by the caller. |
| #[derive(Error, Debug)] |
| pub enum GcsError { |
| /// The refresh token is no longer valid (e.g. expired or revoked). |
| /// Do something similar to `gsutil config` or auth_code_url() to generate a |
| /// new refresh token and try again. |
| #[error("GCS refresh token is invalid. Please generate a new GCS OAUTH2 refresh token.")] |
| NeedNewRefreshToken, |
| |
| /// The access token is no longer valid (e.g. expired or revoked). Access |
| /// tokens are short lived and don't require user interaction to renew. |
| /// Refresh the access token and try the action again. |
| #[error("GCS access token is invalid. This should be handled. Report as a bug.")] |
| NeedNewAccessToken, |
| |
| /// GCS returned an empty response for the request. |
| #[error("The requested GCS bucket + path contains no data (not found): {0}, {1}.")] |
| NotFound(String, String), |
| |
| /// The authorization code is empty string (or None). Likely a mistake in |
| /// the calling application (e.g. calling new_with_code() with an empty |
| /// string). |
| #[error("Empty authorization code passed to GCS. Report as a bug.")] |
| MissingAuthCode, |
| |
| /// Likely a mistake in the calling application. |
| #[error("A GCS refresh token is required to gain a new access token. Report as a bug.")] |
| MissingRefreshToken, |
| |
| /// May be a network issue. Consider informing the user and offer to retry. |
| #[error( |
| "Unable to refresh GCS access token: HTTP {0}. Check network connection and try again." |
| )] |
| RefreshAccessError(http::StatusCode), |
| |
| /// May be a network issue. Consider informing the user and offer to retry. |
| #[error("GCS HttpResponseError: {0}. Check network connection and try again.")] |
| HttpResponseError(http::StatusCode), |
| |
| /// May be a network issue. Consider informing the user and offer to retry. |
| #[error("GCS HttpError: {0}. Check network connection and try again.")] |
| HttpError(#[from] http::Error), |
| |
| /// May be a network issue. Consider informing the user and offer to retry. |
| #[error("GCS HyperError: {0}. Check network connection and try again.")] |
| HyperError(#[from] hyper::Error), |
| |
| /// Possible in-transit corruption, but more likely the GCS protocol |
| /// changed. May need to update GCS lib. |
| #[error("GCS SerdeError: {0}. Report as a bug.")] |
| SerdeError(#[from] serde_json::Error), |
| } |
| |
| /// POST body to [`OAUTH_REFRESH_TOKEN_ENDPOINT`]. |
| #[derive(Serialize)] |
| struct RefreshTokenRequest<'a> { |
| /// A value provided by GCS for fetching tokens. |
| client_id: &'a str, |
| |
| /// A (normally secret) value provided by GCS for fetching tokens. |
| client_secret: &'a str, |
| |
| /// A long lasting secret token used to attain a new `access_token`. |
| refresh_token: &'a str, |
| |
| /// Will be "refresh_token" for a refresh token. |
| grant_type: &'a str, |
| } |
| |
| /// Response body from [`OAUTH_REFRESH_TOKEN_ENDPOINT`]. |
| /// 'expires_in' is intentionally omitted. |
| #[derive(Deserialize)] |
| struct OauthTokenResponse { |
| /// A limited time (see `expires_in`) token used in an Authorization header. |
| access_token: String, |
| } |
| |
| /// POST body to [`EXCHANGE_AUTH_CODE_URL`]. |
| #[derive(Serialize)] |
| struct ExchangeAuthCodeRequest<'a> { |
| /// A value provided by GCS for fetching tokens. |
| client_id: &'a str, |
| |
| /// A (normally secret) value provided by GCS for fetching tokens. |
| client_secret: &'a str, |
| |
| /// A short lived authorization code used to attain an initial |
| /// `refresh_token` and `access_token`. |
| code: &'a str, |
| |
| /// Will be "authorization_code" for a authorization code. |
| grant_type: &'a str, |
| |
| /// For our use (a non-web program), always "urn:ietf:wg:oauth:2.0:oob" |
| redirect_uri: &'a str, |
| } |
| |
| /// Response body from [`EXCHANGE_AUTH_CODE_URL`]. |
| /// 'expires_in' is intentionally omitted. |
| #[derive(Deserialize)] |
| struct ExchangeAuthCodeResponse { |
| /// A limited time (see `expires_in`) token used in an Authorization header. |
| access_token: Option<String>, |
| |
| /// A long lasting secret token. This value is a user secret and must not be |
| /// misused (such as by logging). Suitable for storing in a local file and |
| /// reusing later. |
| refresh_token: String, |
| } |
| |
| /// Response from the `/b/<bucket>/o` object listing API. |
| #[derive(Debug, serde::Deserialize)] |
| #[serde(rename_all = "camelCase")] |
| struct ListResponse { |
| /// Continuation token; only present when there is more data. |
| next_page_token: Option<String>, |
| |
| /// List of objects, sorted by name. |
| #[serde(default)] |
| items: Vec<ListResponseItem>, |
| } |
| |
| #[derive(Debug, serde::Deserialize)] |
| #[serde(rename_all = "camelCase")] |
| struct ListResponseItem { |
| /// GCS object name. |
| name: String, |
| } |
| |
| /// User credentials for use with GCS. |
| /// |
| /// Specifically to: |
| /// - api_base: https://www.googleapis.com/storage/v1 |
| /// - storage_base: https://storage.googleapis.com |
| pub struct TokenStore { |
| /// Base URL for JSON API access. |
| api_base: Url, |
| |
| /// Base URL for reading (blob) objects. |
| storage_base: Url, |
| |
| /// A long lasting secret token used to attain a new `access_token`. This |
| /// value is a user secret and must not be misused (such as by logging). |
| refresh_token: Option<String>, |
| |
| /// A limited time token used in an Authorization header. |
| /// |
| /// Only valid if `Instant::now() < expired()`, though it's better to allow |
| /// some extra time (e.g. maybe 30 seconds). |
| access_token: Mutex<String>, |
| } |
| |
| impl TokenStore { |
| /// Only allow access to public GCS data. |
| pub fn new_without_auth() -> Self { |
| Self { |
| api_base: Url::parse(API_BASE).expect("parse API_BASE"), |
| storage_base: Url::parse(STORAGE_BASE).expect("parse STORAGE_BASE"), |
| refresh_token: None, |
| access_token: Mutex::new("".to_string()), |
| } |
| } |
| |
| /// Allow access to public and private (auth-required) GCS data. |
| /// |
| /// The `refresh_token` must not be an empty string (this will generate an |
| /// Err Result. |
| /// |
| /// The `access_token` is not required, but if it happens to be available, |
| /// i.e. if a code exchange was just done, it avoids an extra round-trip to |
| /// provide it here. |
| pub fn new_with_auth<T>(refresh_token: String, access_token: T) -> Result<Self, GcsError> |
| where |
| T: Into<Option<String>>, |
| { |
| if refresh_token.is_empty() { |
| return Err(GcsError::MissingRefreshToken); |
| } |
| let access_token = Mutex::new(access_token.into().unwrap_or("".to_string())); |
| Ok(Self { |
| api_base: Url::parse(API_BASE).expect("parse API_BASE"), |
| storage_base: Url::parse(STORAGE_BASE).expect("parse STORAGE_BASE"), |
| refresh_token: Some(refresh_token), |
| access_token, |
| }) |
| } |
| |
| /// Allow access to public and private GCS data. |
| /// |
| /// The `https_client` will be used to exchange the auth code for a refresh |
| /// token. |
| /// The `auth_code` must not be an empty string (this will generate an Err |
| /// Result. |
| pub async fn new_with_code( |
| https_client: &HttpsClient, |
| auth_code: &str, |
| ) -> Result<Self, GcsError> { |
| if auth_code.is_empty() { |
| return Err(GcsError::MissingAuthCode); |
| } |
| // Add POST parameters to exchange the auth_code for a refresh_token |
| // and possibly an access_token. |
| let body = form_urlencoded::Serializer::new(String::new()) |
| .append_pair("code", auth_code) |
| .append_pair("redirect_uri", "urn:ietf:wg:oauth:2.0:oob") |
| .append_pair("client_id", GSUTIL_CLIENT_ID) |
| .append_pair("client_secret", GSUTIL_CLIENT_SECRET) |
| .append_pair("grant_type", "authorization_code") |
| .finish(); |
| // Build the request and send it. |
| let req = Request::builder() |
| .method(Method::POST) |
| .header("Content-Type", "application/x-www-form-urlencoded") |
| .uri(EXCHANGE_AUTH_CODE_URL) |
| .body(Body::from(body))?; |
| let res = https_client.request(req).await?; |
| |
| // If the response was successful, extract the new tokens. |
| if res.status().is_success() { |
| let bytes = hyper::body::to_bytes(res.into_body()).await?; |
| let info: ExchangeAuthCodeResponse = serde_json::from_slice(&bytes)?; |
| let access_token = Mutex::new(info.access_token.unwrap_or("".to_string())); |
| Ok(Self { |
| api_base: Url::parse(API_BASE).expect("parse API_BASE"), |
| storage_base: Url::parse(STORAGE_BASE).expect("parse STORAGE_BASE"), |
| refresh_token: Some(info.refresh_token), |
| access_token, |
| }) |
| } else { |
| return Err(GcsError::RefreshAccessError(res.status())); |
| } |
| } |
| |
| pub fn refresh_token(&self) -> Option<String> { |
| self.refresh_token.to_owned() |
| } |
| |
| /// Create localhost base URLs and fake credentials for testing. |
| #[cfg(test)] |
| fn local_fake(refresh_token: Option<String>) -> Self { |
| let api_base = Url::parse("http://localhost:9000").expect("api_base"); |
| let storage_base = Url::parse("http://localhost:9001").expect("storage_base"); |
| Self { api_base, storage_base, refresh_token, access_token: Mutex::new("".to_string()) } |
| } |
| |
| /// Apply Authorization header, if available. |
| /// |
| /// If no access_token is set, no changes are made to the builder. |
| async fn authorize(&self, builder: request::Builder) -> Result<request::Builder> { |
| if self.refresh_token.is_none() { |
| return Ok(builder); |
| } |
| let access_token = self.access_token.lock().await; |
| Ok(builder.header("Authorization", format!("Bearer {}", access_token))) |
| } |
| |
| /// Use the refresh token to get a new access token. |
| async fn refresh_access_token(&self, https_client: &HttpsClient) -> Result<(), GcsError> { |
| match &self.refresh_token { |
| Some(refresh_token) => { |
| let req_body = RefreshTokenRequest { |
| client_id: GSUTIL_CLIENT_ID, |
| client_secret: GSUTIL_CLIENT_SECRET, |
| refresh_token: refresh_token, |
| grant_type: "refresh_token", |
| }; |
| let body = serde_json::to_vec(&req_body)?; |
| let req = Request::builder() |
| .method(Method::POST) |
| .uri(OAUTH_REFRESH_TOKEN_ENDPOINT) |
| .body(Body::from(body))?; |
| |
| let res = https_client.request(req).await?; |
| |
| if res.status().is_success() { |
| let bytes = hyper::body::to_bytes(res.into_body()).await?; |
| let info: OauthTokenResponse = serde_json::from_slice(&bytes)?; |
| let mut access_token = self.access_token.lock().await; |
| *access_token = info.access_token; |
| Ok(()) |
| } else { |
| match res.status() { |
| StatusCode::BAD_REQUEST => return Err(GcsError::NeedNewRefreshToken), |
| _ => return Err(GcsError::HttpResponseError(res.status())), |
| } |
| } |
| } |
| None => return Err(GcsError::MissingRefreshToken), |
| } |
| } |
| |
| /// Reads content of a stored object (blob) from GCS. |
| /// |
| /// A leading slash "/" on `object` will be ignored. |
| pub(crate) async fn download( |
| &self, |
| https_client: &HttpsClient, |
| bucket: &str, |
| object: &str, |
| ) -> Result<Response<Body>> { |
| // If the bucket and object are from a gs:// URL, the object may have a |
| // undesirable leading slash. Trim it if present. |
| let object = if object.starts_with('/') { &object[1..] } else { object }; |
| |
| let res = self.attempt_download(https_client, bucket, object).await?; |
| Ok(match res.status() { |
| StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => { |
| match &self.refresh_token { |
| Some(_) => { |
| // Refresh the access token and make one extra try. |
| self.refresh_access_token(&https_client).await?; |
| self.attempt_download(https_client, bucket, object).await? |
| } |
| None => { |
| // With no refresh token, there's no option to retry. |
| res |
| } |
| } |
| } |
| _ => res, |
| }) |
| } |
| |
| /// Make one attempt to read content of a stored object (blob) from GCS |
| /// without considering redirects or retries. |
| /// |
| /// Callers are expected to handle errors and call attempt_download() again |
| /// as desired (e.g. follow redirects). |
| async fn attempt_download( |
| &self, |
| https_client: &HttpsClient, |
| bucket: &str, |
| object: &str, |
| ) -> Result<Response<Body>> { |
| let url = self.storage_base.join(&format!("{}/{}", bucket, object))?; |
| self.send_request(https_client, url).await |
| } |
| |
| /// Make one attempt to request data from GCS. |
| /// |
| /// Callers are expected to handle errors and call attempt_download() again |
| /// as desired (e.g. follow redirects). |
| async fn send_request(&self, https_client: &HttpsClient, url: Url) -> Result<Response<Body>> { |
| let req = Request::builder().method(Method::GET).uri(url.into_string()); |
| let req = self.authorize(req).await?; |
| let req = req.body(Body::from(""))?; |
| |
| let res = https_client.request(req).await?; |
| Ok(res) |
| } |
| |
| /// List objects from GCS in `bucket` with matching `prefix`. |
| /// |
| /// A leading slash "/" on `prefix` will be ignored. |
| pub async fn list( |
| &self, |
| https_client: &HttpsClient, |
| bucket: &str, |
| prefix: &str, |
| ) -> Result<Vec<String>> { |
| Ok(match self.attempt_list(https_client, bucket, prefix).await { |
| Err(e) => { |
| match e.downcast_ref::<GcsError>() { |
| Some(GcsError::NeedNewAccessToken) => { |
| match &self.refresh_token { |
| Some(_) => { |
| // Refresh the access token and make one extra try. |
| self.refresh_access_token(&https_client).await?; |
| self.attempt_list(https_client, bucket, prefix).await? |
| } |
| None => { |
| // With no refresh token, there's no option to retry. |
| bail!( |
| "Access to gs://{}/{} requires authorization. Error {:?}", |
| bucket, |
| prefix, |
| e |
| ); |
| } |
| } |
| } |
| Some(_) | None => { |
| bail!("Unable to list GCS data. Error {:?}", e); |
| } |
| } |
| } |
| Ok(value) => value, |
| }) |
| } |
| |
| /// Make one attempt to list objects from GCS. |
| async fn attempt_list( |
| &self, |
| https_client: &HttpsClient, |
| bucket: &str, |
| prefix: &str, |
| ) -> Result<Vec<String>> { |
| // If the bucket and prefix are from a gs:// URL, the prefix may have a |
| // undesirable leading slash. Trim it if present. |
| let prefix = if prefix.starts_with('/') { &prefix[1..] } else { prefix }; |
| |
| let mut base_url = self.api_base.to_owned(); |
| base_url.path_segments_mut().unwrap().extend(&["b", bucket, "o"]); |
| base_url |
| .query_pairs_mut() |
| .append_pair("prefix", prefix) |
| .append_pair("prettyPrint", "false") |
| .append_pair("fields", "nextPageToken,items/name"); |
| let mut results = Vec::new(); |
| let mut page_token: Option<String> = None; |
| loop { |
| // Create a new URL for each "page" of results. |
| let mut url = base_url.clone(); |
| if let Some(t) = page_token { |
| url.query_pairs_mut().append_pair("pageToken", t.as_str()); |
| } |
| let res = self.send_request(https_client, url).await?; |
| match res.status() { |
| StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => { |
| bail!(GcsError::NeedNewAccessToken); |
| } |
| StatusCode::OK => { |
| let bytes = hyper::body::to_bytes(res.into_body()).await?; |
| let info: ListResponse = serde_json::from_slice(&bytes)?; |
| results.extend(info.items.into_iter().map(|i| i.name)); |
| if info.next_page_token.is_none() { |
| break; |
| } |
| page_token = info.next_page_token; |
| } |
| _ => { |
| bail!("Failed to list {:?} {:?}", base_url, res); |
| } |
| } |
| } |
| if results.is_empty() { |
| bail!(GcsError::NotFound(bucket.to_string(), prefix.to_string())); |
| } |
| Ok(results) |
| } |
| } |
| |
| impl fmt::Debug for TokenStore { |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| f.debug_struct("TokenStore") |
| .field("api_base", &self.api_base) |
| .field("storage_base", &self.storage_base) |
| .field("refresh_token", &self.refresh_token.as_ref().map(|_| "..")) |
| .field("access_token", &"..") |
| .finish() |
| } |
| } |
| |
| /// URL which guides the user to an authorization code. |
| /// |
| /// Expected flow: |
| /// - Request that the user open a browser to this location |
| /// - authorize and grant permission to this program to use GCS |
| /// - user, copy-pastes the auth code the web page provides back to this program |
| /// - create a TokenStore with TokenStore::new_with_code(pasted_string) |
| pub fn auth_code_url() -> String { |
| APPROVE_AUTH_CODE_URL.to_string() + GSUTIL_CLIENT_ID |
| } |
| |
| /// Convert an authorization code to a refresh token. |
| pub async fn auth_code_to_refresh(auth_code: &str) -> Result<String> { |
| use fuchsia_hyper::new_https_client; |
| let token_store = TokenStore::new_with_code(&new_https_client(), auth_code).await?; |
| match token_store.refresh_token() { |
| Some(s) => Ok(s.to_string()), |
| None => bail!("auth_code_to_refresh failed"), |
| } |
| } |
| |
| /// Ask the user to visit a URL and copy-paste the auth code provided. |
| /// |
| /// A helper wrapper around get_auth_code_with() using stdin/stdout. |
| /// |
| /// E.g. to get a Refresh Token |
| /// ``` |
| /// let auth_code = get_auth_code()?; |
| /// let refresh_token = auth_code_to_refresh(&auth_code).await?; |
| /// ``` |
| pub fn get_auth_code() -> Result<String> { |
| let stdout = io::stdout(); |
| let mut output = stdout.lock(); |
| let stdin = io::stdin(); |
| let mut input = stdin.lock(); |
| get_auth_code_with(&mut output, &mut input) |
| } |
| |
| /// Ask the user to visit a URL and copy-paste the authorization code provided. |
| /// |
| /// Consider using `get_auth_code()` for operation with stdin/stdout. |
| /// |
| /// For a GUI, consider creating a separate (custom) function to ask the user to |
| /// follow the web flow to get a authorization code (tip: use `auth_code_url()` |
| /// to get the URL). |
| pub fn get_auth_code_with<W, R>(writer: &mut W, reader: &mut R) -> Result<String> |
| where |
| W: Write, |
| R: Read, |
| { |
| writeln!( |
| writer, |
| "Please visit this site. Proceed through the web flow to allow access \ |
| and copy the authentication code:\ |
| \n\n{}\n\nPaste the code (from the web page) here\ |
| \nand press return: ", |
| auth_code_url(), |
| )?; |
| writer.flush().expect("flush auth code prompt"); |
| let mut auth_code = String::new(); |
| let mut buf_reader = BufReader::new(reader); |
| buf_reader.read_line(&mut auth_code).expect("Need an auth_code."); |
| Ok(auth_code.trim().to_string()) |
| } |
| |
| /// Fetch an existing refresh token from a .boto (gsutil) configuration file. |
| /// |
| /// Tip, the `boto_path` is commonly "~/.boto". E.g. |
| /// ``` |
| /// use home::home_dir; |
| /// let boto_path = Path::new(&home_dir().expect("home dir")).join(".boto"); |
| /// ``` |
| /// |
| /// TODO(fxbug.dev/82014): Using an ffx specific token will be preferred once |
| /// that feature is created. For the near term, an existing gsutil token is |
| /// workable. |
| /// |
| /// Alert: The refresh token is considered a private secret for the user. Do |
| /// not print the token to a log or otherwise disclose it. |
| pub fn read_boto_refresh_token<P: AsRef<Path>>(boto_path: P) -> Result<Option<String>> { |
| // Read the file at `boto_path` to retrieve a value from a line resembling |
| // "gs_oauth2_refresh_token = <string_of_chars>". |
| static GS_REFRESH_TOKEN_RE: OnceCell<Regex> = OnceCell::new(); |
| let re = GS_REFRESH_TOKEN_RE |
| .get_or_init(|| Regex::new(r#"\n\s*gs_oauth2_refresh_token\s*=\s*(\S+)"#).expect("regex")); |
| let data = fs::read_to_string(boto_path.as_ref())?; |
| let refresh_token = match re.captures(&data) { |
| Some(found) => Some(found.get(1).expect("found at least one").as_str().to_string()), |
| None => None, |
| }; |
| Ok(refresh_token) |
| } |
| |
| /// Overwrite the 'gs_oauth2_refresh_token' in the file at `boto_path`. |
| /// |
| /// TODO(fxbug.dev/82014): Using an ffx specific token will be preferred once |
| /// that feature is created. For the near term, an existing gsutil token is |
| /// workable. |
| /// |
| /// Alert: The refresh token is considered a private secret for the user. Do |
| /// not print the token to a log or otherwise disclose it. |
| pub fn write_boto_refresh_token<P: AsRef<Path>>(boto_path: P, token: &str) -> Result<()> { |
| use std::{fs::set_permissions, os::unix::fs::PermissionsExt}; |
| let boto_path = boto_path.as_ref(); |
| let data = if !boto_path.is_file() { |
| fs::File::create(boto_path).context("Create .boto file")?; |
| const USER_READ_WRITE: u32 = 0o600; |
| let permissions = std::fs::Permissions::from_mode(USER_READ_WRITE); |
| set_permissions(&boto_path, permissions).context("Boto set permissions")?; |
| format!( |
| "# This file was created by the Fuchsia GCS lib.\ |
| \n[GSUtil]\ |
| \ngs_oauth2_refresh_token = {}\ |
| \ndefault_project_id =\ |
| \n", |
| token |
| ) |
| } else { |
| static GS_UPDATE_REFRESH_TOKEN_RE: OnceCell<Regex> = OnceCell::new(); |
| let re = GS_UPDATE_REFRESH_TOKEN_RE.get_or_init(|| { |
| Regex::new(r#"(\n\s*gs_oauth2_refresh_token\s*=\s*)\S*"#).expect("regex") |
| }); |
| let data = fs::read_to_string(boto_path).context("read boto refresh")?; |
| re.replace(&data, format!("${{1}}{}", token).as_str()).to_string() |
| }; |
| fs::write(boto_path, data).context("Writing .boto file")?; |
| Ok(()) |
| } |
| |
| #[cfg(test)] |
| mod test { |
| use {super::*, fuchsia_hyper::new_https_client, hyper::StatusCode, tempfile}; |
| |
| #[test] |
| fn test_approve_auth_code_url() { |
| // If the GSUTIL_CLIENT_ID is changed, the APPROVE_AUTH_CODE_URL must be |
| // updated as well. |
| assert_eq!( |
| auth_code_url(), |
| format!( |
| "\ |
| https://accounts.google.com/o/oauth2/auth?\ |
| scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform\ |
| &redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob\ |
| &response_type=code\ |
| &access_type=offline\ |
| &client_id={}", |
| GSUTIL_CLIENT_ID |
| ) |
| ); |
| } |
| |
| #[test] |
| fn test_get_auth_code_with() { |
| let mut output: Vec<u8> = Vec::new(); |
| let mut input = "fake_auth_code".as_bytes(); |
| let auth_code = get_auth_code_with(&mut output, &mut input).expect("auth code"); |
| assert_eq!(auth_code, "fake_auth_code"); |
| } |
| |
| #[should_panic(expected = "Connection refused")] |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn test_fake_download() { |
| let token_store = TokenStore::local_fake(/*refresh_token=*/ None); |
| let bucket = "fake_bucket"; |
| let object = "fake/object/path.txt"; |
| token_store.download(&new_https_client(), bucket, object).await.expect("client download"); |
| } |
| |
| // This test is marked "ignore" because it actually downloads from GCS, |
| // which isn't good for a CI/GI test. It's here because it's handy to have |
| // as a local developer test. Run with `fx test gcs_lib_test -- --ignored`. |
| // Note: gsutil config is required. |
| #[ignore] |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn test_gcs_download_public() { |
| let token_store = TokenStore::new_without_auth(); |
| let bucket = "fuchsia"; |
| let object = "development/5.20210610.3.1/sdk/linux-amd64/gn.tar.gz"; |
| let res = token_store |
| .download(&new_https_client(), bucket, object) |
| .await |
| .expect("client download"); |
| assert_eq!(res.status(), StatusCode::OK); |
| } |
| |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn test_gcs_read_write_refresh_token() { |
| let boto_temp = tempfile::TempDir::new().expect("temp dir"); |
| let boto_path = boto_temp.path().join(".boto"); |
| assert!(!boto_path.is_file()); |
| // Test with no .boto file. |
| write_boto_refresh_token(&boto_path, "first-token").expect("write token"); |
| assert!(boto_path.is_file()); |
| let refresh_token = |
| read_boto_refresh_token(&boto_path).expect("boto").expect("first token"); |
| assert_eq!(refresh_token, "first-token"); |
| // Test updating existing .boto file. |
| write_boto_refresh_token(&boto_path, "second-token").expect("write token"); |
| let refresh_token = |
| read_boto_refresh_token(&boto_path).expect("boto").expect("second token"); |
| assert_eq!(refresh_token, "second-token"); |
| } |
| |
| // This test is marked "ignore" because it actually downloads from GCS, |
| // which isn't good for a CI/GI test. It's here because it's handy to have |
| // as a local developer test. Run with `fx test gcs_lib_test -- --ignored`. |
| // Note: gsutil config is required. |
| #[ignore] |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn test_gcs_download_auth() { |
| use home::home_dir; |
| let boto_path = Path::new(&home_dir().expect("home dir")).join(".boto"); |
| let refresh_token = |
| read_boto_refresh_token(&boto_path).expect("boto file").expect("refresh token"); |
| let token_store = TokenStore::new_with_auth(refresh_token, /*access_token=*/ None) |
| .expect("new with auth"); |
| let https = new_https_client(); |
| token_store.refresh_access_token(&https).await.expect("refresh_access_token"); |
| let bucket = "fuchsia-sdk"; |
| let object = "development/LATEST_LINUX"; |
| let res = token_store.download(&https, bucket, object).await.expect("client download"); |
| assert_eq!(res.status(), StatusCode::OK); |
| } |
| } |