add login with oskeychain

This commit is contained in:
Christoph Brandau
2026-06-29 19:13:41 +02:00
parent 8d56c3f39f
commit 4aa9a537f0
11 changed files with 1234 additions and 40 deletions
+159 -1
View File
@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
@@ -365,6 +365,142 @@ pub fn push(
status_for_repo(&repo)
}
// ── Credential storage (OS keychain) ────────────────────────────────────────
const CRED_SERVICE: &str = "tauri_git_lite";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredCredential {
pub username: String,
pub password: String,
#[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
let key = key.trim();
if key.is_empty() {
return Err("Kein Schlüssel für die Zugangsdaten angegeben.".to_string());
}
keyring::Entry::new(CRED_SERVICE, key)
.map_err(|err| format!("Schlüsselbund nicht verfügbar: {err}"))
}
/// Returns the remote URL used for auth key derivation (upstream remote of the
/// current branch, falling back to `origin`, then the first configured remote).
#[tauri::command]
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
if let Some(url) = remote_url_for(&repo, &remote) {
return Ok(Some(url));
}
// origin missing → try the first configured remote
if let Some(first) = first_remote_name(&repo) {
if first != remote {
if let Some(url) = remote_url_for(&repo, &first) {
return Ok(Some(url));
}
}
}
Ok(None)
}
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["remote", "get-url", remote])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() {
None
} else {
Some(url)
}
}
fn upstream_remote_name(repo: &Path) -> Option<String> {
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let branch = String::from_utf8_lossy(&branch).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return None;
}
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["config", &format!("branch.{branch}.remote")])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
fn first_remote_name(repo: &Path) -> Option<String> {
let out = run_git(repo, ["remote"]).ok()?;
String::from_utf8_lossy(&out)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
}
#[tauri::command]
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(&key)?;
match entry.get_password() {
Ok(json) => {
let cred = serde_json::from_str::<StoredCredential>(&json)
.map_err(|err| format!("Gespeicherte Zugangsdaten unlesbar: {err}"))?;
Ok(Some(cred))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Schlüsselbund-Zugriff fehlgeschlagen: {err}")),
}
}
#[tauri::command]
pub fn cred_save(
key: String,
username: String,
password: String,
expires_at: Option<String>,
) -> Result<(), String> {
let entry = cred_entry(&key)?;
let expires_at = expires_at.filter(|value| !value.trim().is_empty());
let cred = StoredCredential {
username,
password,
expires_at,
};
let json = serde_json::to_string(&cred)
.map_err(|err| format!("Zugangsdaten konnten nicht serialisiert werden: {err}"))?;
entry
.set_password(&json)
.map_err(|err| format!("Speichern im Schlüsselbund fehlgeschlagen: {err}"))
}
#[tauri::command]
pub fn cred_delete(key: String) -> Result<(), String> {
let entry = cred_entry(&key)?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(format!("Löschen im Schlüsselbund fehlgeschlagen: {err}")),
}
}
#[tauri::command]
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -1824,9 +1960,31 @@ where
} else {
"Unbekannter Fehler".to_string()
};
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
}
/// Heuristic: did git fail because the credentials were rejected/expired,
/// as opposed to a network or merge error? Used by the frontend to drop the
/// stored credential and re-prompt the login.
fn is_auth_error(details: &str) -> bool {
let d = details.to_lowercase();
d.contains("authentication failed")
|| d.contains("could not read username")
|| d.contains("could not read password")
|| d.contains("invalid username or password")
|| d.contains("terminal prompts disabled")
|| d.contains("permission denied")
|| d.contains("access denied")
|| d.contains("403 forbidden")
|| d.contains(" 403")
|| d.contains(" 401")
|| d.contains("authorization failed")
|| d.contains("authentication required")
}
fn run_git_with_paths(
repo: &Path,
base_args: &[&str],