zenops 0.11.0

Declarative system configuration management for shell config and dotfiles.
Documentation
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,
};

/// A single entry from GitHub's `/users/:username/ssh_signing_keys` endpoint.
/// The API returns more fields (`id`, timestamps, etc.), but `serde` ignores
/// unknown fields by default so this struct only names what we consume.
#[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 {
    /// Return one full key line per signing key (e.g. `"ssh-ed25519 AAAA..."`).
    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 {
    /// Build the allowed_signers file body, resolving `Github` entries via
    /// `fetcher`. Returns `None` when there's nothing to emit.
    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()]);
    }
}