zenops 0.19.0

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

/// 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<'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 {
    /// 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 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() {
        // Empty config => update_config_files returns Ok without consulting
        // the fetcher and without panicking. This is the early-return branch
        // that the integration tests (which always configure at least one
        // entry) don't reach.
        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() {
        // Reaches the Some-arm of the let-else in update_config_files and
        // the trailing config_files.add call (existing tests only exercise
        // the empty-config early-return branch).
        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() {
        // Exercises the `?` error branch on the `for line in fetcher.fetch(...)?`
        // loop in `build_body` — the existing tests only feed it Ok responses.
        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()]);
    }
}