zenops 0.17.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::user::StoredUserConfig,
    config_files::{ConfigFilePath, ConfigFileSource, ConfigFiles},
    error::Error,
};

/// Path to the managed allowed_signers file, referenced from git config when
/// SSH signing is enabled and the user has configured `[[ssh.allowed_signers]]`.
/// Kept as a single source of truth so the rendered line and the file we
/// actually write can't drift.
const ALLOWED_SIGNERS_CONFIG_VALUE: &str = "~/.ssh/allowed_signers";

#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct StoredGitConfig {
    pub signing: Option<StoredGitSigning>,
}

/// Signing backend selector. `type = "ssh"` uses an SSH key (matches the
/// `gpg.format = ssh` branch git added in 2.34); `type = "gpg"` uses a
/// classic OpenPGP key by ID/fingerprint. The rest of the git config
/// (`gpg.format`, `commit.gpgsign`, `gpg.ssh.allowedSignersFile`) is inferred
/// from the variant — setting one of these in zenops turns on commit signing.
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum StoredGitSigning {
    Ssh {
        /// Path to an SSH public key, e.g. `~/.ssh/id_ed25519-github.pub`.
        /// Passed through verbatim — git expands `~` itself.
        key: SmolStr,
    },
    Gpg {
        /// OpenPGP key ID or full fingerprint.
        key: SmolStr,
    },
}

impl StoredGitSigning {
    fn key(&self) -> &SmolStr {
        match self {
            Self::Ssh { key } | Self::Gpg { key } => key,
        }
    }

    fn format(&self) -> &'static str {
        match self {
            Self::Ssh { .. } => "ssh",
            Self::Gpg { .. } => "openpgp",
        }
    }
}

impl StoredGitConfig {
    /// Render the `~/.gitconfig` body. Returns `None` when nothing is
    /// configured — no identity set and no signing — so we don't create an
    /// empty managed file.
    fn build_body(&self, user: &StoredUserConfig, has_allowed_signers: bool) -> Option<String> {
        let has_user = user.name.is_some() || user.email.is_some();
        let signing = self.signing.as_ref();
        if !has_user && signing.is_none() {
            return None;
        }

        let mut body = String::from("# Generated by zenops — do not edit.\n");

        if has_user || signing.is_some() {
            body.push_str("[user]\n");
            if let Some(name) = &user.name {
                _ = writeln!(body, "\tname = {name}");
            }
            if let Some(email) = &user.email {
                _ = writeln!(body, "\temail = {email}");
            }
            if let Some(s) = signing {
                _ = writeln!(body, "\tsigningkey = {}", s.key());
            }
        }

        if let Some(s) = signing {
            _ = writeln!(body, "[gpg]\n\tformat = {}", s.format());
            if matches!(s, StoredGitSigning::Ssh { .. }) && has_allowed_signers {
                _ = writeln!(
                    body,
                    "[gpg \"ssh\"]\n\tallowedSignersFile = {ALLOWED_SIGNERS_CONFIG_VALUE}"
                );
            }
            body.push_str("[commit]\n\tgpgsign = true\n");
        }

        Some(body)
    }

