use smol_str::SmolStr;
use std::fmt::Write as _;
use zenops_safe_relative_path::srpath;
use crate::{
config::pkg::which_on_path,
config_files::{ConfigFilePath, ConfigFileSource, ConfigFiles},
error::Error,
};
#[derive(serde::Deserialize)]
struct GithubSigningKey {
key: String,
}
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct StoredSshConfig {
pub allowed_signers: Vec<AllowedSignerEntry>,
}
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum AllowedSignerEntry {
Github {
principal: SmolStr,
username: SmolStr,
},
Manual {
principal: SmolStr,
key_type: SmolStr,
key: SmolStr,
},
}
pub(super) trait GithubKeyFetcher {
fn fetch(&self, username: &str) -> Result<Vec<String>, Error>;
}
pub(super) struct CurlGithubKeyFetcher;
impl GithubKeyFetcher for CurlGithubKeyFetcher {
fn fetch(&self, username: &str) -> Result<Vec<String>, Error> {
if !which_on_path("curl") {
return Err(Error::CurlNotFound);
}
let sh = xshell::Shell::new().map_err(Error::Shell)?;
let url = format!("https://api.github.com/users/{username}/ssh_signing_keys");
let body = xshell::cmd!(
sh,
"curl -sSfL -H 'Accept: application/vnd.github+json' -H 'User-Agent: zenops' {url}"
)
.read()
.map_err(|source| Error::GithubKeyFetchFailed {
username: SmolStr::new(username),
source,
})?;
let keys: Vec<GithubSigningKey> =
serde_json::from_str(&body).map_err(|source| Error::GithubKeyParseFailed {
username: SmolStr::new(username),
source,
})?;
Ok(keys.into_iter().map(|k| k.key).collect())
}
}
impl StoredSshConfig {
fn build_body(&self, fetcher: &dyn GithubKeyFetcher) -> Result<Option<String>, Error> {
if self.allowed_signers.is_empty() {
return Ok(None);
}
let mut body = String::from("# Generated by zenops — do not edit.\n");
for entry in &self.allowed_signers {
match entry {
AllowedSignerEntry::Manual {
principal,
key_type,
key,
} => {
_ = writeln!(body, "{principal} {key_type} {key}");
}
AllowedSignerEntry::Github {
principal,
username,
} => {
for line in fetcher.fetch(username)? {
let line = line.trim();
if line.is_empty() {
continue;
}
_ = writeln!(body, "{principal} {line} {username}@github");
}
}
}
}
Ok(Some(body))
}
pub(super) fn update_config_files(
&self,
config_files: &mut ConfigFiles,
fetcher: &dyn GithubKeyFetcher,
) -> Result<(), Error> {
let Some(body) = self.build_body(fetcher)? else {
return Ok(());
};
config_files.add(
ConfigFilePath::in_home(srpath!(".ssh/allowed_signers")),
ConfigFileSource::Generated(body),
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct StubFetcher {
response: Vec<String>,
}
impl GithubKeyFetcher for StubFetcher {
fn fetch(&self, _username: &str) -> Result<Vec<String>, Error> {
Ok(self.response.clone())
}
}
struct PanicFetcher;
impl GithubKeyFetcher for PanicFetcher {
fn fetch(&self, username: &str) -> Result<Vec<String>, Error> {
panic!("fetcher must not be called for username={username}");
}
}
struct RecordingFetcher {
response: Vec<String>,
calls: RefCell<Vec<String>>,
}
impl GithubKeyFetcher for RecordingFetcher {
fn fetch(&self, username: &str) -> Result<Vec<String>, Error> {
self.calls.borrow_mut().push(username.to_string());
Ok(self.response.clone())
}
}
#[test]
fn empty_allowed_signers_emits_no_body() {
let cfg = StoredSshConfig::default();
assert_eq!(cfg.build_body(&PanicFetcher).unwrap(), None);
}
#[test]
fn manual_entry_produces_single_line_and_does_not_call_fetcher() {
let cfg = StoredSshConfig {
allowed_signers: vec![AllowedSignerEntry::Manual {
principal: SmolStr::new_static("bob@example.com"),
key_type: SmolStr::new_static("ssh-ed25519"),
key: SmolStr::new_static("AAAAKEY"),
}],
};
assert_eq!(
cfg.build_body(&PanicFetcher).unwrap().as_deref(),
Some("# Generated by zenops — do not edit.\nbob@example.com ssh-ed25519 AAAAKEY\n"),
);
}
#[test]
fn github_entry_emits_one_line_per_key_with_source_comment() {
let cfg = StoredSshConfig {
allowed_signers: vec![AllowedSignerEntry::Github {
principal: SmolStr::new_static("octocat@example.com"),
username: SmolStr::new_static("octocat"),
}],
};
let fetcher = StubFetcher {
response: vec![
"ssh-ed25519 AAAAKEY1".to_string(),
"ssh-rsa AAAAKEY2".to_string(),
],
};
assert_eq!(
cfg.build_body(&fetcher).unwrap().as_deref(),
Some(
"# Generated by zenops — do not edit.\n\
octocat@example.com ssh-ed25519 AAAAKEY1 octocat@github\n\
octocat@example.com ssh-rsa AAAAKEY2 octocat@github\n",
),
);
}
#[test]
fn github_entry_skips_blank_keys() {
let cfg = StoredSshConfig {
allowed_signers: vec![AllowedSignerEntry::Github {
principal: SmolStr::new_static("octocat@example.com"),
username: SmolStr::new_static("octocat"),
}],
};
let fetcher = StubFetcher {
response: vec![String::new(), "ssh-ed25519 AAAAKEY1".to_string()],
};
assert_eq!(
cfg.build_body(&fetcher).unwrap().as_deref(),
Some(
"# Generated by zenops — do not edit.\n\
octocat@example.com ssh-ed25519 AAAAKEY1 octocat@github\n",
),
);
}
#[test]
fn mixed_entries_preserve_order_and_only_call_fetcher_for_github() {
let cfg = StoredSshConfig {
allowed_signers: vec![
AllowedSignerEntry::Manual {
principal: SmolStr::new_static("bob@example.com"),
key_type: SmolStr::new_static("ssh-ed25519"),
key: SmolStr::new_static("BOBKEY"),
},
AllowedSignerEntry::Github {
principal: SmolStr::new_static("octocat@example.com"),
username: SmolStr::new_static("octocat"),
},
],
};
let fetcher = RecordingFetcher {
response: vec!["ssh-ed25519 AAAAKEY1".to_string()],
calls: RefCell::new(Vec::new()),
};
assert_eq!(
cfg.build_body(&fetcher).unwrap().as_deref(),
Some(
"# Generated by zenops — do not edit.\n\
bob@example.com ssh-ed25519 BOBKEY\n\
octocat@example.com ssh-ed25519 AAAAKEY1 octocat@github\n",
),
);
assert_eq!(*fetcher.calls.borrow(), vec!["octocat".to_string()]);
}
}