use std::{
fs::{File, OpenOptions},
path::{Path, PathBuf},
time::Duration,
};
use config::{Config, FileFormat};
use s2_sdk::{
self as sdk,
types::{AccountEndpoint, BasinEndpoint, S2Config, S2Endpoints},
};
use serde::{Deserialize, Serialize};
use crate::error::{CliConfigError, CliError};
const CONFIG_LOCK_TIMEOUT: Duration = Duration::from_secs(60);
pub const DEFAULT_ACCOUNT_ENDPOINT: &str = "https://a.s2.dev";
pub const DEFAULT_BASIN_ENDPOINT: &str = "https://{basin}.b.s2.dev";
pub struct ConfigLock {
file: File,
}
impl Drop for ConfigLock {
fn drop(&mut self) {
let _ = fs2::FileExt::unlock(&self.file);
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Compression {
Gzip,
Zstd,
}
impl From<Compression> for sdk::types::Compression {
fn from(value: Compression) -> Self {
match value {
Compression::Gzip => sdk::types::Compression::Gzip,
Compression::Zstd => sdk::types::Compression::Zstd,
}
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CliConfig {
pub access_token: Option<String>,
pub stored_access_token: Option<StoredCredentialReference>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pending_access_token_cleanup: Vec<StoredCredentialReference>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pending_oauth_cleanup: Vec<StoredCredentialReference>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pending_oauth_revocation: Vec<OAuthSession>,
pub auth_method: Option<AuthMethod>,
pub oauth: Option<OAuthSession>,
pub account_endpoint: Option<String>,
pub basin_endpoint: Option<String>,
pub compression: Option<Compression>,
pub ssl_no_verify: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
pub enum AuthMethod {
#[serde(rename = "access_token")]
#[value(name = "access-token")]
AccessToken,
#[serde(rename = "oauth")]
#[value(name = "browser-login")]
BrowserLogin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuthSession {
pub issuer: String,
pub client_id: String,
pub account_endpoint: String,
pub basin_endpoint: String,
pub credential_id: String,
pub credential_store: CredentialStore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialStore {
Keyring,
File,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredCredentialReference {
pub credential_id: String,
pub credential_store: CredentialStore,
}
impl OAuthSession {
pub fn credential_reference(&self) -> StoredCredentialReference {
StoredCredentialReference {
credential_id: self.credential_id.clone(),
credential_store: self.credential_store,
}
}
}
#[cfg(target_os = "windows")]
pub fn config_path() -> Result<PathBuf, CliConfigError> {
let mut path = dirs::config_dir().ok_or(CliConfigError::DirNotFound)?;
path.push("s2");
path.push("config.toml");
Ok(path)
}
#[cfg(not(target_os = "windows"))]
pub fn config_path() -> Result<PathBuf, CliConfigError> {
let mut path = dirs::home_dir().ok_or(CliConfigError::DirNotFound)?;
path.push(".config");
path.push("s2");
path.push("config.toml");
Ok(path)
}
pub fn config_path_string() -> String {
config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<config file>".to_string())
}
pub fn load_config_file() -> Result<CliConfig, CliConfigError> {
let path = config_path()?;
if !path.exists() {
return Ok(CliConfig::default());
}
let builder = Config::builder().add_source(config::File::new(
path.to_str().expect("config path is valid utf8"),
FileFormat::Toml,
));
Ok(builder.build()?.try_deserialize::<CliConfig>()?)
}
pub fn load_cli_config() -> Result<CliConfig, CliConfigError> {
access_token_from_environment()?;
let mut config = load_config_file()?;
for (name, key) in [
("S2_ACCOUNT_ENDPOINT", ConfigKey::AccountEndpoint),
("S2_BASIN_ENDPOINT", ConfigKey::BasinEndpoint),
("S2_COMPRESSION", ConfigKey::Compression),
("S2_SSL_NO_VERIFY", ConfigKey::SslNoVerify),
] {
if let Some(value) = environment_value(name)? {
config.set(key, value)?;
}
}
Ok(config)
}
fn environment_value(name: &'static str) -> Result<Option<String>, CliConfigError> {
let Some(value) = std::env::var_os(name) else {
return Ok(None);
};
if value.is_empty() {
return Ok(None);
}
value
.into_string()
.map(Some)
.map_err(|_| CliConfigError::InvalidEnvironmentValue(name))
}
pub fn access_token_from_environment() -> Result<Option<String>, CliConfigError> {
let Some(value) = std::env::var_os("S2_ACCESS_TOKEN") else {
return Ok(None);
};
if value.is_empty() {
return Ok(None);
}
value
.into_string()
.map(Some)
.map_err(|_| CliConfigError::InvalidAccessTokenEnvironment)
}
pub async fn acquire_config_lock() -> Result<ConfigLock, CliConfigError> {
let path = config_path()?;
let parent = path.parent().ok_or(CliConfigError::DirNotFound)?;
std::fs::create_dir_all(parent).map_err(CliConfigError::Lock)?;
secure_config_dir(parent).map_err(CliConfigError::Lock)?;
let lock_path = path.with_file_name("config.lock");
let mut options = OpenOptions::new();
options.create(true).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let file = options.open(lock_path).map_err(CliConfigError::Lock)?;
let deadline = tokio::time::Instant::now() + CONFIG_LOCK_TIMEOUT;
loop {
match fs2::FileExt::try_lock_exclusive(&file) {
Ok(()) => return Ok(ConfigLock { file }),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if tokio::time::Instant::now() >= deadline {
return Err(CliConfigError::LockTimedOut);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(error) => return Err(CliConfigError::Lock(error)),
}
}
}
#[derive(
Debug, Clone, Copy, clap::ValueEnum, strum::Display, strum::EnumString, strum::VariantNames,
)]
#[clap(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ConfigKey {
#[value(hide = true)]
AccessToken,
AccountEndpoint,
BasinEndpoint,
Compression,
SslNoVerify,
}
impl CliConfig {
pub fn has_legacy_access_token(&self) -> bool {
self.access_token
.as_ref()
.is_some_and(|token| !token.is_empty())
}
pub fn has_stored_access_token(&self) -> bool {
self.stored_access_token.is_some() || self.has_legacy_access_token()
}
pub fn get(&self, key: ConfigKey) -> Option<String> {
match key {
ConfigKey::AccessToken => self
.has_stored_access_token()
.then(|| "<redacted>".to_owned()),
ConfigKey::AccountEndpoint => self.account_endpoint.clone(),
ConfigKey::BasinEndpoint => self.basin_endpoint.clone(),
ConfigKey::Compression => self.compression.map(|c| c.to_string()),
ConfigKey::SslNoVerify => self.ssl_no_verify.map(|v| v.to_string()),
}
}
pub fn set(&mut self, key: ConfigKey, value: String) -> Result<(), CliConfigError> {
match key {
ConfigKey::AccessToken => return Err(CliConfigError::CredentialManagedSeparately),
ConfigKey::AccountEndpoint => self.account_endpoint = Some(value),
ConfigKey::BasinEndpoint => self.basin_endpoint = Some(value),
ConfigKey::Compression => {
self.compression = Some(
value
.parse()
.map_err(|_| CliConfigError::InvalidValue(key.to_string(), value))?,
);
}
ConfigKey::SslNoVerify => {
self.ssl_no_verify = Some(
value
.parse()
.map_err(|_| CliConfigError::InvalidValue(key.to_string(), value))?,
);
}
}
Ok(())
}
pub fn unset(&mut self, key: ConfigKey) -> Result<(), CliConfigError> {
match key {
ConfigKey::AccessToken => return Err(CliConfigError::CredentialManagedSeparately),
ConfigKey::AccountEndpoint => self.account_endpoint = None,
ConfigKey::BasinEndpoint => self.basin_endpoint = None,
ConfigKey::Compression => self.compression = None,
ConfigKey::SslNoVerify => self.ssl_no_verify = None,
}
Ok(())
}
}
pub fn save_cli_config(config: &CliConfig) -> Result<PathBuf, CliConfigError> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(CliConfigError::Write)?;
secure_config_dir(parent).map_err(CliConfigError::Write)?;
}
let toml = toml::to_string(config).map_err(CliConfigError::Serialize)?;
write_config_file(&path, &toml).map_err(CliConfigError::Write)?;
Ok(path)
}
#[cfg(unix)]
fn secure_config_dir(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
}
#[cfg(not(unix))]
fn secure_config_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn write_config_file(path: &Path, toml: &str) -> std::io::Result<()> {
use std::io::Write as _;
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid config path")
})?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
temp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
temp.write_all(toml.as_bytes())?;
temp.as_file_mut().sync_all()?;
temp.persist(path).map_err(|error| error.error)?;
let _ = sync_directory(parent);
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> std::io::Result<()> {
std::fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> std::io::Result<()> {
Ok(())
}
pub async fn set_config_value(key: ConfigKey, value: String) -> Result<PathBuf, CliConfigError> {
if matches!(key, ConfigKey::AccessToken) {
return Err(CliConfigError::CredentialManagedSeparately);
}
let _lock = acquire_config_lock().await?;
let mut config = load_config_file()?;
config.set(key, value)?;
save_cli_config(&config)
}
pub async fn unset_config_value(key: ConfigKey) -> Result<PathBuf, CliConfigError> {
if matches!(key, ConfigKey::AccessToken) {
return Err(CliConfigError::CredentialManagedSeparately);
}
let _lock = acquire_config_lock().await?;
let mut config = load_config_file()?;
config.unset(key)?;
save_cli_config(&config)
}
pub async fn select_auth_method(method: AuthMethod) -> Result<PathBuf, CliConfigError> {
let _lock = acquire_config_lock().await?;
let mut config = load_config_file()?;
match method {
AuthMethod::AccessToken if !config.has_stored_access_token() => {
return Err(CliConfigError::StoredAccessTokenNotConfigured);
}
AuthMethod::BrowserLogin if config.oauth.is_none() => {
return Err(CliConfigError::BrowserLoginNotConfigured);
}
_ => {}
}
config.auth_method = Some(method);
save_cli_config(&config)
}
pub fn sdk_config(
config: &CliConfig,
access_token: &str,
user_agent: &str,
) -> Result<S2Config, CliError> {
let compression: sdk::types::Compression = config
.compression
.map(Into::into)
.unwrap_or(sdk::types::Compression::None);
let mut sdk_config = S2Config::new(access_token)
.with_user_agent(user_agent)
.expect("valid user agent")
.with_request_timeout(Duration::from_secs(30))
.with_compression(compression);
match (&config.account_endpoint, &config.basin_endpoint) {
(Some(account_endpoint_str), Some(basin_endpoint_str)) => {
let account_endpoint = AccountEndpoint::new(account_endpoint_str)
.map_err(|e| CliError::EndpointsInvalid(e.to_string()))?;
let basin_endpoint = BasinEndpoint::new(basin_endpoint_str)
.map_err(|e| CliError::EndpointsInvalid(e.to_string()))?;
let endpoints = S2Endpoints::new(account_endpoint, basin_endpoint)
.map_err(|e| CliError::EndpointsInvalid(e.to_string()))?;
sdk_config = sdk_config.with_endpoints(endpoints);
}
(Some(_), None) => {
eprintln!(
"Warning: account endpoint is set but basin endpoint is not. \
Both must be set to use custom endpoints. Using default endpoints"
);
}
(None, Some(_)) => {
eprintln!(
"Warning: basin endpoint is set but account endpoint is not. \
Both must be set to use custom endpoints. Using default endpoints"
);
}
(None, None) => {}
}
if config.ssl_no_verify == Some(true) {
tracing::warn!("SSL certificate verification is disabled.");
sdk_config = sdk_config.with_insecure_skip_cert_verification(true);
}
Ok(sdk_config)
}