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