hashtree_cli/
bootstrap.rs1use 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 tempfile::TempDir;
105
106 #[test]
107 fn seed_identity_defaults_creates_contacts_and_aliases() {
108 let _lock = crate::test_support::test_env_lock()
109 .lock()
110 .unwrap_or_else(|err| err.into_inner());
111 let temp = TempDir::new().expect("temp dir");
112 let _guard = crate::test_support::EnvVarGuard::set("HTREE_CONFIG_DIR", temp.path());
113
114 let mut config = Config::default();
115 config.storage.data_dir = temp.path().join("data").to_string_lossy().to_string();
116
117 let outcome = seed_identity_defaults(Path::new(&config.storage.data_dir), &config)
118 .expect("seed identity defaults");
119 assert!(outcome.contacts_seeded);
120 assert!(outcome.aliases_seeded);
121
122 let contacts: Vec<String> = serde_json::from_str(
123 &fs::read_to_string(temp.path().join("data/contacts.json")).expect("read contacts"),
124 )
125 .expect("parse contacts");
126 assert_eq!(contacts.len(), 1);
127 assert_eq!(
128 contacts[0],
129 PublicKey::from_bech32(hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB)
130 .expect("parse default npub")
131 .to_hex()
132 );
133
134 let aliases = fs::read_to_string(temp.path().join("aliases")).expect("read aliases");
135 assert!(aliases.contains(&format!(
136 "{} {}",
137 hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB,
138 hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS
139 )));
140 assert!(!aliases
141 .contains("# npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm sirius"));
142 }
143
144 #[test]
145 fn seed_identity_defaults_respects_existing_contacts_and_opt_out() {
146 let _lock = crate::test_support::test_env_lock()
147 .lock()
148 .unwrap_or_else(|err| err.into_inner());
149 let temp = TempDir::new().expect("temp dir");
150 let _guard = crate::test_support::EnvVarGuard::set("HTREE_CONFIG_DIR", temp.path());
151
152 let data_dir = temp.path().join("data");
153 fs::create_dir_all(&data_dir).expect("create data dir");
154 let existing_hex = "11".repeat(32);
155 fs::write(
156 data_dir.join("contacts.json"),
157 serde_json::to_string_pretty(&vec![existing_hex.clone()]).expect("encode contacts"),
158 )
159 .expect("write contacts");
160
161 let mut config = Config::default();
162 config.storage.data_dir = data_dir.to_string_lossy().to_string();
163 config.nostr.bootstrap_follows.clear();
164
165 let outcome =
166 seed_identity_defaults(Path::new(&config.storage.data_dir), &config).expect("seed");
167 assert!(!outcome.contacts_seeded);
168 assert!(outcome.aliases_seeded);
169
170 let contacts: Vec<String> = serde_json::from_str(
171 &fs::read_to_string(data_dir.join("contacts.json")).expect("read contacts"),
172 )
173 .expect("parse contacts");
174 assert_eq!(contacts, vec![existing_hex]);
175 }
176}