radicle/
profile.rs

1//! Radicle node profile.
2//!
3//!   $RAD_HOME/                                 # Radicle home
4//!     storage/                                 # Storage root
5//!       zEQNunJUqkNahQ8VvQYuWZZV7EJB/          # Project git repository
6//!       ...                                    # More projects...
7//!     keys/
8//!       radicle                                # Secret key (PKCS 8)
9//!       radicle.pub                            # Public key (PKCS 8)
10//!     node/
11//!       control.sock                           # Node control socket
12//!
13
14pub mod config;
15pub use config::{Config, ConfigError, ConfigPath, RawConfig};
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::{Path, PathBuf};
19use std::{fs, io};
20
21use localtime::LocalTime;
22use thiserror::Error;
23
24use crate::cob::migrate;
25use crate::crypto::ssh::agent::Agent;
26use crate::crypto::ssh::{keystore, Keystore, Passphrase};
27use crate::crypto::PublicKey;
28use crate::node::device::{BoxedDevice, Device};
29use crate::node::policy::config::store::Read;
30use crate::node::{
31    notifications, policy, policy::Scope, Alias, AliasStore, Handle as _, Node, UserAgent,
32};
33use crate::prelude::{Did, NodeId, RepoId};
34use crate::storage::git::transport;
35use crate::storage::git::Storage;
36use crate::storage::ReadRepository;
37use crate::{cob, git, node, storage};
38
39/// Environment variables used by radicle.
40pub mod env {
41    pub use std::env::*;
42
43    /// Path to the radicle home folder.
44    pub const RAD_HOME: &str = "RAD_HOME";
45    /// Path to the radicle node socket file.
46    pub const RAD_SOCKET: &str = "RAD_SOCKET";
47    /// Passphrase for the encrypted radicle secret key.
48    pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
49    /// RNG seed. Must be convertible to a `u64`.
50    pub const RAD_RNG_SEED: &str = "RAD_RNG_SEED";
51    /// Private key seed. Used for generating deterministic keypairs.
52    pub const RAD_KEYGEN_SEED: &str = "RAD_KEYGEN_SEED";
53    /// Show radicle hints.
54    pub const RAD_HINT: &str = "RAD_HINT";
55    /// Environment variable to set to overwrite the commit date for both
56    /// the author and the committer.
57    ///
58    /// The format must be a unix timestamp.
59    pub const RAD_COMMIT_TIME: &str = "RAD_COMMIT_TIME";
60    /// Override the device's local time.
61    /// The format must be a unix timestamp.
62    pub const RAD_LOCAL_TIME: &str = "RAD_LOCAL_TIME";
63    // Turn debug mode on.
64    pub const RAD_DEBUG: &str = "RAD_DEBUG";
65    // Used to set the Git committer timestamp. Can be overridden
66    // to generate deterministic COB IDs.
67    pub const GIT_COMMITTER_DATE: &str = "GIT_COMMITTER_DATE";
68
69    /// Commit timestamp to use. Can be overriden by [`RAD_COMMIT_TIME`].
70    pub fn commit_time() -> localtime::LocalTime {
71        time(RAD_COMMIT_TIME).unwrap_or_else(local_time)
72    }
73
74    /// Local time. Can be overriden by [`RAD_LOCAL_TIME`].
75    pub fn local_time() -> localtime::LocalTime {
76        time(RAD_LOCAL_TIME).unwrap_or_else(localtime::LocalTime::now)
77    }
78
79    /// Whether debug mode is on.
80    pub fn debug() -> bool {
81        var(RAD_DEBUG).is_ok()
82    }
83
84    /// Whether or not to show hints.
85    pub fn hints() -> bool {
86        var(RAD_HINT).is_ok()
87    }
88
89    /// Get the configured pager program from the environment.
90    pub fn pager() -> Option<String> {
91        if let Ok(cfg) = git2::Config::open_default() {
92            if let Ok(pager) = cfg.get_string("core.pager") {
93                return Some(pager);
94            }
95        }
96        if let Ok(pager) = var("PAGER") {
97            return Some(pager);
98        }
99        None
100    }
101
102    /// Get the radicle passphrase from the environment.
103    pub fn passphrase() -> Option<super::Passphrase> {
104        let Ok(passphrase) = var(RAD_PASSPHRASE) else {
105            return None;
106        };
107        if passphrase.is_empty() {
108            // `ssh-keygen` treats the empty string as no passphrase,
109            // so we do the same.
110            log::trace!(target: "radicle", "Treating empty passphrase as no passphrase.");
111            return None;
112        }
113        Some(super::Passphrase::from(passphrase))
114    }
115
116    /// Get a random number generator from the environment.
117    pub fn rng() -> fastrand::Rng {
118        if let Ok(seed) = var(RAD_RNG_SEED) {
119            let Ok(seed) = seed.parse() else {
120                panic!("env::rng: invalid seed specified in `{RAD_RNG_SEED}`");
121            };
122            fastrand::Rng::with_seed(seed)
123        } else {
124            fastrand::Rng::new()
125        }
126    }
127
128    /// Return the seed stored in the [`RAD_KEYGEN_SEED`] environment variable,
129    /// or generate a random one.
130    pub fn seed() -> crypto::Seed {
131        if let Ok(seed) = var(RAD_KEYGEN_SEED) {
132            let Ok(seed) = (0..seed.len())
133                .step_by(2)
134                .map(|i| u8::from_str_radix(&seed[i..i + 2], 16))
135                .collect::<Result<Vec<u8>, _>>()
136            else {
137                panic!("env::seed: invalid hexadecimal value set in `{RAD_KEYGEN_SEED}`");
138            };
139            let Ok(seed): Result<[u8; 32], _> = seed.try_into() else {
140                panic!("env::seed: invalid seed length set in `{RAD_KEYGEN_SEED}`");
141            };
142            crypto::Seed::new(seed)
143        } else {
144            crypto::Seed::generate()
145        }
146    }
147
148    fn time(key: &str) -> Option<localtime::LocalTime> {
149        if let Ok(s) = var(key) {
150            match s.trim().parse::<u64>() {
151                Ok(t) => return Some(localtime::LocalTime::from_secs(t)),
152                Err(e) => {
153                    panic!("env::time: invalid value {s:?} for `{key}` environment variable: {e}");
154                }
155            }
156        }
157        None
158    }
159}
160
161#[derive(Debug, Error)]
162pub enum Error {
163    #[error(transparent)]
164    Io(#[from] io::Error),
165    #[error(transparent)]
166    Config(#[from] ConfigError),
167    #[error(transparent)]
168    Node(#[from] node::Error),
169    #[error(transparent)]
170    Routing(#[from] node::routing::Error),
171    #[error(transparent)]
172    Keystore(#[from] keystore::Error),
173    #[error(transparent)]
174    MemorySigner(#[from] keystore::MemorySignerError),
175    #[error("no radicle profile found at path '{0}'")]
176    NotFound(PathBuf),
177    #[error("error connecting to ssh-agent: {0}")]
178    Agent(#[from] crate::crypto::ssh::agent::Error),
179    #[error("radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
180    KeyNotRegistered(PublicKey),
181    #[error(transparent)]
182    PolicyStore(#[from] node::policy::store::Error),
183    #[error(transparent)]
184    NotificationsStore(#[from] node::notifications::store::Error),
185    #[error(transparent)]
186    DatabaseStore(#[from] node::db::Error),
187    #[error(transparent)]
188    Repository(#[from] storage::RepositoryError),
189    #[error(transparent)]
190    CobsCache(#[from] cob::cache::Error),
191    #[error(transparent)]
192    Storage(#[from] storage::Error),
193}
194
195#[derive(Debug, Clone)]
196pub struct Profile {
197    pub home: Home,
198    pub storage: Storage,
199    pub keystore: Keystore,
200    pub public_key: PublicKey,
201    pub config: Config,
202}
203
204impl Profile {
205    pub fn init(
206        home: Home,
207        alias: Alias,
208        passphrase: Option<Passphrase>,
209        seed: crypto::Seed,
210    ) -> Result<Self, Error> {
211        let keystore = Keystore::new(&home.keys());
212        let public_key = keystore.init("radicle", passphrase, seed)?;
213        let config = Config::init(alias.clone(), home.config().as_path())?;
214        let storage = Storage::open(
215            home.storage(),
216            git::UserInfo {
217                alias,
218                key: public_key,
219            },
220        )?;
221        // Create DBs.
222        home.policies_mut()?;
223        home.notifications_mut()?;
224        home.database_mut()?
225            .journal_mode(node::db::JournalMode::default())?
226            .init(
227                &public_key,
228                config.node.features(),
229                &config.node.alias,
230                &UserAgent::default(),
231                LocalTime::now().into(),
232                config.node.external_addresses.iter(),
233            )?;
234
235        // Migrate COBs cache.
236        let mut cobs = home.cobs_db_mut()?;
237        cobs.migrate(migrate::ignore)?;
238
239        transport::local::register(storage.clone());
240
241        Ok(Profile {
242            home,
243            storage,
244            keystore,
245            public_key,
246            config,
247        })
248    }
249
250    pub fn load() -> Result<Self, Error> {
251        let home = self::home()?;
252        let keystore = Keystore::new(&home.keys());
253        let public_key = keystore
254            .public_key()?
255            .ok_or_else(|| Error::NotFound(home.path().to_path_buf()))?;
256        let config = Config::load(home.config().as_path())?;
257        let storage = Storage::open(
258            home.storage(),
259            git::UserInfo {
260                alias: config.alias().clone(),
261                key: public_key,
262            },
263        )?;
264        transport::local::register(storage.clone());
265
266        Ok(Profile {
267            home,
268            storage,
269            keystore,
270            public_key,
271            config,
272        })
273    }
274
275    pub fn id(&self) -> &PublicKey {
276        &self.public_key
277    }
278
279    pub fn info(&self) -> git::UserInfo {
280        git::UserInfo {
281            alias: self.config.alias().clone(),
282            key: *self.id(),
283        }
284    }
285
286    pub fn hints(&self) -> bool {
287        if env::hints() {
288            return true;
289        }
290        self.config.cli.hints
291    }
292
293    pub fn did(&self) -> Did {
294        Did::from(self.public_key)
295    }
296
297    pub fn signer(&self) -> Result<BoxedDevice, Error> {
298        if !self.keystore.is_encrypted()? {
299            let signer = keystore::MemorySigner::load(&self.keystore, None)?;
300            return Ok(Device::from(signer).boxed());
301        }
302
303        if let Some(passphrase) = env::passphrase() {
304            let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
305            return Ok(Device::from(signer).boxed());
306        }
307
308        match Agent::connect() {
309            Ok(agent) => {
310                let signer = agent.signer(self.public_key);
311                if signer.is_ready()? {
312                    Ok(Device::from(signer).boxed())
313                } else {
314                    Err(Error::KeyNotRegistered(self.public_key))
315                }
316            }
317            Err(err) => Err(err.into()),
318        }
319    }
320
321    /// Get radicle home.
322    pub fn home(&self) -> &Home {
323        &self.home
324    }
325
326    /// Return a read-only handle to the policies of the node.
327    pub fn policies(&self) -> Result<policy::config::Config<Read>, policy::store::Error> {
328        let path = self.node().join(node::POLICIES_DB_FILE);
329        let config = policy::config::Config::new(
330            self.config.node.seeding_policy.into(),
331            policy::store::Store::reader(path)?,
332        );
333        Ok(config)
334    }
335
336    /// Return a multi-source store for aliases.
337    pub fn aliases(&self) -> Aliases {
338        let policies = self.home.policies().ok();
339        let db = self.home.database().ok();
340
341        Aliases { policies, db }
342    }
343
344    /// Add the repo to our inventory.
345    /// If the node is offline, adds it directly to the database.
346    pub fn add_inventory(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
347        match node.add_inventory(rid) {
348            Ok(updated) => Ok(updated),
349            Err(e) if e.is_connection_err() => {
350                let now = LocalTime::now();
351                let mut db = self.database_mut()?;
352                let updates =
353                    node::routing::Store::add_inventory(&mut db, [&rid], *self.id(), now.into())?;
354
355                Ok(!updates.is_empty())
356            }
357            Err(e) => Err(e.into()),
358        }
359    }
360
361    /// Seed a repository by first trying to seed through the node, and if the node isn't running,
362    /// by updating the policy database directly. If the repo is available locally, we also add it
363    /// to our inventory.
364    pub fn seed(&self, rid: RepoId, scope: Scope, node: &mut Node) -> Result<bool, Error> {
365        match node.seed(rid, scope) {
366            Ok(updated) => Ok(updated),
367            Err(e) if e.is_connection_err() => {
368                let mut config = self.policies_mut()?;
369                let updated = config.seed(&rid, scope)?;
370
371                Ok(updated)
372            }
373            Err(e) => Err(e.into()),
374        }
375    }
376
377    /// Unseed a repository by first trying to unseed through the node, and if the node isn't
378    /// running, by updating the policy database directly.
379    pub fn unseed(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
380        match node.unseed(rid) {
381            Ok(updated) => Ok(updated),
382            Err(e) if e.is_connection_err() => {
383                let mut config = self.policies_mut()?;
384                let result = config.unseed(&rid)?;
385
386                let mut db = self.database_mut()?;
387                node::routing::Store::remove_inventory(&mut db, &rid, self.id())?;
388
389                Ok(result)
390            }
391            Err(e) => Err(e.into()),
392        }
393    }
394}
395
396impl std::ops::Deref for Profile {
397    type Target = Home;
398
399    fn deref(&self) -> &Self::Target {
400        &self.home
401    }
402}
403
404impl std::ops::DerefMut for Profile {
405    fn deref_mut(&mut self) -> &mut Self::Target {
406        &mut self.home
407    }
408}
409
410impl AliasStore for Profile {
411    fn alias(&self, nid: &NodeId) -> Option<Alias> {
412        self.aliases().alias(nid)
413    }
414
415    fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
416        self.aliases().reverse_lookup(alias)
417    }
418}
419
420/// Holds multiple alias stores, and will try
421/// them one by one when asking for an alias.
422pub struct Aliases {
423    policies: Option<policy::store::StoreReader>,
424    db: Option<node::Database>,
425}
426
427impl AliasStore for Aliases {
428    /// Retrieve `alias` of given node.
429    /// First looks in `policies.db` and then `addresses.db`.
430    fn alias(&self, nid: &NodeId) -> Option<Alias> {
431        self.policies
432            .as_ref()
433            .and_then(|db| db.alias(nid))
434            .or_else(|| self.db.as_ref().and_then(|db| db.alias(nid)))
435    }
436
437    fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
438        let mut nodes = BTreeMap::new();
439        if let Some(db) = self.policies.as_ref() {
440            nodes.extend(db.reverse_lookup(alias));
441        }
442        if let Some(db) = self.db.as_ref() {
443            nodes.extend(db.reverse_lookup(alias));
444        }
445        nodes
446    }
447}
448
449/// Get the path to the radicle home folder.
450pub fn home() -> Result<Home, io::Error> {
451    if let Some(home) = env::var_os(env::RAD_HOME) {
452        Ok(Home::new(PathBuf::from(home))?)
453    } else if let Some(home) = env::var_os("HOME") {
454        Ok(Home::new(PathBuf::from(home).join(".radicle"))?)
455    } else {
456        Err(io::Error::new(
457            io::ErrorKind::NotFound,
458            "Neither `RAD_HOME` nor `HOME` are set",
459        ))
460    }
461}
462
463/// Radicle home.
464#[derive(Debug, Clone)]
465pub struct Home {
466    path: PathBuf,
467}
468
469impl TryFrom<PathBuf> for Home {
470    type Error = io::Error;
471
472    fn try_from(home: PathBuf) -> Result<Self, Self::Error> {
473        Self::new(home)
474    }
475}
476
477impl Home {
478    /// Creates the Radicle Home directories.
479    ///
480    /// The `home` path is used as the base directory for all
481    /// necessary subdirectories.
482    ///
483    /// If `home` does not already exist then it and any
484    /// subdirectories are created using [`fs::create_dir_all`].
485    ///
486    /// The `home` path is also canonicalized using [`fs::canonicalize`].
487    ///
488    /// All necessary subdirectories are also created.
489    pub fn new(home: impl Into<PathBuf>) -> Result<Self, io::Error> {
490        let path = home.into();
491        if !path.exists() {
492            fs::create_dir_all(path.clone())?;
493        }
494        let home = Self {
495            path: path.canonicalize()?,
496        };
497
498        for dir in &[home.storage(), home.keys(), home.node(), home.cobs()] {
499            if !dir.exists() {
500                fs::create_dir_all(dir)?;
501            }
502        }
503
504        Ok(home)
505    }
506
507    pub fn path(&self) -> &Path {
508        self.path.as_path()
509    }
510
511    pub fn storage(&self) -> PathBuf {
512        self.path.join("storage")
513    }
514
515    pub fn config(&self) -> PathBuf {
516        self.path.join("config.json")
517    }
518
519    pub fn keys(&self) -> PathBuf {
520        self.path.join("keys")
521    }
522
523    pub fn node(&self) -> PathBuf {
524        self.path.join("node")
525    }
526
527    pub fn cobs(&self) -> PathBuf {
528        self.path.join("cobs")
529    }
530
531    pub fn socket(&self) -> PathBuf {
532        env::var_os(env::RAD_SOCKET)
533            .map(PathBuf::from)
534            .unwrap_or_else(|| self.node().join(node::DEFAULT_SOCKET_NAME))
535    }
536
537    /// Return a read-write handle to the notifications database.
538    pub fn notifications_mut(
539        &self,
540    ) -> Result<notifications::StoreWriter, notifications::store::Error> {
541        let path = self.node().join(node::NOTIFICATIONS_DB_FILE);
542        let db = notifications::Store::open(path)?;
543
544        Ok(db)
545    }
546
547    /// Return a read-write handle to the policies store of the node.
548    pub fn policies_mut(&self) -> Result<policy::store::StoreWriter, policy::store::Error> {
549        let path = self.node().join(node::POLICIES_DB_FILE);
550        let config = policy::store::Store::open(path)?;
551
552        Ok(config)
553    }
554
555    /// Return a handle to a read-only database of the node.
556    pub fn database(&self) -> Result<node::Database, node::db::Error> {
557        let path = self.node().join(node::NODE_DB_FILE);
558        let db = node::Database::reader(path)?;
559
560        Ok(db)
561    }
562
563    /// Return a handle to the database of the node.
564    pub fn database_mut(&self) -> Result<node::Database, node::db::Error> {
565        let path = self.node().join(node::NODE_DB_FILE);
566        let db = node::Database::open(path)?;
567
568        Ok(db)
569    }
570
571    /// Returns the address store.
572    pub fn addresses(&self) -> Result<impl node::address::Store, node::db::Error> {
573        self.database_mut()
574    }
575
576    /// Returns the routing store.
577    pub fn routing(&self) -> Result<impl node::routing::Store, node::db::Error> {
578        self.database()
579    }
580
581    /// Returns the routing store, mutably.
582    pub fn routing_mut(&self) -> Result<impl node::routing::Store, node::db::Error> {
583        self.database_mut()
584    }
585
586    /// Get read access to the COBs cache.
587    pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
588        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
589        let db = cob::cache::Store::reader(path)?;
590
591        Ok(db)
592    }
593
594    /// Get write access to the COBs cache.
595    pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
596        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
597        let db = cob::cache::Store::open(path)?;
598
599        Ok(db)
600    }
601
602    /// Return a read-only handle for the issues cache.
603    pub fn issues<'a, R>(
604        &self,
605        repository: &'a R,
606    ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
607    where
608        R: ReadRepository + cob::Store<Namespace = NodeId>,
609    {
610        let db = self.cobs_db()?;
611        let store = cob::issue::Issues::open(repository)?;
612
613        db.check_version()?;
614
615        Ok(cob::issue::Cache::reader(store, db))
616    }
617
618    /// Return a read-write handle for the issues cache.
619    pub fn issues_mut<'a, R>(
620        &self,
621        repository: &'a R,
622    ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
623    where
624        R: ReadRepository + cob::Store<Namespace = NodeId>,
625    {
626        let db = self.cobs_db_mut()?;
627        let store = cob::issue::Issues::open(repository)?;
628
629        db.check_version()?;
630
631        Ok(cob::issue::Cache::open(store, db))
632    }
633
634    /// Return a read-only handle for the patches cache.
635    pub fn patches<'a, R>(
636        &self,
637        repository: &'a R,
638    ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
639    where
640        R: ReadRepository + cob::Store<Namespace = NodeId>,
641    {
642        let db = self.cobs_db()?;
643        let store = cob::patch::Patches::open(repository)?;
644
645        db.check_version()?;
646
647        Ok(cob::patch::Cache::reader(store, db))
648    }
649
650    /// Return a read-write handle for the patches cache.
651    pub fn patches_mut<'a, R>(
652        &self,
653        repository: &'a R,
654    ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
655    where
656        R: ReadRepository + cob::Store<Namespace = NodeId>,
657    {
658        let db = self.cobs_db_mut()?;
659        let store = cob::patch::Patches::open(repository)?;
660
661        db.check_version()?;
662
663        Ok(cob::patch::Cache::open(store, db))
664    }
665}
666
667// Private methods.
668impl Home {
669    /// Return a read-only handle to the policies store of the node.
670    fn policies(&self) -> Result<policy::store::StoreReader, policy::store::Error> {
671        let path = self.node().join(node::POLICIES_DB_FILE);
672        let config = policy::store::Store::reader(path)?;
673
674        Ok(config)
675    }
676}
677
678#[cfg(test)]
679#[cfg(not(target_os = "macos"))]
680#[allow(clippy::unwrap_used)]
681mod test {
682    use std::fs;
683
684    use serde_json as json;
685
686    use super::*;
687
688    // Checks that if we have:
689    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Radicle/Home'
690    //
691    // that it gets normalized to:
692    // '/run/user/1000/.tmpqfK6ih/Radicle/Home'
693    #[test]
694    fn canonicalize_home() {
695        let tmp = tempfile::tempdir().unwrap();
696        let path = tmp.path().join("Home").join("Radicle");
697        fs::create_dir_all(path.clone()).unwrap();
698
699        let last = tmp.path().components().last().unwrap();
700        let home = Home::new(
701            tmp.path()
702                .join("..")
703                .join(last)
704                .join("Home")
705                .join("Radicle"),
706        )
707        .unwrap();
708
709        assert_eq!(home.path, path);
710    }
711
712    #[test]
713    fn test_config() {
714        let cfg = json::from_value::<Config>(json::json!({
715          "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
716          "preferredSeeds": [],
717          "web": {
718            "pinned": {
719              "repositories": [
720                "rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi",
721                "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"
722              ]
723            }
724          },
725          "cli": { "hints": true },
726          "node": {
727            "alias": "seed.radicle.xyz",
728            "listen": [],
729            "peers": { "type": "dynamic", "target": 8 },
730            "connect": [
731              "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776",
732              "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776"
733            ],
734            "externalAddresses": [ "seed.radicle.xyz:8776" ],
735            "db": { "journalMode": "wal" },
736            "network": "main",
737            "log": "INFO",
738            "relay": "always",
739            "limits": {
740              "routingMaxSize": 1000,
741              "routingMaxAge": 604800,
742              "gossipMaxAge": 604800,
743              "fetchConcurrency": 1,
744              "maxOpenFiles": 4096,
745              "rate": {
746                "inbound": { "fillRate": 10.0, "capacity": 2048 },
747                "outbound": { "fillRate": 10.0, "capacity": 2048 }
748              },
749              "connection": { "inbound": 128, "outbound": 16 }
750            },
751            "workers": 32,
752            "policy": "allow",
753            "scope": "all"
754          }
755        }))
756        .unwrap();
757
758        assert!(cfg.node.extra.contains_key("db"));
759        assert!(cfg.node.extra.contains_key("policy"));
760        assert!(cfg.node.extra.contains_key("scope"));
761    }
762}