kc-cli 0.5.0

CLI for reading and writing macOS keychain files without the Security framework
//! Keychain name resolution and persistent defaults.

mod common;

use common::*;
use keychain::{ApplicationAccess, KeychainFile};

#[test]
fn create_requires_an_output_even_when_a_default_is_configured() {
    let home = TempDir::new("create-output-home");
    let output = kc_with_env(
        &["create", "-p", "test-password"],
        &[("HOME", home.path().to_str().unwrap())],
    );
    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("<KEYCHAIN>"));
}

#[test]
fn created_keychains_default_to_prompt_for_direct_and_native_access() {
    let home = TempDir::new("create-access-home");
    let home_text = home.path().to_str().unwrap();
    let keychain = home.join("prompt.keychain-db");
    let keychain_text = keychain.to_str().unwrap();

    for args in [
        vec!["create", "-p", "pw", keychain_text],
        vec![
            "add",
            "generic",
            "-p",
            "pw",
            "-A",
            "alice",
            "-S",
            "service",
            "-w",
            "secret",
            keychain_text,
        ],
    ] {
        let output = kc_with_env(&args, &[("HOME", home_text)]);
        assert!(
            output.status.success(),
            "{args:?}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let shown = kc_with_env(&["access", "show", keychain_text], &[("HOME", home_text)]);
    let shown = String::from_utf8_lossy(&shown.stdout);
    assert!(shown.contains("hybrid"), "{shown}");
    assert!(shown.contains("prompt"), "{shown}");

    let audited = kc_with_env(&["access", "audit", keychain_text], &[("HOME", home_text)]);
    assert!(
        audited.status.success(),
        "{}",
        String::from_utf8_lossy(&audited.stderr)
    );

    let file = KeychainFile::open(&keychain).expect("open created keychain");
    let item = file.items().into_iter().next().expect("one item");
    assert_eq!(
        file.item_application_access(item.record_type, item.number())
            .expect("read ACL"),
        Some(ApplicationAccess::Prompt)
    );

    let denied = kc_with_env(
        &[
            "find",
            "generic",
            "-p",
            "pw",
            "-A",
            "alice",
            "-w",
            keychain_text,
        ],
        &[("HOME", home_text)],
    );
    assert!(!denied.status.success());
    assert!(
        String::from_utf8_lossy(&denied.stderr).contains("rerun with --interactive"),
        "{}",
        String::from_utf8_lossy(&denied.stderr)
    );
}

#[test]
fn an_existing_keychain_can_project_prompt_without_trusted_apps() {
    let home = TempDir::new("access-prompt-projection-home");
    let home_text = home.path().to_str().unwrap();
    let keychain = home.join("existing.keychain-db");
    let keychain_text = keychain.to_str().unwrap();

    for args in [
        vec!["create", "--no-access-policy", "-p", "pw", keychain_text],
        vec![
            "add",
            "generic",
            "-p",
            "pw",
            "-A",
            "alice",
            "-S",
            "service",
            "-w",
            "secret",
            keychain_text,
        ],
        vec![
            "access",
            "set",
            "--mode",
            "hybrid",
            "--default",
            "prompt",
            keychain_text,
        ],
        vec!["access", "apply", "-p", "pw", keychain_text],
    ] {
        let output = kc_with_env(&args, &[("HOME", home_text)]);
        assert!(
            output.status.success(),
            "{args:?}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let file = KeychainFile::open(&keychain).expect("open keychain");
    let item = file.items().into_iter().next().expect("one item");
    assert_eq!(
        file.item_application_access(item.record_type, item.number())
            .expect("read ACL"),
        Some(ApplicationAccess::Prompt)
    );
}

#[test]
fn bare_names_and_the_saved_default_resolve_under_home() {
    let home = TempDir::new("config-home");
    std::fs::create_dir_all(home.join("Library/Keychains")).unwrap();
    let home_text = home.path().to_str().unwrap();

    let output = kc_with_env(
        &["config", "set", "keychains.default", "machina"],
        &[("HOME", home_text)],
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );

    let output = kc_with_env(
        &["create", "-p", "test-password", "machina"],
        &[("HOME", home_text)],
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(home.join("Library/Keychains/machina.keychain-db").exists());

    let output = kc_with_env(&["info"], &[("HOME", home_text)]);
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );

    let text = std::fs::read_to_string(home.join(".config/keychain.kdl")).unwrap();
    assert!(text.contains("default \"machina\""));
}

#[test]
fn additional_search_paths_are_used_for_existing_names() {
    let home = TempDir::new("search-home");
    let extra = home.join("extra");
    std::fs::create_dir_all(home.join("Library/Keychains")).unwrap();
    std::fs::create_dir_all(&extra).unwrap();
    let home_text = home.path().to_str().unwrap();
    let extra_text = extra.to_str().unwrap();
    let keychain = extra.join("archive.keychain-db");
    let keychain_text = keychain.to_str().unwrap();

    let output = kc_with_env(
        &["create", "-p", "test-password", keychain_text],
        &[("HOME", home_text)],
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let output = kc_with_env(
        &["config", "set", "search.paths", extra_text],
        &[("HOME", home_text)],
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let output = kc_with_env(&["info", "archive"], &[("HOME", home_text)]);
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn setting_search_paths_replaces_the_complete_list() {
    let home = TempDir::new("search-list-home");
    let home_text = home.path().to_str().unwrap();

    let output = kc_with_env(
        &["config", "set", "search.paths", "path1", "path2"],
        &[("HOME", home_text)],
    );
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let text = std::fs::read_to_string(home.join(".config/keychain.kdl")).unwrap();
    assert!(text.contains("search-path \"path1\""));
    assert!(text.contains("search-path \"path2\""));
}

#[test]
fn search_paths_can_be_appended_and_prepended() {
    let home = TempDir::new("search-order-home");
    let home_text = home.path().to_str().unwrap();
    for args in [
        &["config", "set", "search.paths", "middle"][..],
        &["config", "append", "search.paths", "last"][..],
        &["config", "prepend", "search.paths", "first"][..],
    ] {
        let output = kc_with_env(args, &[("HOME", home_text)]);
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    let text = std::fs::read_to_string(home.join(".config/keychain.kdl")).unwrap();
    let first = text.find("search-path \"first\"").unwrap();
    let middle = text.find("search-path \"middle\"").unwrap();
    let last = text.find("search-path \"last\"").unwrap();
    assert!(first < middle && middle < last);
}

#[test]
fn keychain_access_policy_can_be_saved_projected_and_audited() {
    let home = TempDir::new("access-policy-home");
    let home_text = home.path().to_str().unwrap();
    let keychain = home.join("policy.keychain");
    let keychain_text = keychain.to_str().unwrap();
    let requirement_file = home.join("security.req");
    std::fs::write(
        &requirement_file,
        designated_requirement("/usr/bin/security"),
    )
    .unwrap();
    let requirement = format!("/usr/bin/security={}", requirement_file.to_str().unwrap());

    for args in [
        vec!["create", "-p", "pw", keychain_text],
        vec![
            "add",
            "generic",
            "-p",
            "pw",
            "-A",
            "alice",
            "-S",
            "svc",
            "-w",
            "secret",
            keychain_text,
        ],
        vec![
            "access",
            "set",
            "--mode",
            "hybrid",
            "--default",
            "prompt",
            "--trust-requirement",
            &requirement,
            keychain_text,
        ],
    ] {
        let output = kc_with_env(&args, &[("HOME", home_text)]);
        assert!(
            output.status.success(),
            "{args:?}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let before = kc_with_env(&["access", "audit", keychain_text], &[("HOME", home_text)]);
    assert!(!before.status.success(), "an unprojected ACL matched");

    let applied = kc_with_env(
        &["access", "apply", "-p", "pw", keychain_text],
        &[("HOME", home_text)],
    );
    assert!(
        applied.status.success(),
        "{}",
        String::from_utf8_lossy(&applied.stderr)
    );
    let audited = kc_with_env(&["access", "audit", keychain_text], &[("HOME", home_text)]);
    assert!(
        audited.status.success(),
        "{}",
        String::from_utf8_lossy(&audited.stderr)
    );

    let inherited = kc_with_env(
        &[
            "add",
            "generic",
            "-p",
            "pw",
            "-A",
            "bob",
            "-S",
            "other",
            "-w",
            "second",
            keychain_text,
        ],
        &[("HOME", home_text)],
    );
    assert!(
        inherited.status.success(),
        "{}",
        String::from_utf8_lossy(&inherited.stderr)
    );
    let audited = kc_with_env(&["access", "audit", keychain_text], &[("HOME", home_text)]);
    assert!(
        audited.status.success(),
        "a new item did not inherit policy: {}",
        String::from_utf8_lossy(&audited.stderr)
    );

    let denied = kc_with_env(
        &[
            "find",
            "generic",
            "-p",
            "pw",
            "-A",
            "alice",
            "-w",
            keychain_text,
        ],
        &[("HOME", home_text)],
    );
    assert!(!denied.status.success());
    assert!(
        String::from_utf8_lossy(&denied.stderr).contains("rerun with --interactive"),
        "{}",
        String::from_utf8_lossy(&denied.stderr)
    );
}