//! On-disk credentials store. //! //! Path is resolved via `directories::ProjectDirs` so the file lives in //! the platform-appropriate config dir (XDG on Linux, Library on macOS, //! AppData on Windows). On POSIX the file is forced to mode 0600 so the //! pasted bearer token isn't world-readable. use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Credentials { pub url: String, pub token: String, pub username: String, } /// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an /// override (used by tests to redirect to a tempdir) before falling /// back to the platform default. pub fn credentials_path() -> Result { if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") { return Ok(PathBuf::from(dir).join("credentials")); } let dirs = ProjectDirs::from("dev", "picloud", "picloud") .ok_or_else(|| anyhow!("could not determine config directory"))?; Ok(dirs.config_dir().join("credentials")) } pub fn load() -> Result { let path = credentials_path()?; let body = fs::read_to_string(&path).with_context(|| { format!( "no credentials at {}. run `pic login` first", path.display() ) })?; toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display())) } /// Resolution order used by every non-login command: /// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty), /// use them directly. Matches gcloud/aws/kubectl semantics — env /// wins so CI never accidentally reads a developer's stale file. /// 2. Otherwise fall back to the on-disk credentials file. /// /// Username is best-effort: env mode has no way to know the real one /// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami` /// output. Callers that need the canonical username re-fetch via /// `Client::auth_me`. pub fn resolve() -> Result { if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) { if !url.is_empty() && !token.is_empty() { return Ok(Credentials { url, token, username: "-".to_string(), }); } } load() } /// Delete the on-disk credentials file. Idempotent — silently succeeds /// if the file is already gone (the user already logged out, or never /// logged in to begin with). pub fn delete() -> Result<()> { let path = credentials_path()?; match fs::remove_file(&path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err).with_context(|| format!("removing {}", path.display())), } } pub fn save(creds: &Credentials) -> Result<()> { let path = credentials_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } let body = toml::to_string(creds).context("serializing credentials")?; write_private(&path, body.as_bytes())?; Ok(()) } #[cfg(unix)] fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { use std::os::unix::fs::OpenOptionsExt; let mut f = fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path) .with_context(|| format!("opening {}", path.display()))?; f.write_all(bytes) .with_context(|| format!("writing {}", path.display()))?; // Belt-and-suspenders: re-set perms in case the file already existed // with a wider mode (mode() on create doesn't downgrade existing). let mut perms = fs::metadata(path)?.permissions(); use std::os::unix::fs::PermissionsExt; perms.set_mode(0o600); fs::set_permissions(path, perms)?; Ok(()) } #[cfg(not(unix))] fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?; Ok(()) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn roundtrip_toml() { let creds = Credentials { url: "http://localhost:8000".to_string(), token: "pic_abc".to_string(), username: "admin".to_string(), }; let serialized = toml::to_string(&creds).unwrap(); let parsed: Credentials = toml::from_str(&serialized).unwrap(); assert_eq!(creds, parsed); } #[cfg(unix)] #[test] fn posix_mode_is_0600() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); std::env::set_var("PICLOUD_CONFIG_DIR", dir.path()); let creds = Credentials { url: "http://localhost:8000".to_string(), token: "pic_secret".to_string(), username: "admin".to_string(), }; save(&creds).unwrap(); let path = credentials_path().unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "credentials must be readable only by owner"); std::env::remove_var("PICLOUD_CONFIG_DIR"); } }