1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10pub const DEFAULT_READ_SERVERS: &[&str] = &["https://cdn.iris.to", "https://blossom.primal.net"];
12
13pub const DEFAULT_WRITE_SERVERS: &[&str] = &["https://upload.iris.to"];
15
16pub const DEFAULT_RELAYS: &[&str] = &[
18 "wss://temp.iris.to",
19 "wss://nos.lol",
20 "wss://relay.snort.social",
21];
22
23pub const DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB: &str =
25 "npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm";
26
27pub const DEFAULT_SOCIALGRAPH_ENTRYPOINT_ALIAS: &str = "siriusbusiness";
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct Config {
33 #[serde(default)]
34 pub server: ServerConfig,
35 #[serde(default)]
36 pub storage: StorageConfig,
37 #[serde(default)]
38 pub nostr: NostrConfig,
39 #[serde(default)]
40 pub blossom: BlossomConfig,
41 #[serde(default)]
42 pub sync: SyncConfig,
43 #[serde(default)]
44 pub updater: UpdaterConfig,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ServerConfig {
50 #[serde(default = "default_bind_address")]
51 pub bind_address: String,
52 #[serde(default = "default_true")]
53 pub enable_auth: bool,
54 #[serde(default)]
55 pub public_writes: bool,
56 #[serde(default)]
57 pub enable_webrtc: bool,
58 #[serde(default)]
59 pub stun_port: u16,
60}
61
62impl Default for ServerConfig {
63 fn default() -> Self {
64 Self {
65 bind_address: default_bind_address(),
66 enable_auth: true,
67 public_writes: false,
68 enable_webrtc: false,
69 stun_port: 0,
70 }
71 }
72}
73
74fn default_bind_address() -> String {
75 "127.0.0.1:8080".to_string()
76}
77
78fn default_true() -> bool {
79 true
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum StorageBackend {
86 #[default]
88 Lmdb,
89 Fs,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct StorageConfig {
96 #[serde(default)]
98 pub backend: StorageBackend,
99 #[serde(default = "default_data_dir")]
100 pub data_dir: String,
101 #[serde(default = "default_max_size_gb")]
102 pub max_size_gb: u64,
103 #[serde(default = "default_storage_evict_orphans")]
104 pub evict_orphans: bool,
105 #[serde(default)]
106 pub s3: Option<S3Config>,
107}
108
109impl Default for StorageConfig {
110 fn default() -> Self {
111 Self {
112 backend: StorageBackend::default(),
113 data_dir: default_data_dir(),
114 max_size_gb: default_max_size_gb(),
115 evict_orphans: default_storage_evict_orphans(),
116 s3: None,
117 }
118 }
119}
120
121fn default_data_dir() -> String {
122 get_hashtree_dir()
123 .join("data")
124 .to_string_lossy()
125 .to_string()
126}
127
128fn default_max_size_gb() -> u64 {
129 10
130}
131
132fn default_storage_evict_orphans() -> bool {
133 true
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct S3Config {
139 pub endpoint: String,
140 pub bucket: String,
141 pub region: String,
142 #[serde(default)]
143 pub prefix: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct NostrConfig {
149 #[serde(default = "default_relays")]
150 pub relays: Vec<String>,
151 #[serde(default)]
152 pub allowed_npubs: Vec<String>,
153 #[serde(default)]
154 pub socialgraph_root: Option<String>,
155 #[serde(default = "default_nostr_bootstrap_follows")]
156 pub bootstrap_follows: Vec<String>,
157 #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
158 pub social_graph_crawl_depth: u32,
159 #[serde(default)]
162 pub mirror_max_follow_distance: Option<u32>,
163 #[serde(default = "default_max_write_distance")]
164 pub max_write_distance: u32,
165 #[serde(default = "default_nostr_db_max_size_gb")]
167 pub db_max_size_gb: u64,
168 #[serde(default = "default_nostr_spambox_max_size_gb")]
171 pub spambox_max_size_gb: u64,
172 #[serde(default)]
174 pub negentropy_only: bool,
175 #[serde(default = "default_nostr_overmute_threshold")]
177 pub overmute_threshold: f64,
178 #[serde(default = "default_nostr_mirror_kinds")]
180 pub mirror_kinds: Vec<u16>,
181 #[serde(default = "default_nostr_history_sync_author_chunk_size")]
183 pub history_sync_author_chunk_size: usize,
184 #[serde(default = "default_nostr_history_sync_on_reconnect")]
186 pub history_sync_on_reconnect: bool,
187}
188
189impl Default for NostrConfig {
190 fn default() -> Self {
191 Self {
192 relays: default_relays(),
193 allowed_npubs: vec![],
194 socialgraph_root: None,
195 bootstrap_follows: default_nostr_bootstrap_follows(),
196 social_graph_crawl_depth: default_social_graph_crawl_depth(),
197 mirror_max_follow_distance: None,
198 max_write_distance: default_max_write_distance(),
199 db_max_size_gb: default_nostr_db_max_size_gb(),
200 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
201 negentropy_only: false,
202 overmute_threshold: default_nostr_overmute_threshold(),
203 mirror_kinds: default_nostr_mirror_kinds(),
204 history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
205 history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
206 }
207 }
208}
209
210fn default_social_graph_crawl_depth() -> u32 {
211 2
212}
213
214fn default_nostr_bootstrap_follows() -> Vec<String> {
215 vec![DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
216}
217
218fn default_nostr_overmute_threshold() -> f64 {
219 1.0
220}
221
222fn default_max_write_distance() -> u32 {
223 3
224}
225
226fn default_nostr_db_max_size_gb() -> u64 {
227 10
228}
229
230fn default_nostr_spambox_max_size_gb() -> u64 {
231 1
232}
233
234fn default_nostr_history_sync_on_reconnect() -> bool {
235 true
236}
237
238fn default_nostr_mirror_kinds() -> Vec<u16> {
239 vec![0, 1, 3, 6, 7, 9_735, 30_023]
240}
241
242fn default_nostr_history_sync_author_chunk_size() -> usize {
243 5_000
244}
245
246fn default_relays() -> Vec<String> {
247 DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct BlossomConfig {
253 #[serde(default)]
255 pub servers: Vec<String>,
256 #[serde(default = "default_read_servers")]
258 pub read_servers: Vec<String>,
259 #[serde(default = "default_write_servers")]
261 pub write_servers: Vec<String>,
262 #[serde(default = "default_max_upload_mb")]
264 pub max_upload_mb: u64,
265 #[serde(default = "default_require_random_untrusted_ingest")]
267 pub require_random_untrusted_ingest: bool,
268 #[serde(default = "default_upload_concurrency")]
270 pub upload_concurrency: usize,
271 #[serde(default)]
273 pub force_upload: bool,
274}
275
276impl Default for BlossomConfig {
277 fn default() -> Self {
278 Self {
279 servers: vec![],
280 read_servers: default_read_servers(),
281 write_servers: default_write_servers(),
282 max_upload_mb: default_max_upload_mb(),
283 require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
284 upload_concurrency: default_upload_concurrency(),
285 force_upload: false,
286 }
287 }
288}
289
290fn default_read_servers() -> Vec<String> {
291 let mut servers: Vec<String> = DEFAULT_READ_SERVERS.iter().map(|s| s.to_string()).collect();
292 servers.sort();
293 servers
294}
295
296fn default_write_servers() -> Vec<String> {
297 DEFAULT_WRITE_SERVERS
298 .iter()
299 .map(|s| s.to_string())
300 .collect()
301}
302
303fn default_max_upload_mb() -> u64 {
304 100
305}
306
307fn default_require_random_untrusted_ingest() -> bool {
308 true
309}
310
311fn default_upload_concurrency() -> usize {
312 4
313}
314
315impl BlossomConfig {
316 pub fn all_read_servers(&self) -> Vec<String> {
320 let mut servers = self.servers.clone();
321 servers.extend(self.read_servers.clone());
322 servers.extend(self.write_servers.clone());
323 if servers.is_empty() {
324 servers = default_read_servers();
325 servers.extend(default_write_servers());
326 }
327 servers.sort();
328 servers.dedup();
329 servers
330 }
331
332 pub fn all_write_servers(&self) -> Vec<String> {
334 let mut servers = self.servers.clone();
335 servers.extend(self.write_servers.clone());
336 if servers.is_empty() {
337 servers = default_write_servers();
338 }
339 servers.sort();
340 servers.dedup();
341 servers
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct SyncConfig {
348 #[serde(default)]
349 pub enabled: bool,
350 #[serde(default = "default_true")]
351 pub sync_own: bool,
352 #[serde(default)]
353 pub sync_followed: bool,
354 #[serde(default = "default_max_concurrent")]
355 pub max_concurrent: usize,
356 #[serde(default = "default_webrtc_timeout_ms")]
357 pub webrtc_timeout_ms: u64,
358 #[serde(default = "default_blossom_timeout_ms")]
359 pub blossom_timeout_ms: u64,
360}
361
362impl Default for SyncConfig {
363 fn default() -> Self {
364 Self {
365 enabled: false,
366 sync_own: true,
367 sync_followed: false,
368 max_concurrent: default_max_concurrent(),
369 webrtc_timeout_ms: default_webrtc_timeout_ms(),
370 blossom_timeout_ms: default_blossom_timeout_ms(),
371 }
372 }
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct UpdaterConfig {
383 #[serde(default = "default_true")]
384 pub auto_check: bool,
385 #[serde(default)]
386 pub auto_install: bool,
387 #[serde(default = "default_check_interval_hours")]
388 pub check_interval_hours: u32,
389}
390
391impl Default for UpdaterConfig {
392 fn default() -> Self {
393 Self {
394 auto_check: true,
395 auto_install: false,
396 check_interval_hours: default_check_interval_hours(),
397 }
398 }
399}
400
401fn default_check_interval_hours() -> u32 {
402 24
403}
404
405fn default_max_concurrent() -> usize {
406 4
407}
408
409fn default_webrtc_timeout_ms() -> u64 {
410 5000
411}
412
413fn default_blossom_timeout_ms() -> u64 {
414 10000
415}
416
417impl Config {
418 pub fn load() -> Result<Self> {
420 let config_path = get_config_path();
421
422 if config_path.exists() {
423 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
424 toml::from_str(&content).context("Failed to parse config file")
425 } else {
426 let config = Config::default();
427 config.save()?;
428 Ok(config)
429 }
430 }
431
432 pub fn load_or_default() -> Self {
434 Self::load().unwrap_or_default()
435 }
436
437 pub fn save(&self) -> Result<()> {
439 let config_path = get_config_path();
440
441 if let Some(parent) = config_path.parent() {
442 fs::create_dir_all(parent)?;
443 }
444
445 let content = toml::to_string_pretty(self)?;
446 fs::write(&config_path, content)?;
447
448 Ok(())
449 }
450}
451
452pub fn get_hashtree_dir() -> PathBuf {
454 if let Ok(dir) = std::env::var("HTREE_CONFIG_DIR") {
455 return PathBuf::from(dir);
456 }
457 dirs::home_dir()
458 .unwrap_or_else(|| PathBuf::from("."))
459 .join(".hashtree")
460}
461
462pub fn get_config_path() -> PathBuf {
464 get_hashtree_dir().join("config.toml")
465}
466
467pub fn get_keys_path() -> PathBuf {
469 get_hashtree_dir().join("keys")
470}
471
472pub fn get_aliases_path() -> PathBuf {
474 get_hashtree_dir().join("aliases")
475}
476
477#[derive(Debug, Clone)]
479pub struct KeyEntry {
480 pub secret: String,
482 pub alias: Option<String>,
484}
485
486pub fn parse_keys_file(content: &str) -> Vec<KeyEntry> {
490 let mut entries = Vec::new();
491 for line in content.lines() {
492 let line = line.trim();
493 if line.is_empty() || line.starts_with('#') {
494 continue;
495 }
496 let parts: Vec<&str> = line.splitn(2, ' ').collect();
497 let secret = parts[0].to_string();
498 let alias = parts.get(1).map(|s| s.trim().to_string());
499 entries.push(KeyEntry { secret, alias });
500 }
501 entries
502}
503
504pub fn read_first_key() -> Option<String> {
507 let keys_path = get_keys_path();
508 let content = std::fs::read_to_string(&keys_path).ok()?;
509 let entries = parse_keys_file(&content);
510 entries.into_iter().next().map(|e| e.secret)
511}
512
513pub fn get_auth_cookie_path() -> PathBuf {
515 get_hashtree_dir().join("auth.cookie")
516}
517
518pub fn get_data_dir() -> PathBuf {
521 if let Ok(dir) = std::env::var("HTREE_DATA_DIR") {
522 return PathBuf::from(dir);
523 }
524 let config = Config::load_or_default();
525 PathBuf::from(&config.storage.data_dir)
526}
527
528pub fn detect_local_daemon_url(bind_address: Option<&str>) -> Option<String> {
530 use std::net::{SocketAddr, TcpStream};
531 use std::time::Duration;
532
533 if !prefer_local_daemon() {
534 return None;
535 }
536
537 let port = local_daemon_port(bind_address);
538 if port == 0 {
539 return None;
540 }
541
542 let addr = SocketAddr::from(([127, 0, 0, 1], port));
543 let timeout = Duration::from_millis(100);
544 TcpStream::connect_timeout(&addr, timeout).ok()?;
545 Some(format!("http://127.0.0.1:{}", port))
546}
547
548pub fn detect_local_relay_urls(bind_address: Option<&str>) -> Vec<String> {
550 let mut relays = Vec::new();
551
552 if let Some(list) =
553 parse_env_list("NOSTR_LOCAL_RELAY").or_else(|| parse_env_list("HTREE_LOCAL_RELAY"))
554 {
555 for raw in list {
556 if let Some(url) = normalize_relay_url(&raw) {
557 relays.push(url);
558 }
559 }
560 }
561
562 if let Some(base) = detect_local_daemon_url(bind_address) {
563 if let Some(ws) = normalize_relay_url(&base) {
564 let ws = ws.trim_end_matches('/');
565 let ws = if ws.contains("/ws") {
566 ws.to_string()
567 } else {
568 format!("{}/ws", ws)
569 };
570 relays.push(ws);
571 }
572 }
573
574 let mut ports = parse_env_ports("NOSTR_LOCAL_RELAY_PORTS");
575 if ports.is_empty() {
576 ports.push(4869);
577 }
578
579 let daemon_port = local_daemon_port(bind_address);
580 for port in ports {
581 if port == 0 || port == daemon_port {
582 continue;
583 }
584 if local_port_open(port) {
585 relays.push(format!("ws://127.0.0.1:{port}"));
586 }
587 }
588
589 dedupe_relays(relays)
590}
591
592pub fn resolve_relays(config_relays: &[String], bind_address: Option<&str>) -> Vec<String> {
594 let mut base = match parse_env_list("NOSTR_RELAYS") {
595 Some(list) => list,
596 None => config_relays.to_vec(),
597 };
598
599 base = base
600 .into_iter()
601 .filter_map(|r| normalize_relay_url(&r))
602 .collect();
603
604 if !prefer_local_relay() {
605 return dedupe_relays(base);
606 }
607
608 let mut combined = detect_local_relay_urls(bind_address);
609 combined.extend(base);
610 dedupe_relays(combined)
611}
612
613fn local_daemon_port(bind_address: Option<&str>) -> u16 {
614 let default_port = 8080;
615 let Some(addr) = bind_address else {
616 return default_port;
617 };
618 if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
619 return sock.port();
620 }
621 if let Some((_, port_str)) = addr.rsplit_once(':') {
622 if let Ok(port) = port_str.parse::<u16>() {
623 return port;
624 }
625 }
626 default_port
627}
628
629fn prefer_local_relay() -> bool {
630 for key in ["NOSTR_PREFER_LOCAL", "HTREE_PREFER_LOCAL_RELAY"] {
631 if let Ok(val) = std::env::var(key) {
632 let val = val.trim().to_lowercase();
633 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
634 }
635 }
636 true
637}
638
639fn prefer_local_daemon() -> bool {
640 for key in [
641 "HTREE_LOCAL_DAEMON_ONLY",
642 "HTREE_PREFER_LOCAL_DAEMON",
643 "NOSTR_PREFER_LOCAL",
644 "HTREE_PREFER_LOCAL_RELAY",
645 ] {
646 if let Ok(val) = std::env::var(key) {
647 let val = val.trim().to_lowercase();
648 return !matches!(val.as_str(), "0" | "false" | "no" | "off");
649 }
650 }
651 false
652}
653
654fn parse_env_list(var: &str) -> Option<Vec<String>> {
655 let value = std::env::var(var).ok()?;
656 let mut items = Vec::new();
657 for part in value.split([',', ';', '\n', '\t', ' ']) {
658 let trimmed = part.trim();
659 if !trimmed.is_empty() {
660 items.push(trimmed.to_string());
661 }
662 }
663 if items.is_empty() {
664 None
665 } else {
666 Some(items)
667 }
668}
669
670fn parse_env_ports(var: &str) -> Vec<u16> {
671 let Some(list) = parse_env_list(var) else {
672 return Vec::new();
673 };
674 list.into_iter()
675 .filter_map(|item| item.parse::<u16>().ok())
676 .collect()
677}
678
679fn normalize_relay_url(raw: &str) -> Option<String> {
680 let trimmed = raw.trim();
681 if trimmed.is_empty() {
682 return None;
683 }
684 let trimmed = trimmed.trim_end_matches('/');
685 let lower = trimmed.to_lowercase();
686 if lower.starts_with("ws://") || lower.starts_with("wss://") {
687 return Some(trimmed.to_string());
688 }
689 if lower.starts_with("http://") {
690 return Some(format!("ws://{}", &trimmed[7..]));
691 }
692 if lower.starts_with("https://") {
693 return Some(format!("wss://{}", &trimmed[8..]));
694 }
695 Some(format!("ws://{}", trimmed))
696}
697
698fn local_port_open(port: u16) -> bool {
699 use std::net::{SocketAddr, TcpStream};
700 use std::time::Duration;
701
702 let addr = SocketAddr::from(([127, 0, 0, 1], port));
703 let timeout = Duration::from_millis(100);
704 TcpStream::connect_timeout(&addr, timeout).is_ok()
705}
706
707fn dedupe_relays(relays: Vec<String>) -> Vec<String> {
708 use std::collections::HashSet;
709 let mut seen = HashSet::new();
710 let mut out = Vec::new();
711 for relay in relays {
712 let key = relay.trim_end_matches('/').to_lowercase();
713 if seen.insert(key) {
714 out.push(relay);
715 }
716 }
717 out
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use std::net::TcpListener;
724 use std::sync::Mutex;
725
726 static ENV_LOCK: Mutex<()> = Mutex::new(());
727
728 struct EnvGuard {
729 key: &'static str,
730 prev: Option<String>,
731 }
732
733 impl EnvGuard {
734 fn set(key: &'static str, value: &str) -> Self {
735 let prev = std::env::var(key).ok();
736 std::env::set_var(key, value);
737 Self { key, prev }
738 }
739
740 fn clear(key: &'static str) -> Self {
741 let prev = std::env::var(key).ok();
742 std::env::remove_var(key);
743 Self { key, prev }
744 }
745 }
746
747 impl Drop for EnvGuard {
748 fn drop(&mut self) {
749 if let Some(prev) = &self.prev {
750 std::env::set_var(self.key, prev);
751 } else {
752 std::env::remove_var(self.key);
753 }
754 }
755 }
756
757 #[test]
758 fn test_default_config() {
759 let config = Config::default();
760 assert!(!config.blossom.read_servers.is_empty());
761 assert!(!config.blossom.write_servers.is_empty());
762 assert_eq!(
763 config.blossom.upload_concurrency,
764 default_upload_concurrency()
765 );
766 assert!(!config.nostr.relays.is_empty());
767 assert!(config
768 .nostr
769 .relays
770 .contains(&"wss://temp.iris.to".to_string()));
771 }
772
773 #[test]
774 fn test_parse_empty_config() {
775 let config: Config = toml::from_str("").unwrap();
776 assert!(!config.blossom.read_servers.is_empty());
777 }
778
779 #[test]
780 fn test_parse_partial_config() {
781 let toml = r#"
782[blossom]
783write_servers = ["https://custom.server"]
784upload_concurrency = 24
785"#;
786 let config: Config = toml::from_str(toml).unwrap();
787 assert_eq!(config.blossom.write_servers, vec!["https://custom.server"]);
788 assert_eq!(config.blossom.upload_concurrency, 24);
789 assert!(!config.blossom.read_servers.is_empty());
790 }
791
792 #[test]
793 fn test_all_servers() {
794 let config = BlossomConfig {
795 servers: vec!["https://legacy.server".to_string()],
796 ..BlossomConfig::default()
797 };
798
799 let read = config.all_read_servers();
800 assert!(read.contains(&"https://legacy.server".to_string()));
801 assert!(read.contains(&"https://cdn.iris.to".to_string()));
802 assert!(read.contains(&"https://blossom.primal.net".to_string()));
803 assert!(read.contains(&"https://upload.iris.to".to_string()));
804
805 let write = config.all_write_servers();
806 assert!(write.contains(&"https://legacy.server".to_string()));
807 assert!(write.contains(&"https://upload.iris.to".to_string()));
808 }
809
810 #[test]
811 fn test_all_servers_fall_back_to_defaults_when_explicitly_empty() {
812 let config = BlossomConfig {
813 servers: vec![],
814 read_servers: vec![],
815 write_servers: vec![],
816 max_upload_mb: default_max_upload_mb(),
817 require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
818 upload_concurrency: default_upload_concurrency(),
819 force_upload: false,
820 };
821
822 let mut expected_read = default_read_servers();
823 expected_read.extend(default_write_servers());
824 expected_read.sort();
825 expected_read.dedup();
826 assert_eq!(config.all_read_servers(), expected_read);
827 assert_eq!(config.all_write_servers(), default_write_servers());
828 }
829
830 #[test]
831 fn test_storage_backend_default() {
832 let config = Config::default();
833 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
834 }
835
836 #[test]
837 fn test_storage_backend_lmdb() {
838 let toml = r#"
839[storage]
840backend = "lmdb"
841"#;
842 let config: Config = toml::from_str(toml).unwrap();
843 assert_eq!(config.storage.backend, StorageBackend::Lmdb);
844 }
845
846 #[test]
847 fn test_storage_backend_fs_explicit() {
848 let toml = r#"
849[storage]
850backend = "fs"
851"#;
852 let config: Config = toml::from_str(toml).unwrap();
853 assert_eq!(config.storage.backend, StorageBackend::Fs);
854 }
855
856 #[test]
857 fn test_storage_orphan_eviction_defaults_on_and_allows_override() {
858 assert!(Config::default().storage.evict_orphans);
859
860 let toml = r#"
861[storage]
862evict_orphans = false
863"#;
864 let config: Config = toml::from_str(toml).unwrap();
865 assert!(!config.storage.evict_orphans);
866 }
867
868 #[test]
869 fn test_parse_keys_file() {
870 let content = r#"
871nsec1abc123 self
872# comment line
873nsec1def456 work
874
875nsec1ghi789
876"#;
877 let entries = parse_keys_file(content);
878 assert_eq!(entries.len(), 3);
879 assert_eq!(entries[0].secret, "nsec1abc123");
880 assert_eq!(entries[0].alias, Some("self".to_string()));
881 assert_eq!(entries[1].secret, "nsec1def456");
882 assert_eq!(entries[1].alias, Some("work".to_string()));
883 assert_eq!(entries[2].secret, "nsec1ghi789");
884 assert_eq!(entries[2].alias, None);
885 }
886
887 #[test]
888 fn test_local_daemon_port_default() {
889 assert_eq!(local_daemon_port(None), 8080);
890 }
891
892 #[test]
893 fn test_local_daemon_port_parses_ipv4() {
894 assert_eq!(local_daemon_port(Some("127.0.0.1:9090")), 9090);
895 }
896
897 #[test]
898 fn test_local_daemon_port_parses_anyhost() {
899 assert_eq!(local_daemon_port(Some("0.0.0.0:7070")), 7070);
900 }
901
902 #[test]
903 fn test_local_daemon_port_parses_ipv6() {
904 assert_eq!(local_daemon_port(Some("[::1]:6060")), 6060);
905 }
906
907 #[test]
908 fn test_local_daemon_port_parses_hostname() {
909 assert_eq!(local_daemon_port(Some("localhost:5050")), 5050);
910 }
911
912 #[test]
913 fn test_local_daemon_port_invalid() {
914 assert_eq!(local_daemon_port(Some("localhost")), 8080);
915 }
916
917 #[test]
918 fn test_detect_local_daemon_url_respects_prefer_local_flag() {
919 let _lock = ENV_LOCK.lock().unwrap();
920 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
921 let port = listener.local_addr().unwrap().port();
922 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
923
924 assert_eq!(
925 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
926 None
927 );
928 }
929
930 #[test]
931 fn test_detect_local_daemon_url_requires_opt_in() {
932 let _lock = ENV_LOCK.lock().unwrap();
933 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
934 let port = listener.local_addr().unwrap().port();
935 let _prefer = EnvGuard::clear("HTREE_PREFER_LOCAL_DAEMON");
936 let _prefer_nostr = EnvGuard::clear("NOSTR_PREFER_LOCAL");
937 let _prefer_relay = EnvGuard::clear("HTREE_PREFER_LOCAL_RELAY");
938
939 assert_eq!(
940 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
941 None
942 );
943 }
944
945 #[test]
946 fn test_detect_local_daemon_url_uses_opt_in_flag() {
947 let _lock = ENV_LOCK.lock().unwrap();
948 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
949 let port = listener.local_addr().unwrap().port();
950 let _prefer = EnvGuard::set("HTREE_PREFER_LOCAL_DAEMON", "1");
951
952 assert_eq!(
953 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
954 Some(format!("http://127.0.0.1:{port}"))
955 );
956 }
957
958 #[test]
959 fn test_strict_local_daemon_detection_uses_configured_bind_port() {
960 let _lock = ENV_LOCK.lock().unwrap();
961 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
962 let port = listener.local_addr().unwrap().port();
963 let _strict = EnvGuard::set("HTREE_LOCAL_DAEMON_ONLY", "1");
964 let _prefer = EnvGuard::clear("HTREE_PREFER_LOCAL_DAEMON");
965
966 assert_eq!(
967 detect_local_daemon_url(Some(&format!("127.0.0.1:{port}"))),
968 Some(format!("http://127.0.0.1:{port}"))
969 );
970 }
971
972 #[test]
973 fn test_resolve_relays_prefers_local() {
974 let _lock = ENV_LOCK.lock().unwrap();
975 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
976 let port = listener.local_addr().unwrap().port();
977
978 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "1");
979 let _ports = EnvGuard::set("NOSTR_LOCAL_RELAY_PORTS", &port.to_string());
980 let _relays = EnvGuard::clear("NOSTR_RELAYS");
981
982 let base = vec!["wss://relay.example".to_string()];
983 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
984
985 assert!(!resolved.is_empty());
986 assert_eq!(resolved[0], format!("ws://127.0.0.1:{port}"));
987 assert!(resolved.contains(&"wss://relay.example".to_string()));
988 }
989
990 #[test]
991 fn test_resolve_relays_env_override() {
992 let _lock = ENV_LOCK.lock().unwrap();
993 let _prefer = EnvGuard::set("NOSTR_PREFER_LOCAL", "0");
994 let _relays = EnvGuard::set("NOSTR_RELAYS", "wss://relay.one,wss://relay.two");
995
996 let base = vec!["wss://relay.example".to_string()];
997 let resolved = resolve_relays(&base, Some("127.0.0.1:0"));
998
999 assert_eq!(
1000 resolved,
1001 vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()]
1002 );
1003 }
1004
1005 #[test]
1006 fn test_nostr_config_defaults_include_mirror_settings() {
1007 let config = NostrConfig::default();
1008
1009 assert_eq!(config.social_graph_crawl_depth, 2);
1010 assert_eq!(config.mirror_max_follow_distance, None);
1011 assert_eq!(config.max_write_distance, 3);
1012 assert!(config.socialgraph_root.is_none());
1013 assert_eq!(
1014 config.bootstrap_follows,
1015 vec![DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1016 );
1017 assert!(!config.negentropy_only);
1018 assert_eq!(config.overmute_threshold, 1.0);
1019 assert_eq!(config.mirror_kinds, vec![0, 1, 3, 6, 7, 9_735, 30_023]);
1020 assert_eq!(config.history_sync_author_chunk_size, 5_000);
1021 assert!(config.history_sync_on_reconnect);
1022 }
1023
1024 #[test]
1025 fn test_nostr_config_deserializes_mirror_settings() {
1026 let config: NostrConfig = toml::from_str(
1027 r#"
1028relays = ["wss://relay.example"]
1029socialgraph_root = "npub1test"
1030bootstrap_follows = []
1031social_graph_crawl_depth = 6
1032mirror_max_follow_distance = 2
1033max_write_distance = 7
1034negentropy_only = true
1035overmute_threshold = 1.5
1036mirror_kinds = [0, 1, 3, 6, 7, 9735]
1037history_sync_author_chunk_size = 512
1038history_sync_on_reconnect = false
1039"#,
1040 )
1041 .expect("deserialize nostr config");
1042
1043 assert_eq!(config.relays, vec!["wss://relay.example".to_string()]);
1044 assert_eq!(config.socialgraph_root.as_deref(), Some("npub1test"));
1045 assert!(config.bootstrap_follows.is_empty());
1046 assert_eq!(config.social_graph_crawl_depth, 6);
1047 assert_eq!(config.mirror_max_follow_distance, Some(2));
1048 assert_eq!(config.max_write_distance, 7);
1049 assert!(config.negentropy_only);
1050 assert_eq!(config.overmute_threshold, 1.5);
1051 assert_eq!(config.mirror_kinds, vec![0, 1, 3, 6, 7, 9_735]);
1052 assert_eq!(config.history_sync_author_chunk_size, 512);
1053 assert!(!config.history_sync_on_reconnect);
1054 }
1055
1056 #[test]
1057 fn test_nostr_config_deserializes_legacy_crawl_depth_alias() {
1058 let config: NostrConfig = toml::from_str(
1059 r#"
1060relays = ["wss://relay.example"]
1061crawl_depth = 5
1062"#,
1063 )
1064 .expect("deserialize nostr config");
1065
1066 assert_eq!(config.social_graph_crawl_depth, 5);
1067 }
1068}