//! Per-user git credentials and commit identity. //! //! Stored in the database and rendered into the user's home directory //! (`/data/users//home`), which is the home of their sandboxed agents, //! terminals and git client: `.git-credentials` (credential store) and //! `.gitconfig-codecraft`, included from `.gitconfig` so the user's own git //! configuration stays untouched. use crate::app::App; use crate::store::User; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GitCredential { /// Host, optionally with a path prefix (e.g. "git.example.com" or "github.com/org"). pub host: String, pub username: String, /// Password or API token. Empty in updates = keep the stored one. #[serde(default)] pub token: String, /// "https" (default) or "http". #[serde(default)] pub protocol: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GitSettings { #[serde(default)] pub name: String, #[serde(default)] pub email: String, #[serde(default)] pub credentials: Vec, } /// The user's commit identity as environment variables. They take precedence over a /// repository's own `user.name`/`user.email`, so commits made by agents, terminals and /// the git panel always carry the identity from the settings. pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> { let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default(); let mut env = Vec::new(); let (name, email) = (s.name.trim(), s.email.trim()); if !name.is_empty() { env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string())); env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string())); } if !email.is_empty() { env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string())); env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string())); } env } pub fn home_of(app: &App, user: &User) -> PathBuf { app.data_dir().join("users").join(&user.username).join("home") } /// Global git config for processes that run outside the sandbox (admins). pub fn global_config(app: &App, user: &User) -> Option { let p = home_of(app, user).join(".gitconfig"); p.exists().then_some(p) } fn enc(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } fn quote(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } /// Write the credential store and config into the user's home. pub fn write_files(app: &App, user: &User, settings: &GitSettings) -> Result<()> { let home = home_of(app, user); std::fs::create_dir_all(&home)?; let creds_path = home.join(".git-credentials"); let mut creds = String::new(); for c in &settings.credentials { if c.host.trim().is_empty() || c.token.is_empty() { continue; } let proto = if c.protocol == "http" { "http" } else { "https" }; let (host, path) = match c.host.trim().trim_start_matches("https://").trim_start_matches("http://").split_once('/') { Some((h, p)) => (h.to_string(), format!("/{}", p.trim_matches('/'))), None => (c.host.trim().trim_start_matches("https://").trim_start_matches("http://").to_string(), String::new()), }; creds.push_str(&format!("{proto}://{}:{}@{host}{path}\n", enc(c.username.trim()), enc(&c.token))); } std::fs::write(&creds_path, creds)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&creds_path, std::fs::Permissions::from_mode(0o600))?; } // Managed part of the config. Two helpers: the absolute path works outside // the sandbox, the default (~/.git-credentials) inside it. let mut cfg = String::from("# Managed by CodeCraft (Settings → Account → Git). Do not edit.\n"); if !settings.name.trim().is_empty() || !settings.email.trim().is_empty() { cfg.push_str("[user]\n"); if !settings.name.trim().is_empty() { cfg.push_str(&format!("\tname = {}\n", quote(settings.name.trim()))); } if !settings.email.trim().is_empty() { cfg.push_str(&format!("\temail = {}\n", quote(settings.email.trim()))); } } cfg.push_str(&format!("[credential]\n\thelper = {}\n\thelper = store\n", quote(&format!("store --file={}", creds_path.display())))); // Matching credentials by path needs useHttpPath for entries with a path. if settings.credentials.iter().any(|c| c.host.trim().trim_start_matches("https://").contains('/')) { cfg.push_str("\tuseHttpPath = true\n"); } std::fs::write(home.join(".gitconfig-codecraft"), cfg)?; let main = home.join(".gitconfig"); let current = std::fs::read_to_string(&main).unwrap_or_default(); if !current.contains(".gitconfig-codecraft") { let mut next = current; if !next.is_empty() && !next.ends_with('\n') { next.push('\n'); } next.push_str("[include]\n\tpath = .gitconfig-codecraft\n"); std::fs::write(&main, next)?; } Ok(()) } /// Settings with tokens masked for the UI. pub fn masked(settings: &GitSettings) -> GitSettings { let mut s = settings.clone(); for c in &mut s.credentials { c.token = if c.token.is_empty() { String::new() } else { "••••••••".into() }; } s } /// Merge an update from the UI: empty or masked tokens keep the stored token. pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings { for c in &mut new.credentials { if c.token.is_empty() || c.token.chars().all(|ch| ch == '•') { if let Some(prev) = old.credentials.iter().find(|p| p.host == c.host && p.username == c.username) { c.token = prev.token.clone(); } } } new } #[cfg(test)] mod tests { use super::*; #[test] fn keeps_masked_tokens() { let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() }; let upd = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "••••••••".into(), protocol: String::new() }], ..Default::default() }; assert_eq!(merge(&old, upd).credentials[0].token, "secret"); assert_eq!(enc("a@b:c/d"), "a%40b%3Ac%2Fd"); } }