openpencil/crates/op-git/src/auth.rs

131 lines
4.6 KiB
Rust

//! Git remote credential storage.
//!
//! The Rust counterpart of the TS Electron app's `auth-store.ts`:
//! a host-keyed table of the credentials used to authenticate
//! `git fetch` / `pull` / `push` against remotes.
//!
//! Credentials persist as a JSON file — `AuthStore::user()` puts it
//! under the per-user config directory; `AuthStore::at()` takes an
//! explicit path so the store is hermetically testable. On Unix the
//! file is created with owner-only (`0600`) permissions.
//!
//! NOTE: tokens are stored in plain text inside that `0600` file.
//! Binding the store to an OS keychain (macOS Keychain / libsecret /
//! Windows Credential Manager) is a deliberate later hardening step;
//! the file store is the portable foundation.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::GitError;
/// A credential for authenticating to a git remote.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Credential {
/// HTTPS — a username plus a personal-access-token / password.
Https {
/// Remote account user name.
username: String,
/// Personal access token or password.
token: String,
},
/// SSH — the name of an [`crate::SshKeyStore`] key to use.
Ssh {
/// Key name within the SSH key store.
key_name: String,
},
}
/// A host-keyed credential store, persisted as a JSON file.
#[derive(Debug, Clone)]
pub struct AuthStore {
path: PathBuf,
}
/// On-disk shape of the store.
#[derive(Debug, Default, Serialize, Deserialize)]
struct AuthFile {
/// Credentials keyed by host (or full remote URL — the caller
/// decides the key granularity).
#[serde(default)]
credentials: BTreeMap<String, Credential>,
}
impl AuthStore {
/// The per-user credential store
/// (`<config>/openpencil/git-auth.json`).
pub fn user() -> Result<AuthStore, GitError> {
let base =
dirs::config_dir().ok_or_else(|| GitError::Io("no user config directory".into()))?;
Ok(AuthStore {
path: base.join("openpencil").join("git-auth.json"),
})
}
/// A store backed by an explicit JSON file path.
pub fn at(path: impl Into<PathBuf>) -> AuthStore {
AuthStore { path: path.into() }
}
/// The store's backing file path.
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// The credential stored for `host`, if any.
pub fn get(&self, host: &str) -> Result<Option<Credential>, GitError> {
Ok(self.load()?.credentials.remove(host))
}
/// Store (or replace) the credential for `host`.
pub fn set(&self, host: &str, credential: Credential) -> Result<(), GitError> {
let mut file = self.load()?;
file.credentials.insert(host.to_string(), credential);
self.save(&file)
}
/// Remove `host`'s credential. Removing an absent host is a
/// tolerated no-op.
pub fn remove(&self, host: &str) -> Result<(), GitError> {
let mut file = self.load()?;
if file.credentials.remove(host).is_some() {
self.save(&file)?;
}
Ok(())
}
/// Every host with a stored credential, sorted.
pub fn hosts(&self) -> Result<Vec<String>, GitError> {
Ok(self.load()?.credentials.into_keys().collect())
}
/// Read the store file — a missing file is an empty store.
fn load(&self) -> Result<AuthFile, GitError> {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| GitError::Io(format!("parsing credential store: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthFile::default()),
Err(e) => Err(GitError::Io(e.to_string())),
}
}
/// Write the store file atomically: the secrets land in a temp
/// file that is created with owner-only (`0600`) permissions
/// *before* any content is written, then it is renamed into
/// place (rename preserves the mode).
fn save(&self, file: &AuthFile) -> Result<(), GitError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| GitError::Io(e.to_string()))?;
}
let json = serde_json::to_vec_pretty(file)
.map_err(|e| GitError::Io(format!("serializing credential store: {e}")))?;
let tmp = self.path.with_extension("json.tmp");
crate::write_private_file(&tmp, &json)?;
std::fs::rename(&tmp, &self.path).map_err(|e| GitError::Io(e.to_string()))?;
Ok(())
}
}