use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::SailError;
pub const SETTINGS_KEYS: &[&str] = &["mode", "api_url", "sailbox_api_url", "imagebuilder_url"];
pub const API_KEY_TARGET_KEY: &str = "api_key_api_url";
pub const MODE_VALUES: &[&str] = &["prod", "dev", "staging", "local"];
const AUTH_HEADER: &str = "# Managed by `sail auth`. The SAIL_API_KEY env var overrides this.\n";
const CONFIG_HEADER: &str =
"# Managed by `sail config` and `sail auth`. Env vars override these values.\n";
fn is_settings_key(key: &str) -> bool {
SETTINGS_KEYS.contains(&key) || key == API_KEY_TARGET_KEY
}
pub fn sail_home() -> PathBuf {
if let Ok(raw) = std::env::var("SAIL_HOME") {
let raw = raw.trim();
if !raw.is_empty() {
return expand_user(raw);
}
}
let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
base.join(".sail")
}
pub fn expand_user(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(path)
}
pub fn auth_path() -> PathBuf {
sail_home().join("auth.toml")
}
pub fn config_path() -> PathBuf {
sail_home().join("config.toml")
}
pub fn load_auth_key() -> Result<Option<String>, SailError> {
let Some(table) = read_toml_table(&auth_path())? else {
return Ok(None);
};
let key = table
.get("api_key")
.and_then(toml::Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(key)
}
pub fn load_settings() -> Result<BTreeMap<String, String>, SailError> {
let Some(table) = read_toml_table(&config_path())? else {
return Ok(BTreeMap::new());
};
let mut values = BTreeMap::new();
let mut unknown = Vec::new();
for (key, value) in table {
if !is_settings_key(&key) {
unknown.push(key);
continue;
}
let text = match value {
toml::Value::String(s) => s,
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => f.to_string(),
_ => continue,
};
values.insert(key, text);
}
if !unknown.is_empty() {
unknown.sort();
return Err(SailError::Config {
message: format!(
"{} contains unrecognized setting(s): {}. Allowed settings: {}. \
Edit the file or run `sail config reset`.",
config_path().display(),
unknown.join(", "),
SETTINGS_KEYS.join(", "),
),
});
}
Ok(values)
}
pub(crate) fn auth_key_best_effort() -> Option<String> {
load_auth_key().ok().flatten()
}
fn read_toml_table(path: &Path) -> Result<Option<toml::Table>, SailError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(SailError::Internal {
message: format!("could not read {}: {err}", path.display()),
})
}
};
let table = toml::from_str(&text).map_err(|err| SailError::Config {
message: format!(
"could not parse {} ({err}). Edit the file to fix it, or run `sail config reset`.",
path.display()
),
})?;
Ok(Some(table))
}
pub fn save_auth_key(api_key: &str) -> Result<PathBuf, SailError> {
let dir = sail_home();
ensure_private_dir(&dir)?;
let body = format!("{AUTH_HEADER}api_key = {}\n", toml_basic_string(api_key));
let path = auth_path();
atomic_write(&path, body.as_bytes())?;
Ok(path)
}
pub fn clear_auth_key() -> Result<(), SailError> {
match fs::remove_file(auth_path()) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(SailError::Internal {
message: format!("could not remove {}: {err}", auth_path().display()),
}),
}
}
pub fn save_settings(values: &BTreeMap<String, String>) -> Result<(), SailError> {
let path = config_path();
if values.is_empty() {
return match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(SailError::Internal {
message: format!("could not remove {}: {err}", path.display()),
}),
};
}
let dir = sail_home();
ensure_private_dir(&dir)?;
let mut body = String::from(CONFIG_HEADER);
for (key, value) in values {
body.push_str(&format!("{key} = {}\n", toml_basic_string(value)));
}
atomic_write(&path, body.as_bytes())?;
Ok(())
}
pub fn resolve_target_api_url(api_url: &str, mode: &str) -> String {
let api_url = api_url.trim().trim_end_matches('/');
if !api_url.is_empty() {
return api_url.to_string();
}
super::config::api_url_for_mode(mode)
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or_default()
}
pub fn stored_key_matches_target(
settings: &BTreeMap<String, String>,
effective_target: &str,
) -> bool {
let Some(stored) = stored_key_target(settings) else {
return false;
};
let effective = effective_target.trim().trim_end_matches('/');
!effective.is_empty() && stored == effective
}
fn stored_key_target(settings: &BTreeMap<String, String>) -> Option<String> {
let nonempty = |key: &str| {
settings
.get(key)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(str::to_string)
};
if let Some(tag) = nonempty(API_KEY_TARGET_KEY) {
return Some(tag.trim_end_matches('/').to_string());
}
let api_url = nonempty("api_url");
let mode = nonempty("mode");
if api_url.is_none() && mode.is_none() {
return None;
}
let target = resolve_target_api_url(
api_url.as_deref().unwrap_or(""),
mode.as_deref().unwrap_or(""),
);
if target.is_empty() {
None
} else {
Some(target)
}
}
pub fn mask_secret(secret: &str) -> String {
if secret.is_empty() {
return String::new();
}
if secret.chars().count() <= 8 {
return "***".to_string();
}
let chars: Vec<char> = secret.chars().collect();
let head: String = chars[..4].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{head}…{tail}")
}
fn ensure_private_dir(dir: &Path) -> Result<(), SailError> {
if dir.exists() {
return Ok(());
}
fs::create_dir_all(dir).map_err(|err| SailError::Internal {
message: format!("could not create {}: {err}", dir.display()),
})?;
set_mode(dir, 0o700);
Ok(())
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SailError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config");
let tmp = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
let io_err = |err: std::io::Error, what: &Path| SailError::Internal {
message: format!("could not write {}: {err}", what.display()),
};
{
let mut file = fs::File::create(&tmp).map_err(|err| io_err(err, &tmp))?;
set_mode(&tmp, 0o600);
file.write_all(bytes).map_err(|err| io_err(err, &tmp))?;
file.flush().map_err(|err| io_err(err, &tmp))?;
}
fs::rename(&tmp, path).map_err(|err| {
let _ = fs::remove_file(&tmp);
SailError::Internal {
message: format!("could not replace {}: {err}", path.display()),
}
})
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) {}
fn toml_basic_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{0008}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{000C}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mask_secret_masks_long_keys_and_hides_short() {
assert_eq!(mask_secret(""), "");
assert_eq!(mask_secret("short"), "***");
assert_eq!(mask_secret("sk_123456789"), "sk_1…6789");
}
#[test]
fn toml_escaping_round_trips_control_chars() {
assert_eq!(toml_basic_string("plain"), "\"plain\"");
assert_eq!(toml_basic_string("a\nb"), "\"a\\nb\"");
assert_eq!(toml_basic_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
assert_eq!(toml_basic_string("\u{0001}"), "\"\\u0001\"");
}
#[test]
fn target_match_requires_equal_nonempty_targets() {
let mut settings = BTreeMap::new();
settings.insert(
API_KEY_TARGET_KEY.to_string(),
"https://api.sailresearch.com/".to_string(),
);
assert!(stored_key_matches_target(
&settings,
"https://api.sailresearch.com"
));
assert!(!stored_key_matches_target(
&settings,
"https://dev.sailresearch.com"
));
assert!(!stored_key_matches_target(
&BTreeMap::new(),
"https://api.sailresearch.com"
));
}
#[test]
fn target_match_falls_back_to_stored_mode_when_untagged() {
let mut settings = BTreeMap::new();
settings.insert("mode".to_string(), "dev".to_string());
assert!(stored_key_matches_target(
&settings,
"https://dev.sailresearch.com"
));
assert!(!stored_key_matches_target(
&settings,
"https://api.sailresearch.com"
));
}
}