Skip to main content

hashtree_cli/
bootstrap.rs

1use anyhow::Result;
2use nostr::nips::nip19::FromBech32;
3use nostr::PublicKey;
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::Path;
7
8use crate::Config;
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub struct IdentityBootstrapOutcome {
12    pub contacts_seeded: bool,
13    pub aliases_seeded: bool,
14}
15
16pub fn seed_identity_defaults(
17    data_dir: &Path,
18    config: &Config,
19) -> Result<IdentityBootstrapOutcome> {
20    let contacts_seeded = seed_bootstrap_contacts(data_dir, &config.nostr.bootstrap_follows)?;
21    let aliases_seeded = seed_default_alias()?;
22    Ok(IdentityBootstrapOutcome {
23        contacts_seeded,
24        aliases_seeded,
25    })
26}
27
28fn seed_bootstrap_contacts(data_dir: &Path, bootstrap_follows: &[String]) -> Result<bool> {
29    let contacts_path = data_dir.join("contacts.json");
30    if contacts_path.exists() {
31        return Ok(false);
32    }
33
34    let contacts = bootstrap_follow_hexes(bootstrap_follows);
35    if contacts.is_empty() {
36        return Ok(false);
37    }
38
39    if let Some(parent) = contacts_path.parent() {
40        fs::create_dir_all(parent)?;
41    }
42    fs::write(&contacts_path, serde_json::to_string_pretty(&contacts)?)?;
43    Ok(true)
44}
45
46fn bootstrap_follow_hexes(bootstrap_follows: &[String]) -> Vec<String> {
47    bootstrap_follows
48        .iter()
49        .filter_map(|npub| PublicKey::from_bech32(npub).ok())
50        .map(|pubkey| pubkey.to_hex())
51        .collect::<BTreeSet<_>>()
52        .into_iter()
53        .collect()
54}
55
56fn seed_default_alias() -> Result<bool> {
57    let aliases_path = hashtree_config::get_aliases_path();
58    let Some(parent) = aliases_path.parent() else {
59        return Ok(false);
60    };
61    fs::create_dir_all(parent)?;
62
63    let existing = if aliases_path.exists() {
64        let content = fs::read_to_string(&aliases_path)?;
65        hashtree_config::parse_keys_file(&content)
66    } else {
67        Vec::new()
68    };
69    if existing.iter().any(|entry| {
70        entry.secret == hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB
71            || entry.alias.as_deref() == Some(hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS)
72    }) {
73        return Ok(false);
74    }
75
76    let line = format!(
77        "{} {}\n",
78        hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB,
79        hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS
80    );
81
82    if aliases_path.exists() {
83        use std::io::Write;
84
85        let mut file = fs::OpenOptions::new().append(true).open(&aliases_path)?;
86        let needs_newline = fs::metadata(&aliases_path)?.len() > 0;
87        if needs_newline {
88            file.write_all(b"\n")?;
89        }
90        file.write_all(line.as_bytes())?;
91    } else {
92        let content = format!(
93            "# Public read-only aliases for repos you clone or fetch.\n# Format: npub1... alias\n{line}"
94        );
95        fs::write(&aliases_path, content)?;
96    }
97
98    Ok(true)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::sync::{Mutex, OnceLock};
105    use tempfile::TempDir;
106
107    static CONFIG_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
108
109    struct ConfigDirGuard {
110        previous: Option<String>,
111    }
112
113    impl ConfigDirGuard {
114        fn set(path: &Path) -> Self {
115            let previous = std::env::var("HTREE_CONFIG_DIR").ok();
116            std::env::set_var("HTREE_CONFIG_DIR", path);
117            Self { previous }
118        }
119    }
120
121    impl Drop for ConfigDirGuard {
122        fn drop(&mut self) {
123            if let Some(previous) = self.previous.as_deref() {
124                std::env::set_var("HTREE_CONFIG_DIR", previous);
125            } else {
126                std::env::remove_var("HTREE_CONFIG_DIR");
127            }
128        }
129    }
130
131    #[test]
132    fn seed_identity_defaults_creates_contacts_and_aliases() {
133        let _lock = CONFIG_ENV_LOCK
134            .get_or_init(|| Mutex::new(()))
135            .lock()
136            .unwrap();
137        let temp = TempDir::new().expect("temp dir");
138        let _guard = ConfigDirGuard::set(temp.path());
139
140        let mut config = Config::default();
141        config.storage.data_dir = temp.path().join("data").to_string_lossy().to_string();
142
143        let outcome = seed_identity_defaults(Path::new(&config.storage.data_dir), &config)
144            .expect("seed identity defaults");
145        assert!(outcome.contacts_seeded);
146        assert!(outcome.aliases_seeded);
147
148        let contacts: Vec<String> = serde_json::from_str(
149            &fs::read_to_string(temp.path().join("data/contacts.json")).expect("read contacts"),
150        )
151        .expect("parse contacts");
152        assert_eq!(contacts.len(), 1);
153        assert_eq!(
154            contacts[0],
155            PublicKey::from_bech32(hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB)
156                .expect("parse default npub")
157                .to_hex()
158        );
159
160        let aliases = fs::read_to_string(temp.path().join("aliases")).expect("read aliases");
161        assert!(aliases.contains(&format!(
162            "{} {}",
163            hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB,
164            hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS
165        )));
166        assert!(!aliases
167            .contains("# npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm sirius"));
168    }
169
170    #[test]
171    fn seed_identity_defaults_respects_existing_contacts_and_opt_out() {
172        let _lock = CONFIG_ENV_LOCK
173            .get_or_init(|| Mutex::new(()))
174            .lock()
175            .unwrap();
176        let temp = TempDir::new().expect("temp dir");
177        let _guard = ConfigDirGuard::set(temp.path());
178
179        let data_dir = temp.path().join("data");
180        fs::create_dir_all(&data_dir).expect("create data dir");
181        let existing_hex = "11".repeat(32);
182        fs::write(
183            data_dir.join("contacts.json"),
184            serde_json::to_string_pretty(&vec![existing_hex.clone()]).expect("encode contacts"),
185        )
186        .expect("write contacts");
187
188        let mut config = Config::default();
189        config.storage.data_dir = data_dir.to_string_lossy().to_string();
190        config.nostr.bootstrap_follows.clear();
191
192        let outcome =
193            seed_identity_defaults(Path::new(&config.storage.data_dir), &config).expect("seed");
194        assert!(!outcome.contacts_seeded);
195        assert!(outcome.aliases_seeded);
196
197        let contacts: Vec<String> = serde_json::from_str(
198            &fs::read_to_string(data_dir.join("contacts.json")).expect("read contacts"),
199        )
200        .expect("parse contacts");
201        assert_eq!(contacts, vec![existing_hex]);
202    }
203}