hashtree-cli 0.2.44

Hashtree daemon and CLI - content-addressed storage with P2P sync
Documentation
use anyhow::Result;
use nostr::nips::nip19::FromBech32;
use nostr::PublicKey;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

use crate::Config;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IdentityBootstrapOutcome {
    pub contacts_seeded: bool,
    pub aliases_seeded: bool,
}

pub fn seed_identity_defaults(
    data_dir: &Path,
    config: &Config,
) -> Result<IdentityBootstrapOutcome> {
    let contacts_seeded = seed_bootstrap_contacts(data_dir, &config.nostr.bootstrap_follows)?;
    let aliases_seeded = seed_default_alias()?;
    Ok(IdentityBootstrapOutcome {
        contacts_seeded,
        aliases_seeded,
    })
}

fn seed_bootstrap_contacts(data_dir: &Path, bootstrap_follows: &[String]) -> Result<bool> {
    let contacts_path = data_dir.join("contacts.json");
    if contacts_path.exists() {
        return Ok(false);
    }

    let contacts = bootstrap_follow_hexes(bootstrap_follows);
    if contacts.is_empty() {
        return Ok(false);
    }

    if let Some(parent) = contacts_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&contacts_path, serde_json::to_string_pretty(&contacts)?)?;
    Ok(true)
}

fn bootstrap_follow_hexes(bootstrap_follows: &[String]) -> Vec<String> {
    bootstrap_follows
        .iter()
        .filter_map(|npub| PublicKey::from_bech32(npub).ok())
        .map(|pubkey| pubkey.to_hex())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn seed_default_alias() -> Result<bool> {
    let aliases_path = hashtree_config::get_aliases_path();
    let Some(parent) = aliases_path.parent() else {
        return Ok(false);
    };
    fs::create_dir_all(parent)?;

    let existing = if aliases_path.exists() {
        let content = fs::read_to_string(&aliases_path)?;
        hashtree_config::parse_keys_file(&content)
    } else {
        Vec::new()
    };
    if existing.iter().any(|entry| {
        entry.secret == hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB
            || entry.alias.as_deref() == Some(hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS)
    }) {
        return Ok(false);
    }

    let line = format!(
        "{} {}\n",
        hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB,
        hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS
    );

    if aliases_path.exists() {
        use std::io::Write;

        let mut file = fs::OpenOptions::new().append(true).open(&aliases_path)?;
        let needs_newline = fs::metadata(&aliases_path)?.len() > 0;
        if needs_newline {
            file.write_all(b"\n")?;
        }
        file.write_all(line.as_bytes())?;
    } else {
        let content = format!(
            "# Public read-only aliases for repos you clone or fetch.\n# Format: npub1... alias\n{line}"
        );
        fs::write(&aliases_path, content)?;
    }

    Ok(true)
}

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

    #[test]
    fn seed_identity_defaults_creates_contacts_and_aliases() {
        let _lock = crate::test_support::test_env_lock()
            .lock()
            .unwrap_or_else(|err| err.into_inner());
        let temp = TempDir::new().expect("temp dir");
        let _guard = crate::test_support::EnvVarGuard::set("HTREE_CONFIG_DIR", temp.path());

        let mut config = Config::default();
        config.storage.data_dir = temp.path().join("data").to_string_lossy().to_string();

        let outcome = seed_identity_defaults(Path::new(&config.storage.data_dir), &config)
            .expect("seed identity defaults");
        assert!(outcome.contacts_seeded);
        assert!(outcome.aliases_seeded);

        let contacts: Vec<String> = serde_json::from_str(
            &fs::read_to_string(temp.path().join("data/contacts.json")).expect("read contacts"),
        )
        .expect("parse contacts");
        assert_eq!(contacts.len(), 1);
        assert_eq!(
            contacts[0],
            PublicKey::from_bech32(hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB)
                .expect("parse default npub")
                .to_hex()
        );

        let aliases = fs::read_to_string(temp.path().join("aliases")).expect("read aliases");
        assert!(aliases.contains(&format!(
            "{} {}",
            hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB,
            hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS
        )));
        assert!(!aliases
            .contains("# npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm sirius"));
    }

    #[test]
    fn seed_identity_defaults_respects_existing_contacts_and_opt_out() {
        let _lock = crate::test_support::test_env_lock()
            .lock()
            .unwrap_or_else(|err| err.into_inner());
        let temp = TempDir::new().expect("temp dir");
        let _guard = crate::test_support::EnvVarGuard::set("HTREE_CONFIG_DIR", temp.path());

        let data_dir = temp.path().join("data");
        fs::create_dir_all(&data_dir).expect("create data dir");
        let existing_hex = "11".repeat(32);
        fs::write(
            data_dir.join("contacts.json"),
            serde_json::to_string_pretty(&vec![existing_hex.clone()]).expect("encode contacts"),
        )
        .expect("write contacts");

        let mut config = Config::default();
        config.storage.data_dir = data_dir.to_string_lossy().to_string();
        config.nostr.bootstrap_follows.clear();

        let outcome =
            seed_identity_defaults(Path::new(&config.storage.data_dir), &config).expect("seed");
        assert!(!outcome.contacts_seeded);
        assert!(outcome.aliases_seeded);

        let contacts: Vec<String> = serde_json::from_str(
            &fs::read_to_string(data_dir.join("contacts.json")).expect("read contacts"),
        )
        .expect("parse contacts");
        assert_eq!(contacts, vec![existing_hex]);
    }
}