Skip to main content

void_core/support/
config.rs

1//! Configuration module for void
2//!
3//! Manages repository and user settings stored in `.void/config.json`.
4
5use super::error::{Result, VoidError};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Repository configuration
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(default)]
13pub struct Config {
14    /// Schema version (from init)
15    pub version: Option<u32>,
16    /// Creation timestamp (from init)
17    pub created: Option<String>,
18    /// Repo secret for shard assignment (from init)
19    #[serde(rename = "repoSecret")]
20    pub repo_secret: Option<String>,
21    /// Persistent repo UUID (from init)
22    #[serde(rename = "repoId", skip_serializing_if = "Option::is_none")]
23    pub repo_id: Option<String>,
24    /// Human-friendly repo name (from init, derived from directory name)
25    #[serde(rename = "repoName", skip_serializing_if = "Option::is_none")]
26    pub repo_name: Option<String>,
27    /// IPFS backend configuration
28    pub ipfs: Option<IpfsConfig>,
29    /// Tor daemon and onion routing configuration
30    pub tor: Option<TorConfig>,
31    /// User configuration
32    pub user: UserConfig,
33    /// Core settings
34    pub core: CoreConfig,
35    /// Remote configurations (name -> config)
36    pub remote: HashMap<String, RemoteConfig>,
37}
38
39/// User identity configuration
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41pub struct UserConfig {
42    /// User's display name
43    pub name: Option<String>,
44    /// User's email address
45    pub email: Option<String>,
46}
47
48/// Core repository settings
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct CoreConfig {
52    /// Compression level (0-22 for zstd, default 3)
53    pub compression_level: u8,
54    /// Target shard size in bytes (default 100KB)
55    pub target_shard_size: usize,
56}
57
58impl Default for CoreConfig {
59    fn default() -> Self {
60        Self {
61            compression_level: 3,
62            target_shard_size: 100_000,
63        }
64    }
65}
66
67/// IPFS backend configuration
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69#[serde(default)]
70pub struct IpfsConfig {
71    /// Backend type (kubo|gateway)
72    pub backend: Option<String>,
73    /// Kubo API URL
74    #[serde(rename = "kuboApi")]
75    pub kubo_api: Option<String>,
76    /// Gateway URL
77    pub gateway: Option<String>,
78}
79
80/// Tor integration configuration.
81///
82/// This controls how void routes network traffic and how it coordinates with
83/// external Tor and Kubo daemons.
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85#[serde(default)]
86pub struct TorConfig {
87    /// Tor routing mode (off|external)
88    pub mode: Option<String>,
89    /// SOCKS proxy URL (e.g. socks5h://127.0.0.1:9050)
90    #[serde(rename = "socksProxy")]
91    pub socks_proxy: Option<String>,
92    /// Tor control endpoint (host:port)
93    pub control: Option<String>,
94    /// Use Tor cookie authentication for control port access
95    #[serde(rename = "cookieAuth")]
96    pub cookie_auth: Option<bool>,
97    /// Hidden service settings for exposing the P2P daemon
98    #[serde(rename = "hiddenService")]
99    pub hidden_service: Option<TorHiddenServiceConfig>,
100    /// Kubo integration settings
101    pub kubo: Option<TorKuboConfig>,
102}
103
104/// Tor hidden service configuration.
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
106#[serde(default)]
107pub struct TorHiddenServiceConfig {
108    /// Enable hidden service publication for void P2P listener
109    pub enabled: Option<bool>,
110    /// Public virtual port exposed by the onion service
111    #[serde(rename = "virtualPort")]
112    pub virtual_port: Option<u16>,
113    /// Local target endpoint (e.g. 127.0.0.1:4001)
114    pub target: Option<String>,
115    /// Resolved onion hostname (written by automation tooling)
116    pub hostname: Option<String>,
117}
118
119/// Kubo-specific Tor integration options.
120#[derive(Debug, Clone, Serialize, Deserialize, Default)]
121#[serde(default)]
122pub struct TorKuboConfig {
123    /// Enable Kubo Tor wiring automation
124    pub enable: Option<bool>,
125    /// Configure proxy environment for the Kubo service
126    #[serde(rename = "setEnvProxy")]
127    pub set_env_proxy: Option<bool>,
128    /// Service unit name for Kubo (platform specific)
129    #[serde(rename = "serviceName")]
130    pub service_name: Option<String>,
131}
132
133/// Remote endpoint configuration.
134///
135/// A remote peer for block replication.
136///
137/// Stored in `.void/config.json` under the `remote` key.
138/// Transitioning from SSH-based to P2P model — both fields coexist
139/// during migration.
140#[derive(Debug, Clone, Serialize, Deserialize, Default)]
141#[serde(default)]
142pub struct RemoteConfig {
143    /// Remote URL (legacy — IPFS gateway or kubo API endpoint)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub url: Option<String>,
146    /// SSH host (legacy — being replaced by P2P)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub host: Option<String>,
149    /// SSH user (legacy)
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub user: Option<String>,
152    /// SSH private key path (legacy)
153    #[serde(rename = "keyPath", skip_serializing_if = "Option::is_none")]
154    pub key_path: Option<String>,
155    /// Libp2p peer multiaddr for direct P2P connection
156    #[serde(rename = "peerMultiaddr", skip_serializing_if = "Option::is_none")]
157    pub peer_multiaddr: Option<String>,
158    /// libp2p PeerId (P2P model)
159    #[serde(rename = "peerId", skip_serializing_if = "Option::is_none")]
160    pub peer_id: Option<String>,
161    /// Multiaddr(s) for P2P connection
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub addrs: Vec<String>,
164    /// Optional human-readable label
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub label: Option<String>,
167}
168
169/// Config file path within workspace
170const CONFIG_FILE: &str = "config.json";
171
172#[derive(Debug, Clone, Copy)]
173enum BasicConfigKey {
174    Version,
175    Created,
176    RepoSecret,
177    RepoId,
178    RepoName,
179    UserName,
180    UserEmail,
181    CoreCompressionLevel,
182    CoreTargetShardSize,
183    IpfsBackend,
184    IpfsKuboApi,
185    IpfsGateway,
186}
187
188impl BasicConfigKey {
189    const ALL: [Self; 12] = [
190        Self::Version,
191        Self::Created,
192        Self::RepoSecret,
193        Self::RepoId,
194        Self::RepoName,
195        Self::UserName,
196        Self::UserEmail,
197        Self::CoreCompressionLevel,
198        Self::CoreTargetShardSize,
199        Self::IpfsBackend,
200        Self::IpfsKuboApi,
201        Self::IpfsGateway,
202    ];
203
204    fn parse(parts: &[&str]) -> Option<Self> {
205        match parts {
206            ["version"] => Some(Self::Version),
207            ["created"] => Some(Self::Created),
208            ["repoSecret"] => Some(Self::RepoSecret),
209            ["repoId"] => Some(Self::RepoId),
210            ["repoName"] => Some(Self::RepoName),
211            ["user", "name"] => Some(Self::UserName),
212            ["user", "email"] => Some(Self::UserEmail),
213            ["core", "compression_level"] => Some(Self::CoreCompressionLevel),
214            ["core", "target_shard_size"] => Some(Self::CoreTargetShardSize),
215            ["ipfs", "backend"] => Some(Self::IpfsBackend),
216            ["ipfs", "kuboApi"] => Some(Self::IpfsKuboApi),
217            ["ipfs", "gateway"] => Some(Self::IpfsGateway),
218            _ => None,
219        }
220    }
221
222    fn dotted(self) -> &'static str {
223        match self {
224            Self::Version => "version",
225            Self::Created => "created",
226            Self::RepoSecret => "repoSecret",
227            Self::RepoId => "repoId",
228            Self::RepoName => "repoName",
229            Self::UserName => "user.name",
230            Self::UserEmail => "user.email",
231            Self::CoreCompressionLevel => "core.compression_level",
232            Self::CoreTargetShardSize => "core.target_shard_size",
233            Self::IpfsBackend => "ipfs.backend",
234            Self::IpfsKuboApi => "ipfs.kuboApi",
235            Self::IpfsGateway => "ipfs.gateway",
236        }
237    }
238
239    fn get(self, config: &Config) -> Option<String> {
240        match self {
241            Self::Version => config.version.map(|v| v.to_string()),
242            Self::Created => config.created.clone(),
243            Self::RepoSecret => config.repo_secret.clone(),
244            Self::RepoId => config.repo_id.clone(),
245            Self::RepoName => config.repo_name.clone(),
246            Self::UserName => config.user.name.clone(),
247            Self::UserEmail => config.user.email.clone(),
248            Self::CoreCompressionLevel => Some(config.core.compression_level.to_string()),
249            Self::CoreTargetShardSize => Some(config.core.target_shard_size.to_string()),
250            Self::IpfsBackend => config.ipfs.as_ref().and_then(|c| c.backend.clone()),
251            Self::IpfsKuboApi => config.ipfs.as_ref().and_then(|c| c.kubo_api.clone()),
252            Self::IpfsGateway => config.ipfs.as_ref().and_then(|c| c.gateway.clone()),
253        }
254    }
255
256    fn set(self, config: &mut Config, value: &str) -> Result<()> {
257        match self {
258            Self::Version | Self::Created | Self::RepoSecret | Self::RepoId | Self::RepoName => {
259                Err(VoidError::Serialization(format!(
260                    "read-only config key: {}",
261                    self.dotted()
262                )))
263            }
264            Self::UserName => {
265                config.user.name = Some(value.to_string());
266                Ok(())
267            }
268            Self::UserEmail => {
269                config.user.email = Some(value.to_string());
270                Ok(())
271            }
272            Self::CoreCompressionLevel => {
273                let level: u8 = value.parse().map_err(|_| {
274                    VoidError::Serialization("invalid compression_level".to_string())
275                })?;
276                if level > 22 {
277                    return Err(VoidError::Serialization(
278                        "compression_level must be 0-22".to_string(),
279                    ));
280                }
281                config.core.compression_level = level;
282                Ok(())
283            }
284            Self::CoreTargetShardSize => {
285                config.core.target_shard_size = value.parse().map_err(|_| {
286                    VoidError::Serialization("invalid target_shard_size".to_string())
287                })?;
288                Ok(())
289            }
290            Self::IpfsBackend => {
291                config.ipfs.get_or_insert_with(IpfsConfig::default).backend =
292                    Some(value.to_string());
293                Ok(())
294            }
295            Self::IpfsKuboApi => {
296                config.ipfs.get_or_insert_with(IpfsConfig::default).kubo_api =
297                    Some(value.to_string());
298                Ok(())
299            }
300            Self::IpfsGateway => {
301                config.ipfs.get_or_insert_with(IpfsConfig::default).gateway =
302                    Some(value.to_string());
303                Ok(())
304            }
305        }
306    }
307
308    fn unset(self, config: &mut Config) -> Result<()> {
309        match self {
310            Self::Version | Self::Created | Self::RepoSecret | Self::RepoId | Self::RepoName => {
311                Err(VoidError::Serialization(format!(
312                    "read-only config key: {}",
313                    self.dotted()
314                )))
315            }
316            Self::UserName => {
317                config.user.name = None;
318                Ok(())
319            }
320            Self::UserEmail => {
321                config.user.email = None;
322                Ok(())
323            }
324            Self::CoreCompressionLevel => {
325                config.core.compression_level = CoreConfig::default().compression_level;
326                Ok(())
327            }
328            Self::CoreTargetShardSize => {
329                config.core.target_shard_size = CoreConfig::default().target_shard_size;
330                Ok(())
331            }
332            Self::IpfsBackend => {
333                if let Some(ipfs) = config.ipfs.as_mut() {
334                    ipfs.backend = None;
335                }
336                Ok(())
337            }
338            Self::IpfsKuboApi => {
339                if let Some(ipfs) = config.ipfs.as_mut() {
340                    ipfs.kubo_api = None;
341                }
342                Ok(())
343            }
344            Self::IpfsGateway => {
345                if let Some(ipfs) = config.ipfs.as_mut() {
346                    ipfs.gateway = None;
347                }
348                Ok(())
349            }
350        }
351    }
352}
353
354/// Load config from .void/config.json
355pub fn load(workspace: &Path) -> Result<Config> {
356    let config_path = workspace.join(CONFIG_FILE);
357
358    if !config_path.exists() {
359        return Ok(Config::default());
360    }
361
362    let content = std::fs::read_to_string(&config_path)?;
363    serde_json::from_str(&content).map_err(|e| VoidError::Serialization(e.to_string()))
364}
365
366/// Save config to .void/config.json
367pub fn save(workspace: &Path, config: &Config) -> Result<()> {
368    let config_path = workspace.join(CONFIG_FILE);
369    let content = serde_json::to_string_pretty(config)
370        .map_err(|e| VoidError::Serialization(e.to_string()))?;
371    std::fs::write(&config_path, content)?;
372    Ok(())
373}
374
375/// Get a config value by dotted key (e.g., "user.name", "core.compression_level")
376pub fn get(workspace: &Path, key: &str) -> Result<Option<String>> {
377    let config = load(workspace)?;
378    Ok(get_value(&config, key))
379}
380
381/// Set a config value by dotted key
382pub fn set(workspace: &Path, key: &str, value: &str) -> Result<()> {
383    let mut config = load(workspace)?;
384    set_value(&mut config, key, value)?;
385    save(workspace, &config)
386}
387
388/// Unset a config value (set to None/default)
389pub fn unset(workspace: &Path, key: &str) -> Result<()> {
390    let mut config = load(workspace)?;
391    unset_value(&mut config, key)?;
392    save(workspace, &config)
393}
394
395/// List all config values as flat key-value pairs
396pub fn list(workspace: &Path) -> Result<HashMap<String, String>> {
397    let config = load(workspace)?;
398    Ok(flatten_config(&config))
399}
400
401/// Get a value from config by dotted key
402fn get_value(config: &Config, key: &str) -> Option<String> {
403    let parts: Vec<&str> = key.split('.').collect();
404    if let Some(value) = get_tor_value(config, parts.as_slice()) {
405        return Some(value);
406    }
407    if let Some(basic_key) = BasicConfigKey::parse(parts.as_slice()) {
408        return basic_key.get(config);
409    }
410
411    match parts.as_slice() {
412        ["remote", name, "url"] => config.remote.get(*name).and_then(|r| r.url.clone()),
413        ["remote", name, "host"] => config.remote.get(*name).and_then(|r| r.host.clone()),
414        ["remote", name, "user"] => config.remote.get(*name).and_then(|r| r.user.clone()),
415        ["remote", name, "keyPath"] => config.remote.get(*name).and_then(|r| r.key_path.clone()),
416        ["remote", name, "peerMultiaddr"] => config.remote.get(*name).and_then(|r| r.peer_multiaddr.clone()),
417        ["remote", name, "peerId"] => config.remote.get(*name).and_then(|r| r.peer_id.clone()),
418        ["remote", name, "label"] => config.remote.get(*name).and_then(|r| r.label.clone()),
419        _ => None,
420    }
421}
422
423/// Set a value in config by dotted key
424fn set_value(config: &mut Config, key: &str, value: &str) -> Result<()> {
425    let parts: Vec<&str> = key.split('.').collect();
426    if let Some(result) = set_tor_value(config, parts.as_slice(), value) {
427        return result;
428    }
429    if let Some(basic_key) = BasicConfigKey::parse(parts.as_slice()) {
430        return basic_key.set(config, value);
431    }
432
433    match parts.as_slice() {
434        ["remote", name, "url"] => {
435            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).url = Some(value.to_string());
436        }
437        ["remote", name, "host"] => {
438            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).host = Some(value.to_string());
439        }
440        ["remote", name, "user"] => {
441            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).user = Some(value.to_string());
442        }
443        ["remote", name, "keyPath"] => {
444            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).key_path = Some(value.to_string());
445        }
446        ["remote", name, "peerMultiaddr"] => {
447            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).peer_multiaddr = Some(value.to_string());
448        }
449        ["remote", name, "peerId"] => {
450            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).peer_id = Some(value.to_string());
451        }
452        ["remote", name, "label"] => {
453            config.remote.entry((*name).to_string()).or_insert_with(RemoteConfig::default).label = Some(value.to_string());
454        }
455        _ => {
456            return Err(VoidError::Serialization(format!(
457                "unknown config key: {key}"
458            )));
459        }
460    }
461
462    Ok(())
463}
464
465/// Unset a value in config by dotted key
466fn unset_value(config: &mut Config, key: &str) -> Result<()> {
467    let parts: Vec<&str> = key.split('.').collect();
468    if let Some(result) = unset_tor_value(config, parts.as_slice()) {
469        return result;
470    }
471    if let Some(basic_key) = BasicConfigKey::parse(parts.as_slice()) {
472        return basic_key.unset(config);
473    }
474
475    match parts.as_slice() {
476        ["remote", name, "url"] => {
477            config.remote.remove(*name);
478        }
479        ["remote", name] => {
480            config.remote.remove(*name);
481        }
482        _ => {
483            return Err(VoidError::Serialization(format!(
484                "unknown config key: {key}"
485            )));
486        }
487    }
488
489    Ok(())
490}
491
492/// Flatten config into key-value pairs
493fn flatten_config(config: &Config) -> HashMap<String, String> {
494    let mut result = HashMap::new();
495
496    for basic_key in BasicConfigKey::ALL {
497        if let Some(value) = basic_key.get(config) {
498            result.insert(basic_key.dotted().to_string(), value);
499        }
500    }
501
502    if let Some(tor) = &config.tor {
503        flatten_tor_config(&mut result, tor);
504    }
505
506    // Remote configs
507    for (name, remote) in &config.remote {
508        if let Some(url) = &remote.url {
509            result.insert(format!("remote.{name}.url"), url.clone());
510        }
511        if let Some(host) = &remote.host {
512            result.insert(format!("remote.{name}.host"), host.clone());
513        }
514        if let Some(user) = &remote.user {
515            result.insert(format!("remote.{name}.user"), user.clone());
516        }
517        if let Some(key_path) = &remote.key_path {
518            result.insert(format!("remote.{name}.keyPath"), key_path.clone());
519        }
520        if let Some(peer) = &remote.peer_multiaddr {
521            result.insert(format!("remote.{name}.peerMultiaddr"), peer.clone());
522        }
523        if let Some(peer_id) = &remote.peer_id {
524            result.insert(format!("remote.{name}.peerId"), peer_id.clone());
525        }
526        if let Some(label) = &remote.label {
527            result.insert(format!("remote.{name}.label"), label.clone());
528        }
529    }
530
531    result
532}
533
534fn get_tor_value(config: &Config, parts: &[&str]) -> Option<String> {
535    let tor = config.tor.as_ref()?;
536    match parts {
537        ["tor", "mode"] => tor.mode.clone(),
538        ["tor", "socksProxy"] => tor.socks_proxy.clone(),
539        ["tor", "control"] => tor.control.clone(),
540        ["tor", "cookieAuth"] => tor.cookie_auth.map(|v| v.to_string()),
541        ["tor", "hiddenService", "enabled"] => tor
542            .hidden_service
543            .as_ref()
544            .and_then(|hs| hs.enabled.map(|v| v.to_string())),
545        ["tor", "hiddenService", "virtualPort"] => tor
546            .hidden_service
547            .as_ref()
548            .and_then(|hs| hs.virtual_port.map(|v| v.to_string())),
549        ["tor", "hiddenService", "target"] => {
550            tor.hidden_service.as_ref().and_then(|hs| hs.target.clone())
551        }
552        ["tor", "hiddenService", "hostname"] => tor
553            .hidden_service
554            .as_ref()
555            .and_then(|hs| hs.hostname.clone()),
556        ["tor", "kubo", "enable"] => tor
557            .kubo
558            .as_ref()
559            .and_then(|k| k.enable.map(|v| v.to_string())),
560        ["tor", "kubo", "setEnvProxy"] => tor
561            .kubo
562            .as_ref()
563            .and_then(|k| k.set_env_proxy.map(|v| v.to_string())),
564        ["tor", "kubo", "serviceName"] => tor.kubo.as_ref().and_then(|k| k.service_name.clone()),
565        _ => None,
566    }
567}
568
569fn set_tor_value(config: &mut Config, parts: &[&str], value: &str) -> Option<Result<()>> {
570    match parts {
571        ["tor", "mode"] => Some(parse_tor_mode(value).map(|mode| {
572            tor_config_mut(config).mode = Some(mode);
573        })),
574        ["tor", "socksProxy"] => Some(Ok({
575            tor_config_mut(config).socks_proxy = Some(value.to_string());
576        })),
577        ["tor", "control"] => Some(Ok({
578            tor_config_mut(config).control = Some(value.to_string());
579        })),
580        ["tor", "cookieAuth"] => Some(parse_bool(value, "cookieAuth").map(|parsed| {
581            tor_config_mut(config).cookie_auth = Some(parsed);
582        })),
583        ["tor", "hiddenService", "enabled"] => {
584            Some(parse_bool(value, "hiddenService.enabled").map(|parsed| {
585                tor_hidden_service_mut(config).enabled = Some(parsed);
586            }))
587        }
588        ["tor", "hiddenService", "virtualPort"] => Some(
589            value
590                .parse::<u16>()
591                .map_err(|_| {
592                    VoidError::Serialization("invalid hiddenService.virtualPort".to_string())
593                })
594                .map(|parsed| {
595                    tor_hidden_service_mut(config).virtual_port = Some(parsed);
596                }),
597        ),
598        ["tor", "hiddenService", "target"] => Some(Ok({
599            tor_hidden_service_mut(config).target = Some(value.to_string());
600        })),
601        ["tor", "hiddenService", "hostname"] => Some(Ok({
602            tor_hidden_service_mut(config).hostname = Some(value.to_string());
603        })),
604        ["tor", "kubo", "enable"] => Some(parse_bool(value, "kubo.enable").map(|parsed| {
605            tor_kubo_mut(config).enable = Some(parsed);
606        })),
607        ["tor", "kubo", "setEnvProxy"] => {
608            Some(parse_bool(value, "kubo.setEnvProxy").map(|parsed| {
609                tor_kubo_mut(config).set_env_proxy = Some(parsed);
610            }))
611        }
612        ["tor", "kubo", "serviceName"] => Some(Ok({
613            tor_kubo_mut(config).service_name = Some(value.to_string());
614        })),
615        _ => None,
616    }
617}
618
619fn unset_tor_value(config: &mut Config, parts: &[&str]) -> Option<Result<()>> {
620    match parts {
621        ["tor", "mode"] => Some({
622            if let Some(tor) = config.tor.as_mut() {
623                tor.mode = None;
624            }
625            Ok(())
626        }),
627        ["tor", "socksProxy"] => Some({
628            if let Some(tor) = config.tor.as_mut() {
629                tor.socks_proxy = None;
630            }
631            Ok(())
632        }),
633        ["tor", "control"] => Some({
634            if let Some(tor) = config.tor.as_mut() {
635                tor.control = None;
636            }
637            Ok(())
638        }),
639        ["tor", "cookieAuth"] => Some({
640            if let Some(tor) = config.tor.as_mut() {
641                tor.cookie_auth = None;
642            }
643            Ok(())
644        }),
645        ["tor", "hiddenService", "enabled"] => Some({
646            if let Some(hidden) = config
647                .tor
648                .as_mut()
649                .and_then(|tor| tor.hidden_service.as_mut())
650            {
651                hidden.enabled = None;
652            }
653            Ok(())
654        }),
655        ["tor", "hiddenService", "virtualPort"] => Some({
656            if let Some(hidden) = config
657                .tor
658                .as_mut()
659                .and_then(|tor| tor.hidden_service.as_mut())
660            {
661                hidden.virtual_port = None;
662            }
663            Ok(())
664        }),
665        ["tor", "hiddenService", "target"] => Some({
666            if let Some(hidden) = config
667                .tor
668                .as_mut()
669                .and_then(|tor| tor.hidden_service.as_mut())
670            {
671                hidden.target = None;
672            }
673            Ok(())
674        }),
675        ["tor", "hiddenService", "hostname"] => Some({
676            if let Some(hidden) = config
677                .tor
678                .as_mut()
679                .and_then(|tor| tor.hidden_service.as_mut())
680            {
681                hidden.hostname = None;
682            }
683            Ok(())
684        }),
685        ["tor", "kubo", "enable"] => Some({
686            if let Some(kubo) = config.tor.as_mut().and_then(|tor| tor.kubo.as_mut()) {
687                kubo.enable = None;
688            }
689            Ok(())
690        }),
691        ["tor", "kubo", "setEnvProxy"] => Some({
692            if let Some(kubo) = config.tor.as_mut().and_then(|tor| tor.kubo.as_mut()) {
693                kubo.set_env_proxy = None;
694            }
695            Ok(())
696        }),
697        ["tor", "kubo", "serviceName"] => Some({
698            if let Some(kubo) = config.tor.as_mut().and_then(|tor| tor.kubo.as_mut()) {
699                kubo.service_name = None;
700            }
701            Ok(())
702        }),
703        _ => None,
704    }
705}
706
707fn flatten_tor_config(result: &mut HashMap<String, String>, tor: &TorConfig) {
708    if let Some(mode) = &tor.mode {
709        result.insert("tor.mode".to_string(), mode.clone());
710    }
711    if let Some(socks_proxy) = &tor.socks_proxy {
712        result.insert("tor.socksProxy".to_string(), socks_proxy.clone());
713    }
714    if let Some(control) = &tor.control {
715        result.insert("tor.control".to_string(), control.clone());
716    }
717    if let Some(cookie_auth) = tor.cookie_auth {
718        result.insert("tor.cookieAuth".to_string(), cookie_auth.to_string());
719    }
720    if let Some(hidden) = &tor.hidden_service {
721        if let Some(enabled) = hidden.enabled {
722            result.insert("tor.hiddenService.enabled".to_string(), enabled.to_string());
723        }
724        if let Some(port) = hidden.virtual_port {
725            result.insert(
726                "tor.hiddenService.virtualPort".to_string(),
727                port.to_string(),
728            );
729        }
730        if let Some(target) = &hidden.target {
731            result.insert("tor.hiddenService.target".to_string(), target.clone());
732        }
733        if let Some(hostname) = &hidden.hostname {
734            result.insert("tor.hiddenService.hostname".to_string(), hostname.clone());
735        }
736    }
737    if let Some(kubo) = &tor.kubo {
738        if let Some(enable) = kubo.enable {
739            result.insert("tor.kubo.enable".to_string(), enable.to_string());
740        }
741        if let Some(set_env_proxy) = kubo.set_env_proxy {
742            result.insert(
743                "tor.kubo.setEnvProxy".to_string(),
744                set_env_proxy.to_string(),
745            );
746        }
747        if let Some(service_name) = &kubo.service_name {
748            result.insert("tor.kubo.serviceName".to_string(), service_name.clone());
749        }
750    }
751}
752
753fn tor_config_mut(config: &mut Config) -> &mut TorConfig {
754    config.tor.get_or_insert_with(TorConfig::default)
755}
756
757fn tor_hidden_service_mut(config: &mut Config) -> &mut TorHiddenServiceConfig {
758    tor_config_mut(config)
759        .hidden_service
760        .get_or_insert_with(TorHiddenServiceConfig::default)
761}
762
763fn tor_kubo_mut(config: &mut Config) -> &mut TorKuboConfig {
764    tor_config_mut(config)
765        .kubo
766        .get_or_insert_with(TorKuboConfig::default)
767}
768
769fn parse_bool(value: &str, field: &str) -> Result<bool> {
770    value
771        .parse()
772        .map_err(|_| VoidError::Serialization(format!("invalid {field}")))
773}
774
775fn parse_tor_mode(value: &str) -> Result<String> {
776    match value {
777        "off" | "external" => Ok(value.to_string()),
778        _ => Err(VoidError::Serialization(
779            "tor.mode must be 'off' or 'external'".to_string(),
780        )),
781    }
782}
783
784
785/// Load the repo_secret from config.json, falling back to a vault-derived secret.
786///
787/// This centralizes the repo_secret loading pattern used by CLI and TUI.
788/// When no repo_secret is configured, derives one deterministically from
789/// the root key via HKDF — the raw root key is never exposed.
790pub fn load_repo_secret(void_dir: &Path, vault: &void_crypto::KeyVault) -> void_crypto::CryptoResult<void_crypto::RepoSecret> {
791    if let Ok(cfg) = load(void_dir) {
792        if let Some(secret_hex) = cfg.repo_secret {
793            if let Ok(bytes) = hex::decode(secret_hex.trim()) {
794                if let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) {
795                    return Ok(void_crypto::RepoSecret::new(arr));
796                }
797            }
798        }
799    }
800    Ok(void_crypto::RepoSecret::new(*vault.repo_secret_fallback()?.as_bytes()))
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use tempfile::tempdir;
807
808    #[test]
809    fn test_default_config() {
810        let config = Config::default();
811        assert!(config.version.is_none());
812        assert!(config.created.is_none());
813        assert!(config.repo_secret.is_none());
814        assert!(config.ipfs.is_none());
815        assert!(config.tor.is_none());
816        assert!(config.user.name.is_none());
817        assert!(config.user.email.is_none());
818        assert_eq!(config.core.compression_level, 3);
819        assert_eq!(config.core.target_shard_size, 100_000);
820        assert!(config.remote.is_empty());
821    }
822
823    #[test]
824    fn test_save_and_load() {
825        let dir = tempdir().unwrap();
826        let workspace = dir.path();
827
828        let mut config = Config::default();
829        config.user.name = Some("Test User".to_string());
830        config.user.email = Some("test@example.com".to_string());
831
832        save(workspace, &config).unwrap();
833        let loaded = load(workspace).unwrap();
834
835        assert_eq!(loaded.user.name, Some("Test User".to_string()));
836        assert_eq!(loaded.user.email, Some("test@example.com".to_string()));
837    }
838
839    #[test]
840    fn test_get_set() {
841        let dir = tempdir().unwrap();
842        let workspace = dir.path();
843
844        // Set and get user.name
845        set(workspace, "user.name", "Alice").unwrap();
846        assert_eq!(
847            get(workspace, "user.name").unwrap(),
848            Some("Alice".to_string())
849        );
850
851        // Set and get core.compression_level
852        set(workspace, "core.compression_level", "5").unwrap();
853        assert_eq!(
854            get(workspace, "core.compression_level").unwrap(),
855            Some("5".to_string())
856        );
857
858        // Set and get remote
859        set(workspace, "remote.origin.url", "https://example.com").unwrap();
860        assert_eq!(
861            get(workspace, "remote.origin.url").unwrap(),
862            Some("https://example.com".to_string())
863        );
864
865        // Set and get ipfs backend
866        set(workspace, "ipfs.backend", "kubo").unwrap();
867        assert_eq!(
868            get(workspace, "ipfs.backend").unwrap(),
869            Some("kubo".to_string())
870        );
871
872        // Set and get tor mode
873        set(workspace, "tor.mode", "external").unwrap();
874        assert_eq!(
875            get(workspace, "tor.mode").unwrap(),
876            Some("external".to_string())
877        );
878
879        // Set and get tor cookie auth
880        set(workspace, "tor.cookieAuth", "true").unwrap();
881        assert_eq!(
882            get(workspace, "tor.cookieAuth").unwrap(),
883            Some("true".to_string())
884        );
885
886        // Set and get tor hidden service virtual port
887        set(workspace, "tor.hiddenService.virtualPort", "4001").unwrap();
888        assert_eq!(
889            get(workspace, "tor.hiddenService.virtualPort").unwrap(),
890            Some("4001".to_string())
891        );
892    }
893
894    #[test]
895    fn test_unset() {
896        let dir = tempdir().unwrap();
897        let workspace = dir.path();
898
899        set(workspace, "user.name", "Alice").unwrap();
900        assert!(get(workspace, "user.name").unwrap().is_some());
901
902        unset(workspace, "user.name").unwrap();
903        assert!(get(workspace, "user.name").unwrap().is_none());
904
905        set(workspace, "tor.mode", "external").unwrap();
906        assert_eq!(
907            get(workspace, "tor.mode").unwrap(),
908            Some("external".to_string())
909        );
910        unset(workspace, "tor.mode").unwrap();
911        assert!(get(workspace, "tor.mode").unwrap().is_none());
912    }
913
914    #[test]
915    fn test_list() {
916        let dir = tempdir().unwrap();
917        let workspace = dir.path();
918
919        set(workspace, "user.name", "Alice").unwrap();
920        set(workspace, "user.email", "alice@example.com").unwrap();
921        set(workspace, "remote.origin.url", "https://example.com").unwrap();
922        set(workspace, "ipfs.gateway", "https://dweb.link").unwrap();
923        set(workspace, "tor.socksProxy", "socks5h://127.0.0.1:9050").unwrap();
924
925        let all = list(workspace).unwrap();
926
927        assert_eq!(all.get("user.name"), Some(&"Alice".to_string()));
928        assert_eq!(
929            all.get("user.email"),
930            Some(&"alice@example.com".to_string())
931        );
932        assert_eq!(
933            all.get("remote.origin.url"),
934            Some(&"https://example.com".to_string())
935        );
936        assert_eq!(
937            all.get("ipfs.gateway"),
938            Some(&"https://dweb.link".to_string())
939        );
940        assert_eq!(
941            all.get("tor.socksProxy"),
942            Some(&"socks5h://127.0.0.1:9050".to_string())
943        );
944        // Core defaults should also be present
945        assert!(all.contains_key("core.compression_level"));
946        assert!(all.contains_key("core.target_shard_size"));
947    }
948
949    #[test]
950    fn test_invalid_key() {
951        let dir = tempdir().unwrap();
952        let workspace = dir.path();
953
954        let result = set(workspace, "invalid.key", "value");
955        assert!(result.is_err());
956    }
957
958    #[test]
959    fn test_invalid_value() {
960        let dir = tempdir().unwrap();
961        let workspace = dir.path();
962
963        let result = set(workspace, "core.compression_level", "not_a_number");
964        assert!(result.is_err());
965    }
966
967    #[test]
968    fn test_invalid_tor_mode() {
969        let dir = tempdir().unwrap();
970        let workspace = dir.path();
971
972        let result = set(workspace, "tor.mode", "invalid");
973        assert!(result.is_err());
974    }
975}