    pub(super) fn update_config_files(
        &self,
        user: &StoredUserConfig,
        has_allowed_signers: bool,
        config_files: &mut ConfigFiles,
    ) -> Result<(), Error> {
        let Some(body) = self.build_body(user, has_allowed_signers) else {
            return Ok(());
        };
        config_files.add(
            ConfigFilePath::in_home(srpath!(".gitconfig")),
            ConfigFileSource::Generated(body),
        );
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn user(name: Option<&str>, email: Option<&str>) -> StoredUserConfig {
        StoredUserConfig {
            name: name.map(SmolStr::new),
            email: email.map(SmolStr::new),
        }
    }

    #[test]
    fn empty_everything_emits_no_body() {
        let cfg = StoredGitConfig::default();
        assert_eq!(cfg.build_body(&user(None, None), false), None);
        assert_eq!(cfg.build_body(&user(None, None), true), None);
    }

    #[test]
    fn user_alone_emits_only_user_block() {
        let cfg = StoredGitConfig::default();
        assert_eq!(
            cfg.build_body(&user(Some("Alice"), Some("a@example.com")), false),
            Some(
                "# Generated by zenops — do not edit.\n\
                 [user]\n\
                 \tname = Alice\n\
                 \temail = a@example.com\n"
                    .to_string()
            ),
        );
    }

    #[test]
    fn only_email_set_emits_email_only() {
        let cfg = StoredGitConfig::default();
        assert_eq!(
            cfg.build_body(&user(None, Some("a@example.com")), false),
            Some(
                "# Generated by zenops — do not edit.\n\
                 [user]\n\
                 \temail = a@example.com\n"
                    .to_string()
            ),
        );
    }

    #[test]
    fn ssh_signing_alone_emits_signing_blocks_without_allowed_signers_line() {
        let cfg = StoredGitConfig {
            signing: Some(StoredGitSigning::Ssh {
                key: SmolStr::new("~/.ssh/id_ed25519.pub"),
            }),
        };
        assert_eq!(
            cfg.build_body(&user(None, None), false),
            Some(
                "# Generated by zenops — do not edit.\n\
                 [user]\n\
                 \tsigningkey = ~/.ssh/id_ed25519.pub\n\
                 [gpg]\n\
                 \tformat = ssh\n\
                 [commit]\n\
                 \tgpgsign = true\n"
                    .to_string()
            ),
        );
    }

    #[test]
    fn ssh_signing_plus_user_plus_allowed_signers_emits_full_output() {
        let cfg = StoredGitConfig {
            signing: Some(StoredGitSigning::Ssh {
                key: SmolStr::new("~/.ssh/id_ed25519-github.pub"),
            }),
        };
        assert_eq!(
            cfg.build_body(&user(Some("Bjorn"), Some("bjrnove@gmail.com")), true),
            Some(
                "# Generated by zenops — do not edit.\n\
                 [user]\n\
                 \tname = Bjorn\n\
                 \temail = bjrnove@gmail.com\n\
                 \tsigningkey = ~/.ssh/id_ed25519-github.pub\n\
                 [gpg]\n\
                 \tformat = ssh\n\
                 [gpg \"ssh\"]\n\
                 \tallowedSignersFile = ~/.ssh/allowed_signers\n\
                 [commit]\n\
                 \tgpgsign = true\n"
                    .to_string()
            ),
        );
    }

    #[test]
    fn ssh_signing_with_allowed_signers_but_no_user() {
        let cfg = StoredGitConfig {
            signing: Some(StoredGitSigning::Ssh {
                key: SmolStr::new("~/.ssh/id.pub"),
            }),
        };
        assert_eq!(
            cfg.build_body(&user(None, None), true),
            Some(
                "# Generated by zenops — do not edit.\n\
                 [user]\n\
                 \tsigningkey = ~/.ssh/id.pub\n\
                 [gpg]\n\
                 \tformat = ssh\n\
                 [gpg \"ssh\"]\n\
                 \tallowedSignersFile = ~/.ssh/allowed_signers\n\
                 [commit]\n\
                 \tgpgsign = true\n"
                    .to_string()
            ),
        );
    }

    #[test]
    fn gpg_signing_alone_uses_openpgp_format_and_skips_allowed_signers_block() {
        let cfg = StoredGitConfig {
            signing: Some(StoredGitSigning::Gpg {
                key: SmolStr::new("ABCD1234DEADBEEF"),
            }),
        };
        assert_eq!(
            cfg.build_body(&user(None, None), false),
            Some(
                "# Generated by zenops — do not edit.\n\
                 [user]\n\
                 \tsigningkey = ABCD1234DEADBEEF\n\
                 [gpg]\n\
                 \tformat = openpgp\n\
                 [commit]\n\
                 \tgpgsign = true\n"
                    .to_string()
            ),
        );
    }

    /// The inference is SSH-only: GPG signing must not pick up
    /// `gpg.ssh.allowedSignersFile` just because `[[ssh.allowed_signers]]`
    /// happens to be set.
    #[test]
    fn gpg_signing_with_allowed_signers_still_skips_ssh_block() {
        let cfg = StoredGitConfig {
            signing: Some(StoredGitSigning::Gpg {
                key: SmolStr::new("ABCD1234"),
            }),
        };
        let body = cfg.build_body(&user(None, None), true).unwrap();
        assert!(!body.contains("allowedSignersFile"), "body was:\n{body}");
        assert!(body.contains("format = openpgp"), "body was:\n{body}");
    }
}