mod error;
pub use error::Error as SshError;
use smol_str::SmolStr;
use std::fmt::Write as _;
use zenops_safe_relative_path::srpath;
use crate::{
config_files::{ConfigFilePath, ConfigFileSource, ConfigFiles},
error::Error,
utils::which::SearchPath,
};
#[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<'a> {
path: &'a SearchPath,
}
impl<'a> CurlGithubKeyFetcher<'a> {
pub(super) fn new(path: &'a SearchPath) -> Self {
Self { path }
}
}
impl GithubKeyFetcher for CurlGithubKeyFetcher<'_> {
fn fetch(&self, username: &str) -> Result<Vec<String>, Error> {
if !crate::utils::which::exists("curl", self.path)? {
return Err(SshError::CurlNotFound.into());
}
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::from(SshError::GithubKeyFetchFailed {
username: SmolStr::new(username),
source,
})
})?;
let keys: Vec<GithubSigningKey> = serde_json::from_str(&body).map_err(|source| {
Error::from(SshError::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 crate::config_files::ConfigFileDirs;
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 update_config_files_is_a_noop_when_allowed_signers_empty() {
let cfg = StoredSshConfig::default();
let dirs = ConfigFileDirs::load(std::path::PathBuf::from("/tmp/zenops-test-home"));
let mut config_files = ConfigFiles::new(&dirs);
cfg.update_config_files(&mut config_files, &PanicFetcher)
.expect("empty config should be a no-op");
}
#[test]
fn update_config_files_registers_allowed_signers_when_non_empty() {
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"),
}],
};
let dirs = ConfigFileDirs::load(std::path::PathBuf::from("/tmp/zenops-test-home"));
let mut config_files = ConfigFiles::new(&dirs);
cfg.update_config_files(&mut config_files, &PanicFetcher)
.expect("non-empty manual config should register a generated file");
}
struct ErrorFetcher;
impl GithubKeyFetcher for ErrorFetcher {
fn fetch(&self, username: &str) -> Result<Vec<String>, Error> {
let sh = xshell::Shell::new().unwrap();
let source = xshell::cmd!(sh, "false").quiet().run().unwrap_err();
Err(SshError::GithubKeyFetchFailed {
username: SmolStr::new(username),
source,
}
.into())
}
}
#[test]
fn build_body_propagates_fetcher_error_for_github_entry() {
let cfg = StoredSshConfig {
allowed_signers: vec![AllowedSignerEntry::Github {
principal: SmolStr::new_static("octocat@example.com"),
username: SmolStr::new_static("octocat"),
}],
};
let err = cfg.build_body(&ErrorFetcher).unwrap_err();
match err {
Error::Ssh(SshError::GithubKeyFetchFailed { username, .. }) => {
assert_eq!(username, "octocat");
}
other => panic!("expected GithubKeyFetchFailed, got {other:?}"),
}
}
#[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()]);
}
}