Files
codecraft/server/src/gitcreds.rs
T

212 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}