Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
212 lines
9.3 KiB
Rust
212 lines
9.3 KiB
Rust
//! Per-user git credentials and commit identity.
|
||
//!
|
||
//! Stored in the database and rendered into the user's home directory
|
||
//! (`/data/users/<name>/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<GitCredential>,
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// What an agent needs to know about the stored git access (no secrets): git already has the
|
||
/// credentials, so it must not look for them. None when nothing is stored.
|
||
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
|
||
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
|
||
}
|
||
|
||
fn render_prompt(s: &GitSettings) -> Option<String> {
|
||
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
|
||
if creds.is_empty() {
|
||
return None;
|
||
}
|
||
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
|
||
for c in &creds {
|
||
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
|
||
}
|
||
t.push_str(
|
||
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
|
||
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
|
||
Use https:// remote URLs (SSH keys are not set up). \
|
||
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
|
||
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
|
||
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
|
||
);
|
||
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
|
||
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
|
||
}
|
||
Some(t)
|
||
}
|
||
|
||
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<PathBuf> {
|
||
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 prompt_names_hosts_but_never_tokens() {
|
||
assert!(render_prompt(&GitSettings::default()).is_none());
|
||
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
|
||
let p = render_prompt(&settings).unwrap();
|
||
println!("{p}");
|
||
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
|
||
assert!(!p.contains("s3cret-token"));
|
||
// Entries without a token are not offered.
|
||
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
|
||
assert!(render_prompt(&none).is_none());
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|