zenops 0.19.0

Declarative system configuration management for shell config and dotfiles.
Documentation
use similar_asserts::assert_eq;
use std::sync::Arc;
use zenops::{
    Cmd,
    config_files::ConfigFilePath,
    output::{AppliedAction, FileStatus, Status},
};
use zenops_safe_relative_path::srpath;

use test_env::{Entry, Output, paths};

mod test_env;

#[test]
fn ssh_allowed_signers_manual_entry_roundtrips_through_apply_and_status() {
    let env = test_env::TestEnv::load();
    env.init_config(
        r#"
        [[ssh.allowed_signers]]
        type = "manual"
        principal = "bob@example.com"
        key_type = "ssh-ed25519"
        key = "AAAAKEY"
    "#,
    );

    let file_path = env.cfpath(".ssh/allowed_signers", ConfigFilePath::Home);
    let dir_path = env.cfpath(".ssh", ConfigFilePath::Home);
    let want_body = Arc::<str>::from(
        "# Generated by zenops — do not edit.\nbob@example.com ssh-ed25519 AAAAKEY\n",
    );

    // Status reports the file as missing.
    assert_eq!(
        env.run(&Cmd::Status {
            diff: false,
            all: false,
        }),
        Ok(Output {
            entries: vec![
                env.git_repo_clean_entry(),
                Entry::Status(Status::Generated {
                    want_content: want_body.clone(),
                    cur_content: None,
                    path: file_path.clone(),
                    status: FileStatus::New,
                }),
            ],
        }),
    );

    // Apply creates ~/.ssh then writes the file.
    assert_eq!(
        env.run(&Cmd::Apply {
            pull_config: false,
            yes: true,
            dry_run: false,
            allow_dirty: true,
        }),
        Ok(Output {
            entries: vec![
                Entry::AppliedAction(AppliedAction::CreatedDir(dir_path.clone())),
                Entry::AppliedAction(AppliedAction::CreatedFile(file_path.clone())),
            ],
        }),
    );

    // Written content matches.
    let disk = std::fs::read_to_string(
        env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".ssh/allowed_signers"))),
    )
    .unwrap();
    assert_eq!(disk, want_body.as_ref());

    // Re-running status now reports the file as clean.
    assert_eq!(
        env.run(&Cmd::Status {
            diff: false,
            all: false,
        }),
        Ok(Output {
            entries: vec![
                env.git_repo_clean_entry(),
                Entry::Status(Status::Generated {
                    want_content: want_body,
                    cur_content: Some(
                        "# Generated by zenops — do not edit.\nbob@example.com ssh-ed25519 AAAAKEY\n"
                            .to_string(),
                    ),
                    path: file_path,
                    status: FileStatus::Ok,
                }),
            ],
        }),
    );
}

#[test]
fn git_config_ssh_signing_with_allowed_signers_writes_full_gitconfig() {
    let env = test_env::TestEnv::load();
    env.init_config(
        r#"
        [user]
        name = "Ada Lovelace"
        email = "ada@example.com"

        [git.signing]
        type = "ssh"
        key = "~/.ssh/id_ed25519-github.pub"

        [[ssh.allowed_signers]]
        type = "manual"
        principal = "ada@example.com"
        key_type = "ssh-ed25519"
        key = "AAAAKEY"
    "#,
    );

    let allowed_signers_path = env.cfpath(".ssh/allowed_signers", ConfigFilePath::Home);
    let allowed_signers_dir = env.cfpath(".ssh", ConfigFilePath::Home);
    let gitconfig_path = env.cfpath(".gitconfig", ConfigFilePath::Home);
    let want_gitconfig = Arc::<str>::from(
        "# Generated by zenops — do not edit.\n\
         [user]\n\
         \tname = Ada Lovelace\n\
         \temail = ada@example.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",
    );
    let want_allowed_signers = Arc::<str>::from(
        "# Generated by zenops — do not edit.\nada@example.com ssh-ed25519 AAAAKEY\n",
    );

    // Apply writes both the allowed_signers file and ~/.gitconfig.
    assert_eq!(
        env.run(&Cmd::Apply {
            pull_config: false,
            yes: true,
            dry_run: false,
            allow_dirty: true,
        }),
        Ok(Output {
            entries: vec![
                Entry::AppliedAction(AppliedAction::CreatedDir(allowed_signers_dir)),
                Entry::AppliedAction(AppliedAction::CreatedFile(allowed_signers_path)),
                Entry::AppliedAction(AppliedAction::CreatedFile(gitconfig_path.clone())),
            ],
        }),
    );

    let disk =
        std::fs::read_to_string(env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".gitconfig"))))
            .unwrap();
    assert_eq!(disk, want_gitconfig.as_ref());

    let disk = std::fs::read_to_string(
        env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".ssh/allowed_signers"))),
    )
    .unwrap();
    assert_eq!(disk, want_allowed_signers.as_ref());
}

/// `gpg.ssh.allowedSignersFile` is inferred from `[[ssh.allowed_signers]]`
/// being non-empty. With signing enabled but no entries configured, the
/// `[gpg "ssh"]` block must be absent.
#[test]
fn git_config_ssh_signing_without_allowed_signers_omits_gpg_ssh_block() {
    let env = test_env::TestEnv::load();
    env.init_config(
        r#"
        [user]
        name = "Ada Lovelace"
        email = "ada@example.com"

        [git.signing]
        type = "ssh"
        key = "~/.ssh/id_ed25519.pub"
    "#,
    );

    let gitconfig_path = env.cfpath(".gitconfig", ConfigFilePath::Home);

    assert_eq!(
        env.run(&Cmd::Apply {
            pull_config: false,
            yes: true,
            dry_run: false,
            allow_dirty: true,
        }),
        Ok(Output {
            entries: vec![Entry::AppliedAction(AppliedAction::CreatedFile(
                gitconfig_path
            ))],
        }),
    );

    let disk =
        std::fs::read_to_string(env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".gitconfig"))))
            .unwrap();
    assert!(
        !disk.contains("allowedSignersFile"),
        "gitconfig should not reference allowed_signers when none configured:\n{disk}"
    );
    assert!(disk.contains("\tname = Ada Lovelace\n"));
    assert!(disk.contains("\tformat = ssh\n"));
    assert!(disk.contains("\tgpgsign = true\n"));
}