use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SshKeyPair {
pub private_key_path: PathBuf,
pub public_key_path: PathBuf,
pub fingerprint: String,
pub public_key: String,
}
#[derive(Debug)]
pub enum SignatureVerification {
Valid {
commit: String,
fingerprint: Option<String>,
principal: Option<String>,
},
Unsigned { commit: String },
Invalid { commit: String, reason: String },
NoCommits,
}
pub fn generate_agent_key(keys_dir: &Path, agent_id: &str, machine_id: &str) -> Result<SshKeyPair> {
std::fs::create_dir_all(keys_dir)?;
let private_path = keys_dir.join(format!("{agent_id}_ed25519"));
let public_path = keys_dir.join(format!("{agent_id}_ed25519.pub"));
if private_path.exists() {
bail!(
"SSH key already exists at {}. Use `crosslink agent rotate-key` to regenerate.",
private_path.display()
);
}
let comment = format!("crosslink-agent:{agent_id}@{machine_id}");
let output = Command::new("ssh-keygen")
.args([
"-t",
"ed25519",
"-f",
&private_path.to_string_lossy(),
"-N",
"", "-C",
&comment,
])
.output()
.context("Failed to run ssh-keygen. Is OpenSSH installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("ssh-keygen failed: {}", stderr.trim());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(keys_dir, std::fs::Permissions::from_mode(0o700))
.context("Failed to set permissions on keys directory")?;
std::fs::set_permissions(&private_path, std::fs::Permissions::from_mode(0o600))
.context("Failed to set permissions on private key")?;
}
#[cfg(windows)]
{
let username = std::env::var("USERNAME").unwrap_or_default();
if !username.is_empty() {
let dir_result = Command::new("icacls")
.arg(&keys_dir.to_string_lossy().as_ref())
.args(["/inheritance:r", "/grant:r"])
.arg(format!("{}:(OI)(CI)(F)", username))
.output();
match dir_result {
Ok(output) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"warning: icacls failed to set permissions on keys directory: {}",
stderr.trim()
);
}
Err(e) => {
tracing::warn!("failed to run icacls on keys directory: {}", e);
}
_ => {}
}
let key_result = Command::new("icacls")
.arg(&private_path.to_string_lossy().as_ref())
.args(["/inheritance:r", "/grant:r"])
.arg(format!("{}:(R)", username))
.output();
match key_result {
Ok(output) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"warning: icacls failed to set permissions on private key: {}",
stderr.trim()
);
}
Err(e) => {
tracing::warn!("failed to run icacls on private key: {}", e);
}
_ => {}
}
} else {
tracing::warn!("USERNAME not set, skipping Windows ACL permissions for SSH keys");
}
}
let public_key = std::fs::read_to_string(&public_path)
.context("Failed to read generated public key")?
.trim()
.to_string();
let fingerprint = get_key_fingerprint(&public_path)?;
Ok(SshKeyPair {
private_key_path: private_path,
public_key_path: public_path,
fingerprint,
public_key,
})
}
pub fn get_key_fingerprint(public_key_path: &Path) -> Result<String> {
let output = Command::new("ssh-keygen")
.args(["-l", "-f", &public_key_path.to_string_lossy()])
.output()
.context("Failed to run ssh-keygen -l")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("ssh-keygen -l failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.split_whitespace().collect();
if parts.len() >= 2 {
Ok(parts[1].to_string())
} else {
bail!("Unexpected ssh-keygen -l output: {}", stdout.trim());
}
}
const SSH_KEY_NAMES: &[&str] = &["id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"];
pub fn find_default_ssh_key() -> Option<PathBuf> {
let home = dirs_next().or_else(home_dir_fallback)?;
let ssh_dir = home.join(".ssh");
for name in SSH_KEY_NAMES {
let path = ssh_dir.join(name);
if path.exists() {
return Some(path);
}
}
None
}
#[must_use]
pub fn find_git_signing_key() -> Option<PathBuf> {
let output = Command::new("git")
.args(["config", "--global", "user.signingkey"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let key_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if key_path.is_empty() {
return None;
}
let path = PathBuf::from(&key_path);
if path.exists() {
return Some(path);
}
let pub_path = PathBuf::from(format!("{key_path}.pub"));
if pub_path.exists() {
return Some(pub_path);
}
None
}
pub fn read_public_key(path: &Path) -> Result<String> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read public key at {}", path.display()))?;
let line = content.trim().to_string();
if !line.starts_with("ssh-") && !line.starts_with("ecdsa-") {
bail!(
"File does not look like an SSH public key: {}",
path.display()
);
}
Ok(line)
}
#[must_use]
pub fn is_linked_worktree(repo_dir: &Path) -> bool {
let git_dir = Command::new("git")
.current_dir(repo_dir)
.args(["rev-parse", "--git-dir"])
.output();
let common_dir = Command::new("git")
.current_dir(repo_dir)
.args(["rev-parse", "--git-common-dir"])
.output();
let (Ok(gd), Ok(cd)) = (git_dir, common_dir) else {
return false;
};
if !gd.status.success() || !cd.status.success() {
return false;
}
let gd_raw = String::from_utf8_lossy(&gd.stdout).trim().to_string();
let cd_raw = String::from_utf8_lossy(&cd.stdout).trim().to_string();
let gd_path = if Path::new(&gd_raw).is_absolute() {
PathBuf::from(&gd_raw)
} else {
repo_dir.join(&gd_raw)
};
let cd_path = if Path::new(&cd_raw).is_absolute() {
PathBuf::from(&cd_raw)
} else {
repo_dir.join(&cd_raw)
};
let gd_canonical = gd_path.canonicalize().unwrap_or(gd_path);
let cd_canonical = cd_path.canonicalize().unwrap_or(cd_path);
gd_canonical != cd_canonical
}
pub fn enable_worktree_config(repo_dir: &Path) -> Result<()> {
let output = Command::new("git")
.current_dir(repo_dir)
.args(["config", "extensions.worktreeConfig", "true"])
.output()
.context("Failed to enable extensions.worktreeConfig")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"Failed to enable extensions.worktreeConfig: {}",
stderr.trim()
);
}
Ok(())
}
fn cleanup_leaked_signing_config(repo_dir: &Path) {
let output = Command::new("git")
.current_dir(repo_dir)
.args(["config", "--local", "user.signingkey"])
.output();
let Ok(output) = output else {
return;
};
if !output.status.success() {
return;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !value.contains(".crosslink/keys/") && !value.contains(".crosslink\\keys\\") {
return;
}
for key in &[
"user.signingkey",
"gpg.format",
"commit.gpgsign",
"gpg.ssh.allowedSignersFile",
] {
let _ = Command::new("git")
.current_dir(repo_dir)
.args(["config", "--local", "--unset", key])
.output();
}
}
pub fn configure_git_ssh_signing(
repo_dir: &Path,
private_key_path: &Path,
allowed_signers_path: Option<&Path>,
) -> Result<()> {
let use_worktree = is_linked_worktree(repo_dir);
if use_worktree {
enable_worktree_config(repo_dir)?;
cleanup_leaked_signing_config(repo_dir);
}
run_git_config(repo_dir, "gpg.format", "ssh", use_worktree)?;
run_git_config(
repo_dir,
"user.signingkey",
&private_key_path.to_string_lossy(),
use_worktree,
)?;
run_git_config(repo_dir, "commit.gpgsign", "true", use_worktree)?;
if let Some(signers) = allowed_signers_path {
run_git_config(
repo_dir,
"gpg.ssh.allowedSignersFile",
&signers.to_string_lossy(),
use_worktree,
)?;
}
Ok(())
}
fn run_git_config(repo_dir: &Path, key: &str, value: &str, worktree_scope: bool) -> Result<()> {
let scope_flag = if worktree_scope {
"--worktree"
} else {
"--local"
};
let output = Command::new("git")
.current_dir(repo_dir)
.args(["config", scope_flag, key, value])
.output()
.with_context(|| format!("Failed to set git config {key}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git config {} failed: {}", key, stderr.trim());
}
Ok(())
}
pub fn disable_git_signing(repo_dir: &Path) -> Result<()> {
let use_worktree = is_linked_worktree(repo_dir);
if use_worktree {
enable_worktree_config(repo_dir)?;
}
let scope_flag = if use_worktree {
"--worktree"
} else {
"--local"
};
for key in &[
"user.signingkey",
"gpg.format",
"commit.gpgsign",
"gpg.ssh.allowedSignersFile",
] {
let _ = Command::new("git")
.current_dir(repo_dir)
.args(["config", scope_flag, "--unset", key])
.output();
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AllowedSignerEntry {
pub principal: String,
pub public_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata_comment: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AllowedSigners {
pub entries: Vec<AllowedSignerEntry>,
}
impl AllowedSigners {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
Ok(Self::parse(&content))
}
const KNOWN_KEY_TYPES: &'static [&'static str] = &[
"ssh-ed25519",
"ssh-rsa",
"ssh-dss",
"ecdsa-sha2-",
"sk-ssh-ed25519",
"sk-ecdsa-sha2-",
];
fn parse(content: &str) -> Self {
let mut entries = Vec::new();
let mut pending_metadata: Option<String> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
pending_metadata = None;
continue;
}
if trimmed.starts_with('#') {
let comment_text = trimmed.trim_start_matches('#').trim();
if comment_text.starts_with("approved ") || comment_text.starts_with("revoked ") {
pending_metadata = Some(comment_text.to_string());
}
continue;
}
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
if parts.len() < 2 {
eprintln!("warning: skipping malformed allowed_signers line (no space): {line}");
pending_metadata = None;
continue;
}
let principal = parts[0];
let public_key = parts[1];
if principal.is_empty() || principal.chars().any(char::is_control) {
eprintln!(
"warning: skipping allowed_signers entry with invalid principal: {principal}"
);
pending_metadata = None;
continue;
}
if !Self::KNOWN_KEY_TYPES
.iter()
.any(|prefix| public_key.starts_with(prefix))
{
eprintln!(
"warning: skipping allowed_signers entry with unrecognized key type for principal '{}': {}",
principal,
public_key.split_whitespace().next().unwrap_or("<empty>")
);
pending_metadata = None;
continue;
}
entries.push(AllowedSignerEntry {
principal: principal.to_string(),
public_key: public_key.to_string(),
metadata_comment: pending_metadata.take(),
});
}
Self { entries }
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = self.render();
std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
}
fn render(&self) -> String {
let mut lines = vec!["# Crosslink trusted signers".to_string()];
lines.push("# Format: <principal> <key-type> <base64-key> [comment]".to_string());
for entry in &self.entries {
if let Some(ref comment) = entry.metadata_comment {
lines.push(format!("# {comment}"));
}
lines.push(format!("{} {}", entry.principal, entry.public_key));
}
lines.push(String::new()); lines.join("\n")
}
pub fn add_entry(&mut self, entry: AllowedSignerEntry) -> bool {
if self.entries.iter().any(|e| e.principal == entry.principal) {
return false;
}
self.entries.push(entry);
true
}
pub fn remove_by_principal(&mut self, principal: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|e| e.principal != principal);
self.entries.len() < before
}
#[must_use]
pub fn is_trusted(&self, principal: &str) -> bool {
self.entries.iter().any(|e| e.principal == principal)
}
}
#[must_use]
pub fn parse_ssh_verify_output(output: &str) -> Option<(String, String)> {
for line in output.lines() {
if line.contains("Good") && line.contains("signature for") {
if let Some(for_idx) = line.find("signature for ") {
let after_for = &line[for_idx + "signature for ".len()..];
if let Some(with_idx) = after_for.find(" with ") {
let principal = after_for[..with_idx].to_string();
if let Some(key_idx) = after_for.find("key ") {
let fingerprint = after_for[key_idx + "key ".len()..].trim().to_string();
return Some((principal, fingerprint));
}
}
}
}
}
None
}
#[must_use]
pub fn parse_gpg_fingerprint(gpg_output: &str) -> Option<String> {
for line in gpg_output.lines() {
if line.contains("VALIDSIG") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
return Some(parts[2].to_string());
}
}
}
None
}
#[must_use]
pub fn parse_verify_output(stderr: &str) -> Option<(Option<String>, String)> {
if let Some((principal, fingerprint)) = parse_ssh_verify_output(stderr) {
return Some((Some(principal), fingerprint));
}
if let Some(fp) = parse_gpg_fingerprint(stderr) {
return Some((None, fp));
}
None
}
#[must_use]
pub fn canonicalize_for_signing(fields: &[(&str, &str)]) -> Vec<u8> {
let mut sorted: Vec<(&str, &str)> = fields.to_vec();
sorted.sort_by_key(|(k, _)| *k);
let mut out = Vec::new();
for (k, v) in sorted {
out.extend_from_slice(k.as_bytes());
out.push(b'=');
out.extend_from_slice(v.as_bytes());
out.push(b'\n');
}
out
}
pub fn sign_content(private_key_path: &Path, content: &[u8], namespace: &str) -> Result<String> {
let tmp = TempDirGuard::new("crosslink-sign")?;
let content_path = tmp.path().join("content");
let sig_path = tmp.path().join("content.sig");
std::fs::write(&content_path, content)?;
let output = Command::new("ssh-keygen")
.args([
"-Y",
"sign",
"-f",
&private_key_path.to_string_lossy(),
"-n",
namespace,
])
.arg(&content_path)
.output()
.context("Failed to run ssh-keygen -Y sign")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("ssh-keygen sign failed: {}", stderr.trim());
}
let sig_content =
std::fs::read_to_string(&sig_path).context("Failed to read signature file")?;
let sig = sig_content
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
Ok(sig)
}
pub fn verify_content(
allowed_signers_path: &Path,
principal: &str,
namespace: &str,
content: &[u8],
signature_b64: &str,
) -> Result<bool> {
let tmp = TempDirGuard::new("crosslink-verify")?;
let content_path = tmp.path().join("content");
let sig_path = tmp.path().join("content.sig");
std::fs::write(&content_path, content)?;
let pem_sig =
format!("-----BEGIN SSH SIGNATURE-----\n{signature_b64}\n-----END SSH SIGNATURE-----\n");
std::fs::write(&sig_path, pem_sig)?;
let mut child = Command::new("ssh-keygen")
.args([
"-Y",
"verify",
"-f",
&allowed_signers_path.to_string_lossy(),
"-I",
principal,
"-n",
namespace,
"-s",
])
.arg(&sig_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to run ssh-keygen -Y verify")?;
if let Some(ref mut stdin) = child.stdin {
use std::io::Write;
let _ = stdin.write_all(content);
}
drop(child.stdin.take());
{
use std::time::{Duration, Instant};
let start = Instant::now();
let timeout = Duration::from_secs(30);
loop {
if child.try_wait()?.is_some() {
break;
}
if start.elapsed() > timeout {
let _ = child.kill();
bail!("ssh-keygen verification timed out after 30 seconds");
}
std::thread::sleep(Duration::from_millis(50));
}
}
let output = child.wait_with_output()?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.contains("Good") && !stderr.contains("Good") {
return Ok(false);
}
Ok(true)
}
struct TempDirGuard(PathBuf);
impl TempDirGuard {
fn new(prefix: &str) -> Result<Self> {
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("{prefix}-{id}-{ts}"));
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create temp dir {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(&dir, perms).ok();
}
Ok(Self(dir))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_dir_all(&self.0) {
tracing::warn!("failed to clean up temp dir {}: {}", self.0.display(), e);
}
}
}
fn dirs_next() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("USERPROFILE").ok().map(PathBuf::from)
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("HOME").ok().map(PathBuf::from)
}
}
fn home_dir_fallback() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(PathBuf::from)
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(PathBuf::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_parse_ssh_verify_output_valid() {
let output =
r#"Good "git" signature for m1@crosslink with ED25519 key SHA256:AbCdEf123456"#;
let result = parse_ssh_verify_output(output);
assert_eq!(
result,
Some((
"m1@crosslink".to_string(),
"SHA256:AbCdEf123456".to_string()
))
);
}
#[test]
fn test_parse_ssh_verify_output_multiline() {
let output = "some preamble\nGood \"git\" signature for driver@example.com with ECDSA key SHA256:XyZ789\nmore stuff";
let result = parse_ssh_verify_output(output);
assert_eq!(
result,
Some((
"driver@example.com".to_string(),
"SHA256:XyZ789".to_string()
))
);
}
#[test]
fn test_parse_ssh_verify_output_no_match() {
assert!(parse_ssh_verify_output("").is_none());
assert!(parse_ssh_verify_output("Bad signature").is_none());
assert!(parse_ssh_verify_output("Good but no signature for").is_none());
}
#[test]
fn test_parse_gpg_fingerprint_valid() {
let output = "[GNUPG:] VALIDSIG ABCDEF1234567890 2024-01-01 12345678\n[GNUPG:] GOODSIG";
let fp = parse_gpg_fingerprint(output);
assert_eq!(fp, Some("ABCDEF1234567890".to_string()));
}
#[test]
fn test_parse_gpg_fingerprint_no_match() {
assert!(parse_gpg_fingerprint("").is_none());
assert!(parse_gpg_fingerprint("[GNUPG:] GOODSIG ABC123").is_none());
}
#[test]
fn test_parse_verify_output_ssh_preferred() {
let output = r#"Good "git" signature for agent@host with ED25519 key SHA256:Test123"#;
let result = parse_verify_output(output);
assert_eq!(
result,
Some((Some("agent@host".to_string()), "SHA256:Test123".to_string()))
);
}
#[test]
fn test_parse_verify_output_gpg_fallback() {
let output = "[GNUPG:] VALIDSIG DEADBEEF 2024-01-01";
let result = parse_verify_output(output);
assert_eq!(result, Some((None, "DEADBEEF".to_string())));
}
#[test]
fn test_allowed_signers_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "driver@example.com".to_string(),
public_key: "ssh-ed25519 AAAA1234 driver-key".to_string(),
metadata_comment: None,
});
signers.add_entry(AllowedSignerEntry {
principal: "m1@crosslink".to_string(),
public_key: "ssh-ed25519 BBBB5678 agent-m1".to_string(),
metadata_comment: None,
});
signers.save(&path).unwrap();
let loaded = AllowedSigners::load(&path).unwrap();
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].principal, "driver@example.com");
assert_eq!(
loaded.entries[0].public_key,
"ssh-ed25519 AAAA1234 driver-key"
);
assert_eq!(loaded.entries[1].principal, "m1@crosslink");
}
#[test]
fn test_allowed_signers_add_duplicate() {
let mut signers = AllowedSigners::default();
assert!(signers.add_entry(AllowedSignerEntry {
principal: "m1@crosslink".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: None,
}));
assert!(!signers.add_entry(AllowedSignerEntry {
principal: "m1@crosslink".to_string(),
public_key: "ssh-ed25519 BBBB".to_string(),
metadata_comment: None,
}));
assert_eq!(signers.entries.len(), 1);
}
#[test]
fn test_allowed_signers_remove() {
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "m1@crosslink".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: None,
});
assert!(signers.remove_by_principal("m1@crosslink"));
assert!(!signers.remove_by_principal("m1@crosslink"));
assert!(signers.entries.is_empty());
}
#[test]
fn test_allowed_signers_is_trusted() {
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "m1@crosslink".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: None,
});
assert!(signers.is_trusted("m1@crosslink"));
assert!(!signers.is_trusted("unknown@crosslink"));
}
#[test]
fn test_allowed_signers_parse_comments_and_blanks() {
let content = "# comment line\n\ndriver@example.com ssh-ed25519 AAAA key\n# another comment\nm1@crosslink ssh-ed25519 BBBB key2\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 2);
}
#[test]
fn test_allowed_signers_metadata_comment_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "m1@crosslink".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: Some("approved by max at 2026-02-28 12:00:00 UTC".to_string()),
});
signers.save(&path).unwrap();
let loaded = AllowedSigners::load(&path).unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(
loaded.entries[0].metadata_comment.as_deref(),
Some("approved by max at 2026-02-28 12:00:00 UTC")
);
}
#[test]
fn test_allowed_signers_rejects_invalid_key_type() {
let content = "agent@crosslink not-an-ssh-key AAAA\n";
let signers = AllowedSigners::parse(content);
assert!(signers.entries.is_empty());
}
#[test]
fn test_allowed_signers_rejects_control_chars_in_principal() {
let content = "agent\x00bad@crosslink ssh-ed25519 AAAA\n";
let signers = AllowedSigners::parse(content);
assert!(signers.entries.is_empty());
}
#[test]
fn test_allowed_signers_accepts_valid_key_types() {
let content = "a@crosslink ssh-ed25519 AAAA\nb@crosslink ssh-rsa BBBB\nc@crosslink ecdsa-sha2-nistp256 CCCC\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 3);
}
#[test]
fn test_allowed_signers_load_missing_file() {
let dir = tempdir().unwrap();
let signers = AllowedSigners::load(&dir.path().join("nonexistent")).unwrap();
assert!(signers.entries.is_empty());
}
#[test]
fn test_read_public_key_rejects_non_ssh() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.pub");
std::fs::write(&path, "not an ssh key").unwrap();
assert!(read_public_key(&path).is_err());
}
#[test]
fn test_generate_agent_key() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "test-agent", "test-host").unwrap();
assert!(keypair.private_key_path.exists());
assert!(keypair.public_key_path.exists());
assert!(keypair.fingerprint.starts_with("SHA256:"));
assert!(keypair.public_key.starts_with("ssh-ed25519"));
assert!(keypair
.public_key
.contains("crosslink-agent:test-agent@test-host"));
}
#[test]
fn test_generate_agent_key_rejects_existing() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
generate_agent_key(&keys_dir, "test-agent", "host").unwrap();
let result = generate_agent_key(&keys_dir, "test-agent", "host");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn test_get_key_fingerprint() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "fp-test", "host").unwrap();
let fp = get_key_fingerprint(&keypair.public_key_path).unwrap();
assert!(fp.starts_with("SHA256:"));
assert_eq!(fp, keypair.fingerprint);
}
#[test]
fn test_configure_git_ssh_signing() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
let key_path = repo.join("fake-key");
std::fs::write(&key_path, "fake").unwrap();
configure_git_ssh_signing(repo, &key_path, None).unwrap();
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "gpg.format"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ssh");
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "commit.gpgsign"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "true");
}
fn init_git_repo_with_commit(path: &Path) {
Command::new("git")
.current_dir(path)
.args(["init", "-q"])
.output()
.unwrap();
Command::new("git")
.current_dir(path)
.args(["config", "user.email", "test@test.com"])
.output()
.unwrap();
Command::new("git")
.current_dir(path)
.args(["config", "user.name", "Test"])
.output()
.unwrap();
Command::new("git")
.current_dir(path)
.args(["commit", "--allow-empty", "-m", "init"])
.output()
.unwrap();
}
#[test]
fn test_configure_git_ssh_signing_in_linked_worktree() {
let dir = tempdir().unwrap();
let main_root = dir.path().join("main");
std::fs::create_dir_all(&main_root).unwrap();
init_git_repo_with_commit(&main_root);
Command::new("git")
.current_dir(&main_root)
.args([
"config",
"--local",
"user.signingkey",
"~/.ssh/id_ecdsa_signing",
])
.output()
.unwrap();
Command::new("git")
.current_dir(&main_root)
.args(["branch", "wt-test"])
.output()
.unwrap();
let wt_path = dir.path().join("worktree");
Command::new("git")
.current_dir(&main_root)
.args(["worktree", "add", &wt_path.to_string_lossy(), "wt-test"])
.output()
.unwrap();
let agent_key = wt_path.join(".crosslink/keys/agent_ed25519");
std::fs::create_dir_all(agent_key.parent().unwrap()).unwrap();
std::fs::write(&agent_key, "fake-agent-key").unwrap();
configure_git_ssh_signing(&wt_path, &agent_key, None).unwrap();
let output = Command::new("git")
.current_dir(&wt_path)
.args(["config", "--worktree", "user.signingkey"])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&output.stdout)
.trim()
.contains(".crosslink/keys/"),
"agent key should be in worktree config"
);
let output = Command::new("git")
.current_dir(&main_root)
.args(["config", "--local", "user.signingkey"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"~/.ssh/id_ecdsa_signing",
"user's signing key must not be overwritten in shared config"
);
let output = Command::new("git")
.current_dir(&main_root)
.args(["config", "extensions.worktreeConfig"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"true",
"extensions.worktreeConfig should be enabled"
);
}
#[test]
fn test_configure_git_ssh_signing_standalone_still_uses_local() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
let key_path = repo.join("fake-key");
std::fs::write(&key_path, "fake").unwrap();
configure_git_ssh_signing(repo, &key_path, None).unwrap();
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "user.signingkey"])
.output()
.unwrap();
assert!(
output.status.success(),
"signing key should be in local config for standalone repos"
);
let output = Command::new("git")
.current_dir(repo)
.args(["config", "extensions.worktreeConfig"])
.output()
.unwrap();
assert!(
!output.status.success(),
"extensions.worktreeConfig should not be set for standalone repos"
);
}
#[test]
fn test_read_public_key_valid_ssh_ed25519() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pub");
std::fs::write(&path, "ssh-ed25519 AAAA1234 testkey").unwrap();
let result = read_public_key(&path).unwrap();
assert_eq!(result, "ssh-ed25519 AAAA1234 testkey");
}
#[test]
fn test_read_public_key_valid_ecdsa() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pub");
std::fs::write(&path, "ecdsa-sha2-nistp256 BBBB5678 testkey").unwrap();
let result = read_public_key(&path).unwrap();
assert_eq!(result, "ecdsa-sha2-nistp256 BBBB5678 testkey");
}
#[test]
fn test_read_public_key_trims_whitespace() {
let dir = tempdir().unwrap();
let path = dir.path().join("key.pub");
std::fs::write(&path, " ssh-ed25519 AAAA1234 testkey \n").unwrap();
let result = read_public_key(&path).unwrap();
assert_eq!(result, "ssh-ed25519 AAAA1234 testkey");
}
#[test]
fn test_read_public_key_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.pub");
assert!(read_public_key(&path).is_err());
}
#[test]
fn test_read_public_key_invalid_prefix() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.pub");
std::fs::write(&path, "rsa-key AAAA1234").unwrap();
let err = read_public_key(&path).unwrap_err();
assert!(err
.to_string()
.contains("does not look like an SSH public key"));
}
#[test]
fn test_canonicalize_for_signing_sorts_keys() {
let fields = vec![("z", "last"), ("a", "first"), ("m", "middle")];
let result = canonicalize_for_signing(&fields);
assert_eq!(result, b"a=first\nm=middle\nz=last\n");
}
#[test]
fn test_canonicalize_for_signing_empty() {
let fields: Vec<(&str, &str)> = vec![];
let result = canonicalize_for_signing(&fields);
assert!(result.is_empty());
}
#[test]
fn test_canonicalize_for_signing_single_field() {
let fields = vec![("key", "value")];
let result = canonicalize_for_signing(&fields);
assert_eq!(result, b"key=value\n");
}
#[test]
fn test_canonicalize_for_signing_duplicate_keys() {
let fields = vec![("a", "one"), ("a", "two")];
let result = canonicalize_for_signing(&fields);
let s = String::from_utf8(result).unwrap();
assert!(s.contains("a=one\n"));
assert!(s.contains("a=two\n"));
}
#[test]
fn test_canonicalize_for_signing_special_characters() {
let fields = vec![("key", "val=ue"), ("sp ace", "data")];
let result = canonicalize_for_signing(&fields);
let s = String::from_utf8(result).unwrap();
assert!(s.contains("key=val=ue\n"));
assert!(s.contains("sp ace=data\n"));
}
#[test]
fn test_temp_dir_guard_creates_directory() {
let guard = TempDirGuard::new("test-prefix").unwrap();
assert!(guard.path().exists());
assert!(guard.path().is_dir());
let name = guard.path().file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("test-prefix-"));
}
#[test]
fn test_dirs_next_returns_some() {
let result = dirs_next();
assert!(
result.is_some(),
"dirs_next should return Some on typical systems"
);
}
#[test]
fn test_home_dir_fallback_returns_some() {
let result = home_dir_fallback();
assert!(
result.is_some(),
"home_dir_fallback should return Some on typical systems"
);
}
#[test]
fn test_is_linked_worktree_standalone_repo() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
assert!(!is_linked_worktree(repo));
}
#[test]
fn test_is_linked_worktree_not_a_git_repo() {
let dir = tempdir().unwrap();
assert!(!is_linked_worktree(dir.path()));
}
#[test]
fn test_is_linked_worktree_linked() {
let dir = tempdir().unwrap();
let main_root = dir.path().join("main");
std::fs::create_dir_all(&main_root).unwrap();
init_git_repo_with_commit(&main_root);
Command::new("git")
.current_dir(&main_root)
.args(["branch", "wt-branch"])
.output()
.unwrap();
let wt_path = dir.path().join("linked-wt");
Command::new("git")
.current_dir(&main_root)
.args(["worktree", "add", &wt_path.to_string_lossy(), "wt-branch"])
.output()
.unwrap();
assert!(is_linked_worktree(&wt_path));
}
#[test]
fn test_enable_worktree_config() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
enable_worktree_config(repo).unwrap();
let output = Command::new("git")
.current_dir(repo)
.args(["config", "extensions.worktreeConfig"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "true");
}
#[test]
fn test_enable_worktree_config_not_a_repo() {
let dir = tempdir().unwrap();
let _result = enable_worktree_config(dir.path());
}
#[test]
fn test_cleanup_leaked_signing_config_no_signing_key() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
cleanup_leaked_signing_config(repo);
}
#[test]
fn test_cleanup_leaked_signing_config_user_key_preserved() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
Command::new("git")
.current_dir(repo)
.args([
"config",
"--local",
"user.signingkey",
"~/.ssh/id_ecdsa_signing",
])
.output()
.unwrap();
cleanup_leaked_signing_config(repo);
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "user.signingkey"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"~/.ssh/id_ecdsa_signing"
);
}
#[test]
fn test_cleanup_leaked_signing_config_removes_agent_key() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
Command::new("git")
.current_dir(repo)
.args([
"config",
"--local",
"user.signingkey",
"/some/path/.crosslink/keys/agent_ed25519",
])
.output()
.unwrap();
Command::new("git")
.current_dir(repo)
.args(["config", "--local", "gpg.format", "ssh"])
.output()
.unwrap();
Command::new("git")
.current_dir(repo)
.args(["config", "--local", "commit.gpgsign", "true"])
.output()
.unwrap();
Command::new("git")
.current_dir(repo)
.args([
"config",
"--local",
"gpg.ssh.allowedSignersFile",
"/some/path/allowed_signers",
])
.output()
.unwrap();
cleanup_leaked_signing_config(repo);
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "user.signingkey"])
.output()
.unwrap();
assert!(
!output.status.success(),
"agent signing key should be cleaned up"
);
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "gpg.format"])
.output()
.unwrap();
assert!(!output.status.success(), "gpg.format should be cleaned up");
}
#[test]
fn test_cleanup_leaked_signing_config_not_a_git_repo() {
let dir = tempdir().unwrap();
cleanup_leaked_signing_config(dir.path());
}
#[test]
fn test_configure_git_ssh_signing_with_allowed_signers() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
let key_path = repo.join("fake-key");
std::fs::write(&key_path, "fake").unwrap();
let signers_path = repo.join("trust").join("allowed_signers");
std::fs::create_dir_all(signers_path.parent().unwrap()).unwrap();
std::fs::write(&signers_path, "# empty").unwrap();
configure_git_ssh_signing(repo, &key_path, Some(&signers_path)).unwrap();
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "gpg.ssh.allowedSignersFile"])
.output()
.unwrap();
assert!(output.status.success());
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert!(
value.contains("allowed_signers"),
"should contain allowed_signers path, got: {value}"
);
}
#[test]
fn test_run_git_config_local_scope() {
let dir = tempdir().unwrap();
let repo = dir.path();
Command::new("git")
.current_dir(repo)
.args(["init", "-q"])
.output()
.unwrap();
run_git_config(repo, "user.name", "TestUser", false).unwrap();
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--local", "user.name"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "TestUser");
}
#[test]
fn test_run_git_config_worktree_scope() {
let dir = tempdir().unwrap();
let main_root = dir.path().join("main");
std::fs::create_dir_all(&main_root).unwrap();
init_git_repo_with_commit(&main_root);
enable_worktree_config(&main_root).unwrap();
Command::new("git")
.current_dir(&main_root)
.args(["branch", "wt-cfg"])
.output()
.unwrap();
let wt_path = dir.path().join("wt-cfg");
Command::new("git")
.current_dir(&main_root)
.args(["worktree", "add", &wt_path.to_string_lossy(), "wt-cfg"])
.output()
.unwrap();
run_git_config(&wt_path, "user.name", "WTUser", true).unwrap();
let output = Command::new("git")
.current_dir(&wt_path)
.args(["config", "--worktree", "user.name"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "WTUser");
}
#[test]
fn test_run_git_config_not_a_repo_fails() {
let dir = tempdir().unwrap();
let result = run_git_config(dir.path(), "user.name", "Fail", false);
assert!(result.is_err());
}
#[test]
fn test_parse_ssh_verify_output_good_no_key_field() {
let output = r#"Good "git" signature for user@host with ED25519 SHA256:Abc"#;
let result = parse_ssh_verify_output(output);
assert!(result.is_none());
}
#[test]
fn test_parse_ssh_verify_output_good_no_with() {
let output = r#"Good "git" signature for user@host ED25519 key SHA256:Abc"#;
let result = parse_ssh_verify_output(output);
assert!(result.is_none());
}
#[test]
fn test_parse_verify_output_no_match() {
let result = parse_verify_output("nothing useful here");
assert!(result.is_none());
}
#[test]
fn test_parse_verify_output_empty() {
let result = parse_verify_output("");
assert!(result.is_none());
}
#[test]
fn test_parse_gpg_fingerprint_short_validsig_line() {
let output = "[GNUPG:] VALIDSIG";
assert!(parse_gpg_fingerprint(output).is_none());
}
#[test]
fn test_parse_gpg_fingerprint_exactly_three_parts() {
let output = "[GNUPG:] VALIDSIG FINGERPRINT123";
let fp = parse_gpg_fingerprint(output);
assert_eq!(fp, Some("FINGERPRINT123".to_string()));
}
#[test]
fn test_allowed_signers_parse_malformed_no_space() {
let content = "nospacehere\n";
let signers = AllowedSigners::parse(content);
assert!(signers.entries.is_empty());
}
#[test]
fn test_allowed_signers_parse_empty_principal_via_leading_space() {
let content = " ssh-ed25519 AAAA\n";
let signers = AllowedSigners::parse(content);
assert!(signers.entries.is_empty());
}
#[test]
fn test_allowed_signers_parse_metadata_reset_on_blank() {
let content =
"# approved by user at some-time\n\ndriver@example.com ssh-ed25519 AAAA key\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert!(
signers.entries[0].metadata_comment.is_none(),
"metadata should be cleared after blank line"
);
}
#[test]
fn test_allowed_signers_parse_metadata_reset_on_malformed() {
let content = "# approved by user at some-time\nnospacehere\ndriver@example.com ssh-ed25519 AAAA key\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert!(
signers.entries[0].metadata_comment.is_none(),
"metadata should be cleared after malformed line"
);
}
#[test]
fn test_allowed_signers_parse_metadata_reset_on_invalid_principal() {
let content =
"# approved by user\nagent\x01bad ssh-ed25519 AAAA\nok@host ssh-ed25519 BBBB\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert_eq!(signers.entries[0].principal, "ok@host");
assert!(
signers.entries[0].metadata_comment.is_none(),
"metadata should be cleared after invalid principal"
);
}
#[test]
fn test_allowed_signers_parse_metadata_reset_on_bad_key_type() {
let content =
"# approved by user\nagent@host fake-key-type AAAA\nok@host ssh-ed25519 BBBB\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert_eq!(signers.entries[0].principal, "ok@host");
assert!(
signers.entries[0].metadata_comment.is_none(),
"metadata should be cleared after bad key type"
);
}
#[test]
fn test_allowed_signers_parse_revoked_metadata() {
let content = "# revoked by admin at 2026-03-01\nagent@host ssh-ed25519 AAAA\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert_eq!(
signers.entries[0].metadata_comment.as_deref(),
Some("revoked by admin at 2026-03-01")
);
}
#[test]
fn test_allowed_signers_parse_non_metadata_comment_ignored() {
let content = "# just a regular comment\nagent@host ssh-ed25519 AAAA\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert!(
signers.entries[0].metadata_comment.is_none(),
"non-metadata comment should not be attached"
);
}
#[test]
fn test_allowed_signers_render_empty() {
let signers = AllowedSigners::default();
let rendered = signers.render();
assert!(rendered.contains("# Crosslink trusted signers"));
assert!(rendered.contains("# Format:"));
}
#[test]
fn test_allowed_signers_render_with_entries_and_metadata() {
let mut signers = AllowedSigners::default();
signers.entries.push(AllowedSignerEntry {
principal: "user@host".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: Some("approved by admin at 2026-03-01".to_string()),
});
signers.entries.push(AllowedSignerEntry {
principal: "agent@host".to_string(),
public_key: "ssh-rsa BBBB".to_string(),
metadata_comment: None,
});
let rendered = signers.render();
assert!(rendered.contains("# approved by admin at 2026-03-01"));
assert!(rendered.contains("user@host ssh-ed25519 AAAA"));
assert!(rendered.contains("agent@host ssh-rsa BBBB"));
let lines: Vec<&str> = rendered.lines().collect();
let agent_idx = lines.iter().position(|l| l.contains("agent@host")).unwrap();
assert!(
!lines[agent_idx - 1].starts_with("# approved")
&& !lines[agent_idx - 1].starts_with("# revoked"),
"entry without metadata should not have preceding metadata comment"
);
}
#[test]
fn test_allowed_signers_save_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir
.path()
.join("deep")
.join("nested")
.join("allowed_signers");
let signers = AllowedSigners::default();
signers.save(&path).unwrap();
assert!(path.exists());
}
#[test]
fn test_allowed_signers_parse_all_key_types() {
let content = "\
a@host ssh-ed25519 AAAA\n\
b@host ssh-rsa BBBB\n\
c@host ssh-dss CCCC\n\
d@host ecdsa-sha2-nistp256 DDDD\n\
e@host sk-ssh-ed25519 EEEE\n\
f@host sk-ecdsa-sha2-nistp256 FFFF\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 6);
}
#[test]
fn test_signature_verification_debug_variants() {
let valid = SignatureVerification::Valid {
commit: "abc123".to_string(),
fingerprint: Some("SHA256:abc".to_string()),
principal: Some("user@host".to_string()),
};
let debug_str = format!("{valid:?}");
assert!(debug_str.contains("Valid"));
assert!(debug_str.contains("abc123"));
let unsigned = SignatureVerification::Unsigned {
commit: "def456".to_string(),
};
let debug_str = format!("{unsigned:?}");
assert!(debug_str.contains("Unsigned"));
let invalid = SignatureVerification::Invalid {
commit: "ghi789".to_string(),
reason: "bad sig".to_string(),
};
let debug_str = format!("{invalid:?}");
assert!(debug_str.contains("Invalid"));
let no_commits = SignatureVerification::NoCommits;
let debug_str = format!("{no_commits:?}");
assert!(debug_str.contains("NoCommits"));
}
#[test]
fn test_signature_verification_valid_no_fingerprint_no_principal() {
let v = SignatureVerification::Valid {
commit: "abc".to_string(),
fingerprint: None,
principal: None,
};
let debug = format!("{v:?}");
assert!(debug.contains("None"));
}
#[test]
fn test_ssh_key_pair_clone_and_eq() {
let kp1 = SshKeyPair {
private_key_path: PathBuf::from("/tmp/key"),
public_key_path: PathBuf::from("/tmp/key.pub"),
fingerprint: "SHA256:abc".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
};
let kp2 = kp1.clone();
assert_eq!(kp1, kp2);
let kp3 = SshKeyPair {
private_key_path: PathBuf::from("/tmp/other"),
public_key_path: PathBuf::from("/tmp/other.pub"),
fingerprint: "SHA256:xyz".to_string(),
public_key: "ssh-ed25519 BBBB".to_string(),
};
assert_ne!(kp1, kp3);
}
#[test]
fn test_ssh_key_pair_serde_roundtrip() {
let kp = SshKeyPair {
private_key_path: PathBuf::from("/tmp/key"),
public_key_path: PathBuf::from("/tmp/key.pub"),
fingerprint: "SHA256:abc".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
};
let json = serde_json::to_string(&kp).unwrap();
let deserialized: SshKeyPair = serde_json::from_str(&json).unwrap();
assert_eq!(kp, deserialized);
}
#[test]
fn test_allowed_signer_entry_serde_roundtrip() {
let entry = AllowedSignerEntry {
principal: "user@host".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: Some("approved by admin".to_string()),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: AllowedSignerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, deserialized);
}
#[test]
fn test_allowed_signer_entry_serde_skips_none_metadata() {
let entry = AllowedSignerEntry {
principal: "user@host".to_string(),
public_key: "ssh-ed25519 AAAA".to_string(),
metadata_comment: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("metadata_comment"));
}
#[test]
fn test_sign_and_verify_content_roundtrip() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "sign-test", "host").unwrap();
let signers_path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "sign-test@crosslink".to_string(),
public_key: keypair.public_key.clone(),
metadata_comment: None,
});
signers.save(&signers_path).unwrap();
let content = b"test content to sign";
let namespace = "crosslink";
let sig = sign_content(&keypair.private_key_path, content, namespace).unwrap();
assert!(!sig.is_empty());
let valid = verify_content(
&signers_path,
"sign-test@crosslink",
namespace,
content,
&sig,
)
.unwrap();
assert!(valid, "signature should verify with correct principal");
let invalid = verify_content(
&signers_path,
"wrong-principal@crosslink",
namespace,
content,
&sig,
)
.unwrap();
assert!(!invalid, "signature should not verify with wrong principal");
let invalid = verify_content(
&signers_path,
"sign-test@crosslink",
namespace,
b"tampered content",
&sig,
)
.unwrap();
assert!(!invalid, "signature should not verify with wrong content");
}
#[test]
fn test_sign_content_with_canonicalized_fields() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "canon-test", "host").unwrap();
let fields = vec![("action", "create"), ("id", "123"), ("ts", "2026-03-13")];
let content = canonicalize_for_signing(&fields);
let sig = sign_content(&keypair.private_key_path, &content, "crosslink").unwrap();
assert!(!sig.is_empty());
let signers_path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "canon-test@crosslink".to_string(),
public_key: keypair.public_key,
metadata_comment: None,
});
signers.save(&signers_path).unwrap();
let valid = verify_content(
&signers_path,
"canon-test@crosslink",
"crosslink",
&content,
&sig,
)
.unwrap();
assert!(valid);
}
#[test]
fn test_sign_content_invalid_key_path() {
let dir = tempdir().unwrap();
let fake_key = dir.path().join("nonexistent_key");
let result = sign_content(&fake_key, b"content", "ns");
assert!(result.is_err());
}
#[test]
fn test_verify_content_invalid_signers_path() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let fake_signers = dir.path().join("nonexistent_signers");
let result =
verify_content(&fake_signers, "principal", "ns", b"content", "invalidsig").unwrap();
assert!(!result, "should return false for invalid signers file");
}
#[test]
fn test_verify_content_malformed_signature() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let signers_path = dir.path().join("allowed_signers");
let signers = AllowedSigners::default();
signers.save(&signers_path).unwrap();
let result = verify_content(
&signers_path,
"user@host",
"ns",
b"content",
"not-real-base64-sig",
)
.unwrap();
assert!(!result, "should return false for malformed signature");
}
#[test]
fn test_find_default_ssh_key_returns_option() {
let _result = find_default_ssh_key();
}
#[test]
fn test_find_git_signing_key_returns_option() {
let _result = find_git_signing_key();
}
#[test]
fn test_get_key_fingerprint_nonexistent_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.pub");
let result = get_key_fingerprint(&path);
assert!(result.is_err());
}
#[test]
fn test_get_key_fingerprint_invalid_key_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.pub");
std::fs::write(&path, "not a valid ssh key").unwrap();
let result = get_key_fingerprint(&path);
assert!(result.is_err());
}
#[test]
fn test_ssh_key_names_constant() {
assert_eq!(SSH_KEY_NAMES.len(), 3);
assert!(SSH_KEY_NAMES.contains(&"id_ed25519.pub"));
assert!(SSH_KEY_NAMES.contains(&"id_ecdsa.pub"));
assert!(SSH_KEY_NAMES.contains(&"id_rsa.pub"));
}
#[test]
fn test_allowed_signers_known_key_types() {
assert!(AllowedSigners::KNOWN_KEY_TYPES.contains(&"ssh-ed25519"));
assert!(AllowedSigners::KNOWN_KEY_TYPES.contains(&"ssh-rsa"));
assert!(AllowedSigners::KNOWN_KEY_TYPES.contains(&"ssh-dss"));
assert!(AllowedSigners::KNOWN_KEY_TYPES.contains(&"ecdsa-sha2-"));
assert!(AllowedSigners::KNOWN_KEY_TYPES.contains(&"sk-ssh-ed25519"));
assert!(AllowedSigners::KNOWN_KEY_TYPES.contains(&"sk-ecdsa-sha2-"));
}
#[test]
fn test_find_default_ssh_key_no_keys_in_dir() {
let _result: Option<PathBuf> = find_default_ssh_key();
}
#[test]
fn test_find_git_signing_key_nonexistent_path() {
let _result: Option<PathBuf> = find_git_signing_key();
}
#[test]
fn test_get_key_fingerprint_unexpected_output_format() {
let dir = tempdir().unwrap();
let path = dir.path().join("not_a_key.pub");
std::fs::write(&path, "hello world").unwrap();
let result = get_key_fingerprint(&path);
assert!(result.is_err());
}
#[test]
fn test_verify_content_with_valid_sig_passes_good_check() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "good-check-test", "host").unwrap();
let signers_path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "good-check-test@crosslink".to_string(),
public_key: keypair.public_key.clone(),
metadata_comment: None,
});
signers.save(&signers_path).unwrap();
let content = b"content for good check test";
let sig = sign_content(&keypair.private_key_path, content, "crosslink").unwrap();
let valid = verify_content(
&signers_path,
"good-check-test@crosslink",
"crosslink",
content,
&sig,
)
.unwrap();
assert!(valid, "valid signature should pass");
}
#[test]
fn test_allowed_signers_parse_only_comments_and_blanks() {
let content = "# Comment 1\n# Comment 2\n\n# Comment 3\n";
let signers = AllowedSigners::parse(content);
assert!(signers.entries.is_empty());
}
#[test]
fn test_allowed_signers_parse_consecutive_metadata_comments() {
let content = "# approved by user1\n# approved by user2\nagent@host ssh-ed25519 AAAA\n";
let signers = AllowedSigners::parse(content);
assert_eq!(signers.entries.len(), 1);
assert_eq!(
signers.entries[0].metadata_comment.as_deref(),
Some("approved by user2")
);
}
#[test]
fn test_temp_dir_guard_unique() {
let guard1 = TempDirGuard::new("unique-test").unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
let guard2 = TempDirGuard::new("unique-test").unwrap();
assert_ne!(guard1.path(), guard2.path());
}
#[test]
fn test_sign_content_different_namespaces() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
eprintln!("Skipping: ssh-keygen not available");
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "ns-test", "host").unwrap();
let content = b"namespace test content";
let sig_alpha = sign_content(&keypair.private_key_path, content, "alpha").unwrap();
let sig_beta = sign_content(&keypair.private_key_path, content, "beta").unwrap();
assert_ne!(sig_alpha, sig_beta);
let signers_path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "ns-test@crosslink".to_string(),
public_key: keypair.public_key,
metadata_comment: None,
});
signers.save(&signers_path).unwrap();
let valid = verify_content(
&signers_path,
"ns-test@crosslink",
"alpha",
content,
&sig_alpha,
)
.unwrap();
assert!(valid);
let invalid = verify_content(
&signers_path,
"ns-test@crosslink",
"beta",
content,
&sig_alpha,
)
.unwrap();
assert!(!invalid);
}
#[test]
fn test_sign_verify_tampered_content_fails() {
if Command::new("ssh-keygen").arg("--help").output().is_err() {
return;
}
let dir = tempdir().unwrap();
let keys_dir = dir.path().join("keys");
let keypair = generate_agent_key(&keys_dir, "sign-rt2", "host").unwrap();
let signers_path = dir.path().join("allowed_signers");
let mut signers = AllowedSigners::default();
signers.add_entry(AllowedSignerEntry {
principal: "sign-rt2@crosslink".to_string(),
public_key: keypair.public_key.clone(),
metadata_comment: None,
});
signers.save(&signers_path).unwrap();
let content = b"original content";
let sig = sign_content(&keypair.private_key_path, content, "sign-rt2@crosslink").unwrap();
let invalid = verify_content(
&signers_path,
"sign-rt2@crosslink",
"git",
b"tampered content",
&sig,
)
.unwrap();
assert!(!invalid, "Tampered content should fail verification");
}
}