1pub mod config;
15pub use config::{Config, WriteError};
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::cob::store::access::{ReadOnly, WriteAs};
26use crate::crypto::PublicKey;
27use crate::crypto::ssh::agent::Agent;
28use crate::crypto::ssh::{Keystore, Passphrase, keystore};
29use crate::node::device::{BoxedDevice, Device};
30use crate::node::policy::config::store::Read;
31use crate::node::{Alias, AliasStore, Handle as _, Node, notifications, policy, policy::Scope};
32use crate::prelude::{Did, NodeId, RepoId};
33use crate::storage::ReadRepository;
34use crate::storage::git::Storage;
35use crate::storage::git::transport;
36use crate::{cob, git, node, storage};
37
38pub mod env {
40 pub use std::env::*;
41
42 pub const RAD_HOME: &str = "RAD_HOME";
44 pub const RAD_SOCKET: &str = "RAD_SOCKET";
46 pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
48 pub const RAD_RNG_SEED: &str = "RAD_RNG_SEED";
50 pub const RAD_KEYGEN_SEED: &str = "RAD_KEYGEN_SEED";
52 pub const RAD_HINT: &str = "RAD_HINT";
54 pub const RAD_COMMIT_TIME: &str = "RAD_COMMIT_TIME";
59 pub const RAD_LOCAL_TIME: &str = "RAD_LOCAL_TIME";
62 pub const RAD_DEBUG: &str = "RAD_DEBUG";
64 pub const GIT_COMMITTER_DATE: &str = "GIT_COMMITTER_DATE";
67
68 pub fn commit_time() -> localtime::LocalTime {
70 time(RAD_COMMIT_TIME).unwrap_or_else(local_time)
71 }
72
73 pub fn local_time() -> localtime::LocalTime {
75 time(RAD_LOCAL_TIME).unwrap_or_else(localtime::LocalTime::now)
76 }
77
78 pub fn debug() -> bool {
80 var(RAD_DEBUG).is_ok()
81 }
82
83 pub fn hints() -> bool {
85 var(RAD_HINT).is_ok()
86 }
87
88 pub fn pager() -> Option<String> {
90 #[cfg(not(windows))]
94 if let Ok(cfg) = crate::git::raw::Config::open_default() {
95 if let Ok(pager) = cfg.get_string("core.pager") {
96 return Some(pager);
97 }
98 }
99 if let Ok(pager) = var("PAGER") {
100 return Some(pager);
101 }
102 None
103 }
104
105 pub fn passphrase() -> Option<super::Passphrase> {
107 let Ok(passphrase) = var(RAD_PASSPHRASE) else {
108 return None;
109 };
110 if passphrase.is_empty() {
111 log::trace!(target: "radicle", "Treating empty passphrase as no passphrase.");
114 return None;
115 }
116 Some(super::Passphrase::from(passphrase))
117 }
118
119 pub fn rng() -> fastrand::Rng {
121 if let Ok(seed) = var(RAD_RNG_SEED) {
122 let Ok(seed) = seed.parse() else {
123 panic!("env::rng: invalid seed specified in `{RAD_RNG_SEED}`");
124 };
125 fastrand::Rng::with_seed(seed)
126 } else {
127 fastrand::Rng::new()
128 }
129 }
130
131 pub fn seed() -> crypto::Seed {
134 if let Ok(seed) = var(RAD_KEYGEN_SEED) {
135 let Ok(seed) = (0..seed.len())
136 .step_by(2)
137 .map(|i| u8::from_str_radix(&seed[i..i + 2], 16))
138 .collect::<Result<Vec<u8>, _>>()
139 else {
140 panic!("env::seed: invalid hexadecimal value set in `{RAD_KEYGEN_SEED}`");
141 };
142 let Ok(seed): Result<[u8; 32], _> = seed.try_into() else {
143 panic!("env::seed: invalid seed length set in `{RAD_KEYGEN_SEED}`");
144 };
145 crypto::Seed::new(seed)
146 } else {
147 crypto::Seed::generate()
148 }
149 }
150
151 fn time(key: &str) -> Option<localtime::LocalTime> {
152 if let Ok(s) = var(key) {
153 match s.trim().parse::<u64>() {
154 Ok(t) => return Some(localtime::LocalTime::from_secs(t)),
155 Err(e) => {
156 panic!("env::time: invalid value {s:?} for `{key}` environment variable: {e}");
157 }
158 }
159 }
160 None
161 }
162}
163
164#[derive(Debug, Error)]
165pub enum Error {
166 #[error(transparent)]
167 Io(#[from] io::Error),
168 #[error(transparent)]
169 InitConfig(#[from] config::InitError),
170 #[error(transparent)]
171 LoadConfig(#[from] config::LoadError),
172 #[error(transparent)]
173 Node(#[from] node::Error),
174 #[error(transparent)]
175 Routing(#[from] node::routing::Error),
176 #[error(transparent)]
177 Keystore(#[from] keystore::Error),
178 #[error("no Radicle profile found at path '{0}'")]
179 NotFound(PathBuf),
180 #[error(transparent)]
181 PolicyStore(#[from] node::policy::store::Error),
182 #[error(transparent)]
183 NotificationsStore(#[from] node::notifications::store::Error),
184 #[error(transparent)]
185 DatabaseStore(#[from] node::db::Error),
186 #[error(transparent)]
187 Repository(#[from] storage::RepositoryError),
188 #[error(transparent)]
189 CobsCache(#[from] cob::cache::Error),
190 #[error(transparent)]
191 Storage(#[from] storage::Error),
192}
193
194#[derive(Debug, Error)]
195pub enum SignerError {
196 #[error(transparent)]
197 MemorySigner(#[from] keystore::MemorySignerError),
198
199 #[error(transparent)]
200 Agent(#[from] crate::crypto::ssh::agent::AgentError),
201
202 #[error("Radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
203 KeyNotRegistered(PublicKey),
204
205 #[error(transparent)]
206 Keystore(#[from] keystore::Error),
207
208 #[error("error connecting to ssh-agent: {source}")]
209 AgentConnection {
210 source: crate::crypto::ssh::agent::ConnectError,
211 },
212}
213
214impl SignerError {
215 pub fn prompt_for_passphrase(&self) -> bool {
218 matches!(
219 self,
220 Self::AgentConnection { .. } | Self::KeyNotRegistered(_)
221 )
222 }
223}
224
225#[derive(Debug, Clone)]
226pub struct Profile {
227 pub home: Home,
228 pub storage: Storage,
229 pub keystore: Keystore,
230 pub public_key: PublicKey,
231 pub config: Config,
232}
233
234impl Profile {
235 pub fn init(
236 home: Home,
237 alias: Alias,
238 passphrase: Option<Passphrase>,
239 seed: crypto::Seed,
240 ) -> Result<Self, Error> {
241 let keystore = Keystore::new(&home.keys());
242 let public_key = keystore.init("radicle", passphrase, seed)?;
243 let config = Config::init(alias.clone(), home.config().as_path())?;
244 let storage = Storage::open(
245 home.storage(),
246 git::UserInfo {
247 alias,
248 key: public_key,
249 },
250 )?;
251 home.policies_mut()?;
253 home.notifications_mut()?;
254 home.database_mut(config.node.database)?.init(
255 &public_key,
256 config.node.features(),
257 &config.node.alias,
258 &config.node.user_agent(),
259 LocalTime::now().into(),
260 config.node.external_addresses.iter(),
261 )?;
262
263 let mut cobs = home.cobs_db_mut()?;
265 cobs.migrate(migrate::ignore)?;
266
267 transport::local::register(storage.clone());
268
269 Ok(Profile {
270 home,
271 storage,
272 keystore,
273 public_key,
274 config,
275 })
276 }
277
278 pub fn load() -> Result<Self, Error> {
279 let home = self::home()?;
280 let keystore = Keystore::new(&home.keys());
281 let public_key = keystore
282 .public_key()?
283 .ok_or_else(|| Error::NotFound(home.path().to_path_buf()))?;
284 let config = Config::load(home.config().as_path())?;
285 let storage = Storage::open(
286 home.storage(),
287 git::UserInfo {
288 alias: config.alias().clone(),
289 key: public_key,
290 },
291 )?;
292 transport::local::register(storage.clone());
293
294 Ok(Profile {
295 home,
296 storage,
297 keystore,
298 public_key,
299 config,
300 })
301 }
302
303 pub fn id(&self) -> &PublicKey {
304 &self.public_key
305 }
306
307 pub fn info(&self) -> git::UserInfo {
308 git::UserInfo {
309 alias: self.config.alias().clone(),
310 key: *self.id(),
311 }
312 }
313
314 pub fn hints(&self) -> bool {
315 if env::hints() {
316 return true;
317 }
318 self.config.cli.hints
319 }
320
321 pub fn did(&self) -> Did {
322 Did::from(self.public_key)
323 }
324
325 pub fn signer(&self) -> Result<BoxedDevice, SignerError> {
326 if !self.keystore.is_encrypted()? {
327 let signer = keystore::MemorySigner::load(&self.keystore, None)?;
328 return Ok(Device::from(signer).boxed());
329 }
330
331 if let Some(passphrase) = env::passphrase() {
332 let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
333 return Ok(Device::from(signer).boxed());
334 }
335
336 let agent = Agent::connect().map_err(|source| SignerError::AgentConnection { source })?;
337 let signer = agent.signer(self.public_key);
338 if signer.is_ready()? {
339 Ok(Device::from(signer).boxed())
340 } else {
341 Err(SignerError::KeyNotRegistered(self.public_key))
342 }
343 }
344
345 pub fn home(&self) -> &Home {
347 &self.home
348 }
349
350 pub fn policies(&self) -> Result<policy::config::Config<Read>, policy::store::Error> {
352 let path = self.node().join(node::POLICIES_DB_FILE);
353 let config = policy::config::Config::new(
354 self.config.node.seeding_policy.into(),
355 policy::store::Store::reader(path)?,
356 );
357 Ok(config)
358 }
359
360 pub fn aliases(&self) -> Aliases {
362 let policies = self.home.policies().ok();
363 let db = self.home.database(self.config.node.database).ok();
364
365 Aliases { policies, db }
366 }
367
368 pub fn add_inventory(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
371 match node.add_inventory(rid) {
372 Ok(updated) => Ok(updated),
373 Err(e) if e.is_connection_err() => {
374 let now = LocalTime::now();
375 let mut db = self.database_mut()?;
376 let updates =
377 node::routing::Store::add_inventory(&mut db, [&rid], *self.id(), now.into())?;
378
379 Ok(!updates.is_empty())
380 }
381 Err(e) => Err(e.into()),
382 }
383 }
384
385 pub fn seed(&self, rid: RepoId, scope: Scope, node: &mut Node) -> Result<bool, Error> {
389 match node.seed(rid, scope) {
390 Ok(updated) => Ok(updated),
391 Err(e) if e.is_connection_err() => {
392 let mut config = self.policies_mut()?;
393 let updated = config.seed(&rid, scope)?;
394
395 Ok(updated)
396 }
397 Err(e) => Err(e.into()),
398 }
399 }
400
401 pub fn unseed(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
404 match node.unseed(rid) {
405 Ok(updated) => Ok(updated),
406 Err(e) if e.is_connection_err() => {
407 let mut config = self.policies_mut()?;
408 let result = config.unseed(&rid)?;
409
410 let mut db = self.database_mut()?;
411 node::routing::Store::remove_inventory(&mut db, &rid, self.id())?;
412
413 Ok(result)
414 }
415 Err(e) => Err(e.into()),
416 }
417 }
418
419 pub fn database_mut(&self) -> Result<node::Database, node::db::Error> {
422 self.home.database_mut(self.config.node.database)
423 }
424
425 pub fn database(&self) -> Result<node::Database, node::db::Error> {
428 self.home.database(self.config.node.database)
429 }
430
431 pub fn routing(&self) -> Result<impl node::routing::Store + use<>, node::db::Error> {
434 self.home.routing(self.config.node.database)
435 }
436}
437
438impl std::ops::Deref for Profile {
439 type Target = Home;
440
441 fn deref(&self) -> &Self::Target {
442 &self.home
443 }
444}
445
446impl std::ops::DerefMut for Profile {
447 fn deref_mut(&mut self) -> &mut Self::Target {
448 &mut self.home
449 }
450}
451
452impl AliasStore for Profile {
453 fn alias(&self, nid: &NodeId) -> Option<Alias> {
454 self.aliases().alias(nid)
455 }
456
457 fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
458 self.aliases().reverse_lookup(alias)
459 }
460}
461
462pub struct Aliases {
465 policies: Option<policy::store::StoreReader>,
466 db: Option<node::Database>,
467}
468
469impl AliasStore for Aliases {
470 fn alias(&self, nid: &NodeId) -> Option<Alias> {
473 self.policies
474 .as_ref()
475 .and_then(|db| db.alias(nid))
476 .or_else(|| self.db.as_ref().and_then(|db| db.alias(nid)))
477 }
478
479 fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
480 let mut nodes = BTreeMap::new();
481 if let Some(db) = self.policies.as_ref() {
482 nodes.extend(db.reverse_lookup(alias));
483 }
484 if let Some(db) = self.db.as_ref() {
485 nodes.extend(db.reverse_lookup(alias));
486 }
487 nodes
488 }
489}
490
491pub fn home() -> Result<Home, io::Error> {
493 #[cfg(unix)]
494 const ERROR_MESSAGE_UNSET: &str =
495 "Environment variables `RAD_HOME` and `HOME` are both unset or not valid Unicode.";
496
497 #[cfg(windows)]
498 const ERROR_MESSAGE_UNSET: &str = "Environment variables `RAD_HOME`, `HOME`, and `USERPROFILE` are all unset or not valid Unicode.";
499
500 struct DetectedHome {
501 path: String,
502
503 join_dot_radicle: bool,
505 }
506
507 let detected = {
508 match env::var(env::RAD_HOME).ok() {
509 Some(path) => Some(DetectedHome {
510 path,
511 join_dot_radicle: false,
512 }),
513 None => env::var("HOME")
514 .ok()
515 .or_else(|| {
516 cfg!(windows)
517 .then(|| env::var("USERPROFILE").ok())
518 .flatten()
519 })
520 .map(|path| DetectedHome {
521 path,
522 join_dot_radicle: true,
523 }),
524 }
525 };
526
527 match detected {
528 Some(DetectedHome {
529 path,
530 join_dot_radicle,
531 }) => {
532 let home = {
533 let path = PathBuf::from(path);
534
535 if join_dot_radicle {
536 path.join(".radicle")
537 } else {
538 path
539 }
540 };
541
542 Ok(Home::new(home)?)
543 }
544 None => Err(io::Error::new(
545 io::ErrorKind::NotFound,
546 ERROR_MESSAGE_UNSET.to_string(),
547 )),
548 }
549}
550
551#[derive(Debug, Clone)]
553pub struct Home {
554 path: PathBuf,
555}
556
557impl Home {
558 pub fn new(home: impl Into<PathBuf>) -> Result<Self, io::Error> {
570 let path = home.into();
571 if !path.exists() {
572 fs::create_dir_all(path.clone())?;
573 }
574 let home = Self {
575 path: dunce::canonicalize(path)?,
576 };
577
578 for dir in &home.subdirectories() {
579 if !dir.exists() {
580 fs::create_dir_all(dir)?;
581 }
582 }
583
584 Ok(home)
585 }
586
587 pub fn load<P>(home: P) -> Result<Self, io::Error>
597 where
598 P: AsRef<Path>,
599 {
600 let path = dunce::canonicalize(home.as_ref())?;
601 if !path.exists() {
602 return Err(io::Error::new(
603 io::ErrorKind::NotFound,
604 format!("Radicle home directory does not exist: {}", path.display()),
605 ));
606 }
607 let home = Self { path };
608
609 let missing = home
610 .subdirectories()
611 .into_iter()
612 .filter(|dir| !dir.exists())
613 .collect::<Vec<_>>();
614
615 if !missing.is_empty() {
616 let missing = missing
617 .into_iter()
618 .map(|dir| dir.display().to_string())
619 .collect::<Vec<_>>()
620 .join(",");
621 return Err(io::Error::new(
622 io::ErrorKind::NotFound,
623 format!("Required Radicle directories are missing: [{}]", missing),
624 ));
625 }
626
627 Ok(home)
628 }
629
630 fn subdirectories(&self) -> [PathBuf; 4] {
638 [self.storage(), self.keys(), self.node(), self.cobs()]
639 }
640
641 pub fn path(&self) -> &Path {
642 self.path.as_path()
643 }
644
645 pub fn storage(&self) -> PathBuf {
647 self.path.join("storage")
648 }
649
650 pub fn config(&self) -> PathBuf {
652 self.path.join("config.json")
653 }
654
655 pub fn keys(&self) -> PathBuf {
657 self.path.join("keys")
658 }
659
660 pub fn node(&self) -> PathBuf {
662 self.path.join("node")
663 }
664
665 pub fn cobs(&self) -> PathBuf {
667 self.path.join("cobs")
668 }
669
670 pub fn socket_from_env(&self) -> PathBuf {
676 env::var_os(env::RAD_SOCKET)
677 .map(PathBuf::from)
678 .unwrap_or_else(|| self.socket_default())
679 }
680
681 pub fn socket_default(&self) -> PathBuf {
687 const DEFAULT_SOCKET_NAME: &str = "control.sock";
688 self.node().join(DEFAULT_SOCKET_NAME)
689 }
690
691 pub fn notifications_mut(
693 &self,
694 ) -> Result<notifications::StoreWriter, notifications::store::Error> {
695 let path = self.node().join(node::NOTIFICATIONS_DB_FILE);
696 let db = notifications::Store::open(path)?;
697
698 Ok(db)
699 }
700
701 pub fn policies_mut(&self) -> Result<policy::store::StoreWriter, policy::store::Error> {
703 let path = self.node().join(node::POLICIES_DB_FILE);
704 let config = policy::store::Store::open(path)?;
705
706 Ok(config)
707 }
708
709 pub fn database(
711 &self,
712 config: node::db::config::Config,
713 ) -> Result<node::Database, node::db::Error> {
714 let path = self.node().join(node::NODE_DB_FILE);
715 let db = node::Database::reader(path, config)?;
716
717 Ok(db)
718 }
719
720 pub fn database_mut(
722 &self,
723 config: node::db::config::Config,
724 ) -> Result<node::Database, node::db::Error> {
725 let path = self.node().join(node::NODE_DB_FILE);
726 let db = node::Database::open(path, config)?;
727
728 Ok(db)
729 }
730
731 pub fn addresses(
733 &self,
734 config: node::db::config::Config,
735 ) -> Result<impl node::address::Store + use<>, node::db::Error> {
736 self.database_mut(config)
737 }
738
739 pub fn routing(
741 &self,
742 config: node::db::config::Config,
743 ) -> Result<impl node::routing::Store + use<>, node::db::Error> {
744 self.database(config)
745 }
746
747 pub fn routing_mut(
749 &self,
750 config: node::db::config::Config,
751 ) -> Result<impl node::routing::Store + use<>, node::db::Error> {
752 self.database_mut(config)
753 }
754
755 pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
757 let path = self.cobs().join(cob::cache::COBS_DB_FILE);
758 let db = cob::cache::Store::reader(path)?;
759
760 Ok(db)
761 }
762
763 pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
765 let path = self.cobs().join(cob::cache::COBS_DB_FILE);
766 let db = cob::cache::Store::open(path)?;
767
768 Ok(db)
769 }
770
771 pub fn issues<'a, Repo>(
773 &self,
774 repository: &'a Repo,
775 ) -> Result<cob::issue::Cache<'a, Repo, ReadOnly, cob::cache::StoreReader>, Error>
776 where
777 Repo: ReadRepository + cob::Store<Namespace = NodeId>,
778 {
779 let db = self.cobs_db()?;
780
781 let store = cob::issue::Issues::open(repository, ReadOnly)?;
782
783 db.check_version()?;
784
785 Ok(cob::issue::Cache::reader(store, db))
786 }
787
788 pub fn issues_mut<'a, 'b, Repo, Signer>(
790 &self,
791 repository: &'a Repo,
792 signer: &'b Signer,
793 ) -> Result<cob::issue::Cache<'a, Repo, WriteAs<'b, Signer>, cob::cache::StoreWriter>, Error>
794 where
795 Repo: ReadRepository + cob::Store<Namespace = NodeId>,
796 {
797 let db = self.cobs_db_mut()?;
798 let store = cob::issue::Issues::open(repository, WriteAs::new(signer))?;
799
800 db.check_version()?;
801
802 Ok(cob::issue::Cache::open(store, db))
803 }
804
805 pub fn patches<'a, Repo>(
807 &self,
808 repository: &'a Repo,
809 ) -> Result<cob::patch::Cache<'a, Repo, ReadOnly, cob::cache::StoreReader>, Error>
810 where
811 Repo: ReadRepository + cob::Store<Namespace = NodeId>,
812 {
813 let db = self.cobs_db()?;
814 let store = cob::patch::Patches::open(repository, ReadOnly)?;
815
816 db.check_version()?;
817
818 Ok(cob::patch::Cache::reader(store, db))
819 }
820
821 pub fn patches_mut<'a, 'b, Repo, Signer>(
823 &self,
824 repository: &'a Repo,
825 signer: &'b Signer,
826 ) -> Result<cob::patch::Cache<'a, Repo, WriteAs<'b, Signer>, cob::cache::StoreWriter>, Error>
827 where
828 Repo: ReadRepository + cob::Store<Namespace = NodeId>,
829 {
830 let db = self.cobs_db_mut()?;
831 let store = cob::patch::Patches::open(repository, WriteAs::new(signer))?;
832
833 db.check_version()?;
834
835 Ok(cob::patch::Cache::open(store, db))
836 }
837}
838
839impl Home {
841 fn policies(&self) -> Result<policy::store::StoreReader, policy::store::Error> {
843 let path = self.node().join(node::POLICIES_DB_FILE);
844 let config = policy::store::Store::reader(path)?;
845
846 Ok(config)
847 }
848}
849
850#[cfg(test)]
851#[cfg(not(target_os = "macos"))]
852#[allow(clippy::unwrap_used)]
853mod test {
854 use std::fs;
855
856 use serde_json as json;
857
858 use super::*;
859
860 #[test]
866 fn canonicalize_home() {
867 let tmp = tempfile::tempdir().unwrap();
868 let path = tmp.path().join("Home").join("Radicle");
869 fs::create_dir_all(path.clone()).unwrap();
870 let path = dunce::canonicalize(path).unwrap();
871
872 let last = tmp.path().components().next_back().unwrap();
873 let home = Home::new(
874 tmp.path()
875 .join("..")
876 .join(last)
877 .join("Home")
878 .join("Radicle"),
879 )
880 .unwrap();
881
882 assert_eq!(home.path, path);
883 }
884
885 #[test]
886 fn test_config() {
887 let cfg = json::from_value::<Config>(json::json!({
888 "publicExplorer": "https://app.radicle.example.com/nodes/$host/$rid$path",
889 "preferredSeeds": [],
890 "web": {
891 "pinned": {
892 "repositories": [
893 "rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi",
894 "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"
895 ]
896 }
897 },
898 "cli": { "hints": true },
899 "node": {
900 "alias": "seed.radicle.example.com",
901 "listen": [],
902 "peers": { "type": "dynamic", "target": 8 },
903 "connect": [
904 "z6MkmJzKhSjQz1USfh8NBtaAFyz5gJace9eBV9yFcfMY5BN5@a.radicle.example.com:8776",
905 "z6MkrUZHwJD3pqerEBugSZRxDFdVqKnMUbyPHcFe5gkfFvTe@b.radicle.example.com:8776"
906 ],
907 "externalAddresses": [ "seed.radicle.example.com:8776" ],
908 "db": { "journalMode": "wal" },
909 "network": "main",
910 "log": "INFO",
911 "relay": "always",
912 "limits": {
913 "routingMaxSize": 1000,
914 "routingMaxAge": 604800,
915 "gossipMaxAge": 604800,
916 "fetchConcurrency": 1,
917 "maxOpenFiles": 4096,
918 "rate": {
919 "inbound": { "fillRate": 10.0, "capacity": 2048 },
920 "outbound": { "fillRate": 10.0, "capacity": 2048 }
921 },
922 "connection": { "inbound": 128, "outbound": 16 }
923 },
924 "workers": 32,
925 "policy": "allow",
926 "scope": "all"
927 }
928 }))
929 .unwrap();
930
931 assert!(cfg.node.extra.contains_key("db"));
932 assert!(cfg.node.extra.contains_key("policy"));
933 assert!(cfg.node.extra.contains_key("scope"));
934 }
935}