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, ConfigPath, RawConfig, 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::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    InitConfig(#[from] config::InitError),
167    #[error(transparent)]
168    LoadConfig(#[from] config::LoadError),
169    #[error(transparent)]
170    Node(#[from] node::Error),
171    #[error(transparent)]
172    Routing(#[from] node::routing::Error),
173    #[error(transparent)]
174    Keystore(#[from] keystore::Error),
175    #[error("no radicle profile found at path '{0}'")]
176    NotFound(PathBuf),
177    #[error(transparent)]
178    PolicyStore(#[from] node::policy::store::Error),
179    #[error(transparent)]
180    NotificationsStore(#[from] node::notifications::store::Error),
181    #[error(transparent)]
182    DatabaseStore(#[from] node::db::Error),
183    #[error(transparent)]
184    Repository(#[from] storage::RepositoryError),
185    #[error(transparent)]
186    CobsCache(#[from] cob::cache::Error),
187    #[error(transparent)]
188    Storage(#[from] storage::Error),
189}
190
191#[derive(Debug, Error)]
192pub enum SignerError {
193    #[error(transparent)]
194    MemorySigner(#[from] keystore::MemorySignerError),
195
196    #[error(transparent)]
197    Agent(#[from] crate::crypto::ssh::agent::Error),
198
199    #[error("radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
200    KeyNotRegistered(PublicKey),
201
202    #[error(transparent)]
203    Keystore(#[from] keystore::Error),
204
205    #[error("error connecting to ssh-agent: {source}")]
206    AgentConnection {
207        source: crate::crypto::ssh::agent::Error,
208    },
209}
210
211impl SignerError {
212    /// Some signer errors are potentially recoverable by prompting the user
213    /// for a password.
214    pub fn prompt_for_passphrase(&self) -> bool {
215        matches!(
216            self,
217            Self::AgentConnection { .. } | Self::KeyNotRegistered(_)
218        )
219    }
220}
221
222#[derive(Debug, Clone)]
223pub struct Profile {
224    pub home: Home,
225    pub storage: Storage,
226    pub keystore: Keystore,
227    pub public_key: PublicKey,
228    pub config: Config,
229}
230
231impl Profile {
232    pub fn init(
233        home: Home,
234        alias: Alias,
235        passphrase: Option<Passphrase>,
236        seed: crypto::Seed,
237    ) -> Result<Self, Error> {
238        let keystore = Keystore::new(&home.keys());
239        let public_key = keystore.init("radicle", passphrase, seed)?;
240        let config = Config::init(alias.clone(), home.config().as_path())?;
241        let storage = Storage::open(
242            home.storage(),
243            git::UserInfo {
244                alias,
245                key: public_key,
246            },
247        )?;
248        // Create DBs.
249        home.policies_mut()?;
250        home.notifications_mut()?;
251        home.database_mut()?
252            .journal_mode(node::db::JournalMode::default())?
253            .init(
254                &public_key,
255                config.node.features(),
256                &config.node.alias,
257                &UserAgent::default(),
258                LocalTime::now().into(),
259                config.node.external_addresses.iter(),
260            )?;
261
262        // Migrate COBs cache.
263        let mut cobs = home.cobs_db_mut()?;
264        cobs.migrate(migrate::ignore)?;
265
266        transport::local::register(storage.clone());
267
268        Ok(Profile {
269            home,
270            storage,
271            keystore,
272            public_key,
273            config,
274        })
275    }
276
277    pub fn load() -> Result<Self, Error> {
278        let home = self::home()?;
279        let keystore = Keystore::new(&home.keys());
280        let public_key = keystore
281            .public_key()?
282            .ok_or_else(|| Error::NotFound(home.path().to_path_buf()))?;
283        let config = Config::load(home.config().as_path())?;
284        let storage = Storage::open(
285            home.storage(),
286            git::UserInfo {
287                alias: config.alias().clone(),
288                key: public_key,
289            },
290        )?;
291        transport::local::register(storage.clone());
292
293        Ok(Profile {
294            home,
295            storage,
296            keystore,
297            public_key,
298            config,
299        })
300    }
301
302    pub fn id(&self) -> &PublicKey {
303        &self.public_key
304    }
305
306    pub fn info(&self) -> git::UserInfo {
307        git::UserInfo {
308            alias: self.config.alias().clone(),
309            key: *self.id(),
310        }
311    }
312
313    pub fn hints(&self) -> bool {
314        if env::hints() {
315            return true;
316        }
317        self.config.cli.hints
318    }
319
320    pub fn did(&self) -> Did {
321        Did::from(self.public_key)
322    }
323
324    pub fn signer(&self) -> Result<BoxedDevice, SignerError> {
325        if !self.keystore.is_encrypted()? {
326            let signer = keystore::MemorySigner::load(&self.keystore, None)?;
327            return Ok(Device::from(signer).boxed());
328        }
329
330        if let Some(passphrase) = env::passphrase() {
331            let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
332            return Ok(Device::from(signer).boxed());
333        }
334
335        let agent = Agent::connect().map_err(|source| SignerError::AgentConnection { source })?;
336        let signer = agent.signer(self.public_key);
337        if signer.is_ready()? {
338            Ok(Device::from(signer).boxed())
339        } else {
340            Err(SignerError::KeyNotRegistered(self.public_key))
341        }
342    }
343
344    /// Get radicle home.
345    pub fn home(&self) -> &Home {
346        &self.home
347    }
348
349    /// Return a read-only handle to the policies of the node.
350    pub fn policies(&self) -> Result<policy::config::Config<Read>, policy::store::Error> {
351        let path = self.node().join(node::POLICIES_DB_FILE);
352        let config = policy::config::Config::new(
353            self.config.node.seeding_policy.into(),
354            policy::store::Store::reader(path)?,
355        );
356        Ok(config)
357    }
358
359    /// Return a multi-source store for aliases.
360    pub fn aliases(&self) -> Aliases {
361        let policies = self.home.policies().ok();
362        let db = self.home.database().ok();
363
364        Aliases { policies, db }
365    }
366
367    /// Add the repo to our inventory.
368    /// If the node is offline, adds it directly to the database.
369    pub fn add_inventory(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
370        match node.add_inventory(rid) {
371            Ok(updated) => Ok(updated),
372            Err(e) if e.is_connection_err() => {
373                let now = LocalTime::now();
374                let mut db = self.database_mut()?;
375                let updates =
376                    node::routing::Store::add_inventory(&mut db, [&rid], *self.id(), now.into())?;
377
378                Ok(!updates.is_empty())
379            }
380            Err(e) => Err(e.into()),
381        }
382    }
383
384    /// Seed a repository by first trying to seed through the node, and if the node isn't running,
385    /// by updating the policy database directly. If the repo is available locally, we also add it
386    /// to our inventory.
387    pub fn seed(&self, rid: RepoId, scope: Scope, node: &mut Node) -> Result<bool, Error> {
388        match node.seed(rid, scope) {
389            Ok(updated) => Ok(updated),
390            Err(e) if e.is_connection_err() => {
391                let mut config = self.policies_mut()?;
392                let updated = config.seed(&rid, scope)?;
393
394                Ok(updated)
395            }
396            Err(e) => Err(e.into()),
397        }
398    }
399
400    /// Unseed a repository by first trying to unseed through the node, and if the node isn't
401    /// running, by updating the policy database directly.
402    pub fn unseed(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
403        match node.unseed(rid) {
404            Ok(updated) => Ok(updated),
405            Err(e) if e.is_connection_err() => {
406                let mut config = self.policies_mut()?;
407                let result = config.unseed(&rid)?;
408
409                let mut db = self.database_mut()?;
410                node::routing::Store::remove_inventory(&mut db, &rid, self.id())?;
411
412                Ok(result)
413            }
414            Err(e) => Err(e.into()),
415        }
416    }
417}
418
419impl std::ops::Deref for Profile {
420    type Target = Home;
421
422    fn deref(&self) -> &Self::Target {
423        &self.home
424    }
425}
426
427impl std::ops::DerefMut for Profile {
428    fn deref_mut(&mut self) -> &mut Self::Target {
429        &mut self.home
430    }
431}
432
433impl AliasStore for Profile {
434    fn alias(&self, nid: &NodeId) -> Option<Alias> {
435        self.aliases().alias(nid)
436    }
437
438    fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
439        self.aliases().reverse_lookup(alias)
440    }
441}
442
443/// Holds multiple alias stores, and will try
444/// them one by one when asking for an alias.
445pub struct Aliases {
446    policies: Option<policy::store::StoreReader>,
447    db: Option<node::Database>,
448}
449
450impl AliasStore for Aliases {
451    /// Retrieve `alias` of given node.
452    /// First looks in `policies.db` and then `addresses.db`.
453    fn alias(&self, nid: &NodeId) -> Option<Alias> {
454        self.policies
455            .as_ref()
456            .and_then(|db| db.alias(nid))
457            .or_else(|| self.db.as_ref().and_then(|db| db.alias(nid)))
458    }
459
460    fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
461        let mut nodes = BTreeMap::new();
462        if let Some(db) = self.policies.as_ref() {
463            nodes.extend(db.reverse_lookup(alias));
464        }
465        if let Some(db) = self.db.as_ref() {
466            nodes.extend(db.reverse_lookup(alias));
467        }
468        nodes
469    }
470}
471
472/// Get the path to the radicle home folder.
473pub fn home() -> Result<Home, io::Error> {
474    #[cfg(unix)]
475    const ERROR_MESSAGE_UNSET: &str =
476        "Environment variables `RAD_HOME` and `HOME` are both unset or not valid Unicode.";
477
478    #[cfg(windows)]
479    const ERROR_MESSAGE_UNSET: &str =
480        "Environment variables `RAD_HOME`, `HOME`, and `USERPROFILE` are all unset or not valid Unicode.";
481
482    struct DetectedHome {
483        path: String,
484
485        /// Depending on the detection method, we may need to join `.radicle` to the detected path.
486        join_dot_radicle: bool,
487    }
488
489    let detected = {
490        match env::var(env::RAD_HOME).ok() {
491            Some(path) => Some(DetectedHome {
492                path,
493                join_dot_radicle: false,
494            }),
495            None => env::var("HOME")
496                .ok()
497                .or_else(|| {
498                    cfg!(windows)
499                        .then(|| env::var("USERPROFILE").ok())
500                        .flatten()
501                })
502                .map(|path| DetectedHome {
503                    path,
504                    join_dot_radicle: true,
505                }),
506        }
507    };
508
509    match detected {
510        Some(DetectedHome {
511            path,
512            join_dot_radicle,
513        }) => {
514            let home = {
515                let path = PathBuf::from(path);
516
517                if join_dot_radicle {
518                    path.join(".radicle")
519                } else {
520                    path
521                }
522            };
523
524            Ok(Home::new(home)?)
525        }
526        None => Err(io::Error::new(
527            io::ErrorKind::NotFound,
528            ERROR_MESSAGE_UNSET.to_string(),
529        )),
530    }
531}
532
533/// Radicle home.
534#[derive(Debug, Clone)]
535pub struct Home {
536    path: PathBuf,
537}
538
539impl TryFrom<PathBuf> for Home {
540    type Error = io::Error;
541
542    fn try_from(home: PathBuf) -> Result<Self, Self::Error> {
543        Self::new(home)
544    }
545}
546
547impl Home {
548    /// Creates the Radicle Home directories.
549    ///
550    /// The `home` path is used as the base directory for all
551    /// necessary subdirectories.
552    ///
553    /// If `home` does not already exist then it and any
554    /// subdirectories are created using [`fs::create_dir_all`].
555    ///
556    /// The `home` path is also canonicalized using [`fs::canonicalize`].
557    ///
558    /// All necessary subdirectories are also created.
559    pub fn new(home: impl Into<PathBuf>) -> Result<Self, io::Error> {
560        let path = home.into();
561        if !path.exists() {
562            fs::create_dir_all(path.clone())?;
563        }
564        let home = Self {
565            path: dunce::canonicalize(path)?,
566        };
567
568        for dir in &[home.storage(), home.keys(), home.node(), home.cobs()] {
569            if !dir.exists() {
570                fs::create_dir_all(dir)?;
571            }
572        }
573
574        Ok(home)
575    }
576
577    pub fn path(&self) -> &Path {
578        self.path.as_path()
579    }
580
581    pub fn storage(&self) -> PathBuf {
582        self.path.join("storage")
583    }
584
585    pub fn config(&self) -> PathBuf {
586        self.path.join("config.json")
587    }
588
589    pub fn keys(&self) -> PathBuf {
590        self.path.join("keys")
591    }
592
593    pub fn node(&self) -> PathBuf {
594        self.path.join("node")
595    }
596
597    pub fn cobs(&self) -> PathBuf {
598        self.path.join("cobs")
599    }
600
601    pub fn socket(&self) -> PathBuf {
602        use env::RAD_SOCKET;
603
604        #[cfg(unix)]
605        const DEFAULT_SOCKET_NAME: &str = "control.sock";
606
607        #[cfg(windows)]
608        const DEFAULT_SOCKET_NAME: &str = r#"\\.\pipe\radicle-node"#;
609
610        match env::var_os(RAD_SOCKET).map(PathBuf::from) {
611            None => {
612                #[cfg(unix)]
613                return self.node().join(DEFAULT_SOCKET_NAME);
614
615                #[cfg(windows)]
616                return PathBuf::from(DEFAULT_SOCKET_NAME);
617            }
618            Some(path) => {
619                #[cfg(windows)]
620                {
621                    const PIPE_PREFIX: &str = r#"\\.\pipe\"#;
622                    assert!(path.starts_with(PIPE_PREFIX), "The value of the environment variable {RAD_SOCKET} ('{}') must start with {PIPE_PREFIX}. This restriction might be relaxed in the future.", path.display());
623                }
624
625                path
626            }
627        }
628    }
629
630    /// Return a read-write handle to the notifications database.
631    pub fn notifications_mut(
632        &self,
633    ) -> Result<notifications::StoreWriter, notifications::store::Error> {
634        let path = self.node().join(node::NOTIFICATIONS_DB_FILE);
635        let db = notifications::Store::open(path)?;
636
637        Ok(db)
638    }
639
640    /// Return a read-write handle to the policies store of the node.
641    pub fn policies_mut(&self) -> Result<policy::store::StoreWriter, policy::store::Error> {
642        let path = self.node().join(node::POLICIES_DB_FILE);
643        let config = policy::store::Store::open(path)?;
644
645        Ok(config)
646    }
647
648    /// Return a handle to a read-only database of the node.
649    pub fn database(&self) -> Result<node::Database, node::db::Error> {
650        let path = self.node().join(node::NODE_DB_FILE);
651        let db = node::Database::reader(path)?;
652
653        Ok(db)
654    }
655
656    /// Return a handle to the database of the node.
657    pub fn database_mut(&self) -> Result<node::Database, node::db::Error> {
658        let path = self.node().join(node::NODE_DB_FILE);
659        let db = node::Database::open(path)?;
660
661        Ok(db)
662    }
663
664    /// Returns the address store.
665    pub fn addresses(&self) -> Result<impl node::address::Store, node::db::Error> {
666        self.database_mut()
667    }
668
669    /// Returns the routing store.
670    pub fn routing(&self) -> Result<impl node::routing::Store, node::db::Error> {
671        self.database()
672    }
673
674    /// Returns the routing store, mutably.
675    pub fn routing_mut(&self) -> Result<impl node::routing::Store, node::db::Error> {
676        self.database_mut()
677    }
678
679    /// Get read access to the COBs cache.
680    pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
681        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
682        let db = cob::cache::Store::reader(path)?;
683
684        Ok(db)
685    }
686
687    /// Get write access to the COBs cache.
688    pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
689        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
690        let db = cob::cache::Store::open(path)?;
691
692        Ok(db)
693    }
694
695    /// Return a read-only handle for the issues cache.
696    pub fn issues<'a, R>(
697        &self,
698        repository: &'a R,
699    ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
700    where
701        R: ReadRepository + cob::Store<Namespace = NodeId>,
702    {
703        let db = self.cobs_db()?;
704        let store = cob::issue::Issues::open(repository)?;
705
706        db.check_version()?;
707
708        Ok(cob::issue::Cache::reader(store, db))
709    }
710
711    /// Return a read-write handle for the issues cache.
712    pub fn issues_mut<'a, R>(
713        &self,
714        repository: &'a R,
715    ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
716    where
717        R: ReadRepository + cob::Store<Namespace = NodeId>,
718    {
719        let db = self.cobs_db_mut()?;
720        let store = cob::issue::Issues::open(repository)?;
721
722        db.check_version()?;
723
724        Ok(cob::issue::Cache::open(store, db))
725    }
726
727    /// Return a read-only handle for the patches cache.
728    pub fn patches<'a, R>(
729        &self,
730        repository: &'a R,
731    ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
732    where
733        R: ReadRepository + cob::Store<Namespace = NodeId>,
734    {
735        let db = self.cobs_db()?;
736        let store = cob::patch::Patches::open(repository)?;
737
738        db.check_version()?;
739
740        Ok(cob::patch::Cache::reader(store, db))
741    }
742
743    /// Return a read-write handle for the patches cache.
744    pub fn patches_mut<'a, R>(
745        &self,
746        repository: &'a R,
747    ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
748    where
749        R: ReadRepository + cob::Store<Namespace = NodeId>,
750    {
751        let db = self.cobs_db_mut()?;
752        let store = cob::patch::Patches::open(repository)?;
753
754        db.check_version()?;
755
756        Ok(cob::patch::Cache::open(store, db))
757    }
758}
759
760// Private methods.
761impl Home {
762    /// Return a read-only handle to the policies store of the node.
763    fn policies(&self) -> Result<policy::store::StoreReader, policy::store::Error> {
764        let path = self.node().join(node::POLICIES_DB_FILE);
765        let config = policy::store::Store::reader(path)?;
766
767        Ok(config)
768    }
769}
770
771#[cfg(test)]
772#[cfg(not(target_os = "macos"))]
773#[allow(clippy::unwrap_used)]
774mod test {
775    use std::fs;
776
777    use serde_json as json;
778
779    use super::*;
780
781    // Checks that if we have:
782    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Radicle/Home'
783    //
784    // that it gets normalized to:
785    // '/run/user/1000/.tmpqfK6ih/Radicle/Home'
786    #[test]
787    fn canonicalize_home() {
788        let tmp = tempfile::tempdir().unwrap();
789        let path = tmp.path().join("Home").join("Radicle");
790        fs::create_dir_all(path.clone()).unwrap();
791
792        let last = tmp.path().components().next_back().unwrap();
793        let home = Home::new(
794            tmp.path()
795                .join("..")
796                .join(last)
797                .join("Home")
798                .join("Radicle"),
799        )
800        .unwrap();
801
802        assert_eq!(home.path, path);
803    }
804
805    #[test]
806    fn test_config() {
807        let cfg = json::from_value::<Config>(json::json!({
808          "publicExplorer": "https://app.radicle.example.com/nodes/$host/$rid$path",
809          "preferredSeeds": [],
810          "web": {
811            "pinned": {
812              "repositories": [
813                "rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi",
814                "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"
815              ]
816            }
817          },
818          "cli": { "hints": true },
819          "node": {
820            "alias": "seed.radicle.example.com",
821            "listen": [],
822            "peers": { "type": "dynamic", "target": 8 },
823            "connect": [
824              "z6MkmJzKhSjQz1USfh8NBtaAFyz5gJace9eBV9yFcfMY5BN5@a.radicle.example.com:8776",
825              "z6MkrUZHwJD3pqerEBugSZRxDFdVqKnMUbyPHcFe5gkfFvTe@b.radicle.example.com:8776"
826            ],
827            "externalAddresses": [ "seed.radicle.example.com:8776" ],
828            "db": { "journalMode": "wal" },
829            "network": "main",
830            "log": "INFO",
831            "relay": "always",
832            "limits": {
833              "routingMaxSize": 1000,
834              "routingMaxAge": 604800,
835              "gossipMaxAge": 604800,
836              "fetchConcurrency": 1,
837              "maxOpenFiles": 4096,
838              "rate": {
839                "inbound": { "fillRate": 10.0, "capacity": 2048 },
840                "outbound": { "fillRate": 10.0, "capacity": 2048 }
841              },
842              "connection": { "inbound": 128, "outbound": 16 }
843            },
844            "workers": 32,
845            "policy": "allow",
846            "scope": "all"
847          }
848        }))
849        .unwrap();
850
851        assert!(cfg.node.extra.contains_key("db"));
852        assert!(cfg.node.extra.contains_key("policy"));
853        assert!(cfg.node.extra.contains_key("scope"));
854    }
855}