Skip to main content

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, 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
38/// Environment variables used by Radicle.
39pub mod env {
40    pub use std::env::*;
41
42    /// Path to the Radicle home folder.
43    pub const RAD_HOME: &str = "RAD_HOME";
44    /// Path to the Radicle node socket file.
45    pub const RAD_SOCKET: &str = "RAD_SOCKET";
46    /// Passphrase for the encrypted Radicle secret key.
47    pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
48    /// RNG seed. Must be convertible to a `u64`.
49    pub const RAD_RNG_SEED: &str = "RAD_RNG_SEED";
50    /// Private key seed. Used for generating deterministic keypairs.
51    pub const RAD_KEYGEN_SEED: &str = "RAD_KEYGEN_SEED";
52    /// Show Radicle hints.
53    pub const RAD_HINT: &str = "RAD_HINT";
54    /// Environment variable to set to overwrite the commit date for both
55    /// the author and the committer.
56    ///
57    /// The format must be a unix timestamp.
58    pub const RAD_COMMIT_TIME: &str = "RAD_COMMIT_TIME";
59    /// Override the device's local time.
60    /// The format must be a unix timestamp.
61    pub const RAD_LOCAL_TIME: &str = "RAD_LOCAL_TIME";
62    // Turn debug mode on.
63    pub const RAD_DEBUG: &str = "RAD_DEBUG";
64    // Used to set the Git committer timestamp. Can be overridden
65    // to generate deterministic COB IDs.
66    pub const GIT_COMMITTER_DATE: &str = "GIT_COMMITTER_DATE";
67
68    /// Commit timestamp to use. Can be overridden by [`RAD_COMMIT_TIME`].
69    pub fn commit_time() -> localtime::LocalTime {
70        time(RAD_COMMIT_TIME).unwrap_or_else(local_time)
71    }
72
73    /// Local time. Can be overridden by [`RAD_LOCAL_TIME`].
74    pub fn local_time() -> localtime::LocalTime {
75        time(RAD_LOCAL_TIME).unwrap_or_else(localtime::LocalTime::now)
76    }
77
78    /// Whether debug mode is on.
79    pub fn debug() -> bool {
80        var(RAD_DEBUG).is_ok()
81    }
82
83    /// Whether or not to show hints.
84    pub fn hints() -> bool {
85        var(RAD_HINT).is_ok()
86    }
87
88    /// Get the configured pager program from the environment.
89    pub fn pager() -> Option<String> {
90        // On Windows, custom pagers configured via Git are not supported,
91        // because of the complexity surrounding how the pager command is
92        // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
93        #[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    /// Get the Radicle passphrase from the environment.
106    pub fn passphrase() -> Option<super::Passphrase> {
107        let Ok(passphrase) = var(RAD_PASSPHRASE) else {
108            return None;
109        };
110        if passphrase.is_empty() {
111            // `ssh-keygen` treats the empty string as no passphrase,
112            // so we do the same.
113            log::trace!(target: "radicle", "Treating empty passphrase as no passphrase.");
114            return None;
115        }
116        Some(super::Passphrase::from(passphrase))
117    }
118
119    /// Get a random number generator from the environment.
120    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    /// Return the seed stored in the [`RAD_KEYGEN_SEED`] environment variable,
132    /// or generate a random one.
133    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    /// Some signer errors are potentially recoverable by prompting the user
216    /// for a password.
217    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        // Create DBs.
252        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        // Migrate COBs cache.
264        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    /// Get Radicle home.
346    pub fn home(&self) -> &Home {
347        &self.home
348    }
349
350    /// Return a read-only handle to the policies of the node.
351    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    /// Return a multi-source store for aliases.
361    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    /// Add the repo to our inventory.
369    /// If the node is offline, adds it directly to the database.
370    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    /// Seed a repository by first trying to seed through the node, and if the node isn't running,
386    /// by updating the policy database directly. If the repo is available locally, we also add it
387    /// to our inventory.
388    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    /// Unseed a repository by first trying to unseed through the node, and if the node isn't
402    /// running, by updating the policy database directly.
403    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    /// Return a handle to the database of the node, with SQLite configuration
420    /// from [`Self::config`] applied.
421    pub fn database_mut(&self) -> Result<node::Database, node::db::Error> {
422        self.home.database_mut(self.config.node.database)
423    }
424
425    /// Return a handle to a read-only database of the node, with SQLite
426    /// configuration from [`Self::config`] applied.
427    pub fn database(&self) -> Result<node::Database, node::db::Error> {
428        self.home.database(self.config.node.database)
429    }
430
431    /// Returns the routing store, with SQLite
432    /// configuration from [`Self::config`] applied.
433    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
462/// Holds multiple alias stores, and will try
463/// them one by one when asking for an alias.
464pub struct Aliases {
465    policies: Option<policy::store::StoreReader>,
466    db: Option<node::Database>,
467}
468
469impl AliasStore for Aliases {
470    /// Retrieve `alias` of given node.
471    /// First looks in `policies.db` and then `addresses.db`.
472    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
491/// Get the path to the Radicle home folder.
492pub 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        /// Depending on the detection method, we may need to join `.radicle` to the detected path.
504        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/// Radicle home.
552#[derive(Debug, Clone)]
553pub struct Home {
554    path: PathBuf,
555}
556
557impl Home {
558    /// Creates the Radicle Home directories.
559    ///
560    /// The `home` path is used as the base directory for all
561    /// necessary subdirectories.
562    ///
563    /// If `home` does not already exist then it and any
564    /// subdirectories are created using [`fs::create_dir_all`].
565    ///
566    /// The `home` path is also canonicalized using [`fs::canonicalize`].
567    ///
568    /// All necessary subdirectories are also created.
569    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    /// Load existing Radicle Home directories.
588    ///
589    /// The `home` path is the expected base directory for all necessary
590    /// subdirectories.
591    ///
592    /// # Errors
593    ///
594    /// If `home` or any of the subdirectories are missing an [`io::Error`] is
595    /// returned.
596    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    /// The set of directories found under the [`Home`] directory path.
631    ///
632    /// List of directories:
633    /// - [`Home::storage`]
634    /// - [`Home::keys`]
635    /// - [`Home::node`]
636    /// - [`Home::cobs`]
637    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    /// The `/storage` directory under [`Home::path`].
646    pub fn storage(&self) -> PathBuf {
647        self.path.join("storage")
648    }
649
650    /// The `config.json` file path under [`Home::path`].
651    pub fn config(&self) -> PathBuf {
652        self.path.join("config.json")
653    }
654
655    /// The `/keys` directory under [`Home::path`].
656    pub fn keys(&self) -> PathBuf {
657        self.path.join("keys")
658    }
659
660    /// The `/node` directory under [`Home::path`].
661    pub fn node(&self) -> PathBuf {
662        self.path.join("node")
663    }
664
665    /// The `/cobs` directory under [`Home::path`].
666    pub fn cobs(&self) -> PathBuf {
667        self.path.join("cobs")
668    }
669
670    /// The location of the control socket of the node.
671    /// If the environment variable with name [`env::RAD_SOCKET`] is set,
672    /// its value is used.
673    /// Otherwise, the default socket name, which is relative to this
674    /// [`Home`], is used (see [`Self::socket_default`]).
675    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    /// The default location of the control socket of the node.
682    /// The returned value only depends on `self`, and not on
683    /// any environment variables.
684    ///
685    /// See also [`Self::socket_from_env`].
686    pub fn socket_default(&self) -> PathBuf {
687        const DEFAULT_SOCKET_NAME: &str = "control.sock";
688        self.node().join(DEFAULT_SOCKET_NAME)
689    }
690
691    /// Return a read-write handle to the notifications database.
692    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    /// Return a read-write handle to the policies store of the node.
702    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    /// Return a handle to a read-only database of the node.
710    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    /// Return a handle to the database of the node.
721    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    /// Returns the address store.
732    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    /// Returns the routing store.
740    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    /// Returns the routing store, mutably.
748    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    /// Get read access to the COBs cache.
756    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    /// Get write access to the COBs cache.
764    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    /// Return a read-only handle for the issues cache.
772    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    /// Return a read-write handle for the issues cache.
789    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    /// Return a read-only handle for the patches cache.
806    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    /// Return a read-write handle for the patches cache.
822    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
839// Private methods.
840impl Home {
841    /// Return a read-only handle to the policies store of the node.
842    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    // Checks that if we have:
861    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Home/Radicle'
862    //
863    // that it gets normalized to:
864    // '/run/user/1000/.tmpqfK6ih/Home/Radicle'
865    #[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}