Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle src profile.rs
//! Radicle node profile.
//!
//!   $RAD_HOME/                                 # Radicle home
//!     storage/                                 # Storage root
//!       zEQNunJUqkNahQ8VvQYuWZZV7EJB/          # Project git repository
//!       ...                                    # More projects...
//!     keys/
//!       radicle                                # Secret key (PKCS 8)
//!       radicle.pub                            # Public key (PKCS 8)
//!     node/
//!       control.sock                           # Node control socket
//!

pub mod config;
pub use config::{Config, WriteError};

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::{fs, io};

use localtime::LocalTime;
use thiserror::Error;

use crate::cob::migrate;
use crate::cob::store::access::{ReadOnly, WriteAs};
use crate::crypto::PublicKey;
use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{Keystore, Passphrase, keystore};
use crate::node::device::{BoxedDevice, Device};
use crate::node::policy::config::store::Read;
use crate::node::{Alias, AliasStore, Handle as _, Node, notifications, policy, policy::Scope};
use crate::prelude::{Did, NodeId, RepoId};
use crate::storage::ReadRepository;
use crate::storage::git::Storage;
use crate::storage::git::transport;
use crate::{cob, git, node, storage};

/// Environment variables used by Radicle.
pub mod env {
    pub use std::env::*;

    /// Path to the Radicle home folder.
    pub const RAD_HOME: &str = "RAD_HOME";
    /// Path to the Radicle node socket file.
    pub const RAD_SOCKET: &str = "RAD_SOCKET";
    /// Passphrase for the encrypted Radicle secret key.
    pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
    /// RNG seed. Must be convertible to a `u64`.
    pub const RAD_RNG_SEED: &str = "RAD_RNG_SEED";
    /// Private key seed. Used for generating deterministic keypairs.
    pub const RAD_KEYGEN_SEED: &str = "RAD_KEYGEN_SEED";
    /// Show Radicle hints.
    pub const RAD_HINT: &str = "RAD_HINT";
    /// Environment variable to set to overwrite the commit date for both
    /// the author and the committer.
    ///
    /// The format must be a unix timestamp.
    pub const RAD_COMMIT_TIME: &str = "RAD_COMMIT_TIME";
    /// Override the device's local time.
    /// The format must be a unix timestamp.
    pub const RAD_LOCAL_TIME: &str = "RAD_LOCAL_TIME";
    // Turn debug mode on.
    pub const RAD_DEBUG: &str = "RAD_DEBUG";
    // Used to set the Git committer timestamp. Can be overridden
    // to generate deterministic COB IDs.
    pub const GIT_COMMITTER_DATE: &str = "GIT_COMMITTER_DATE";

    /// Commit timestamp to use. Can be overridden by [`RAD_COMMIT_TIME`].
    pub fn commit_time() -> localtime::LocalTime {
        time(RAD_COMMIT_TIME).unwrap_or_else(local_time)
    }

    /// Local time. Can be overridden by [`RAD_LOCAL_TIME`].
    pub fn local_time() -> localtime::LocalTime {
        time(RAD_LOCAL_TIME).unwrap_or_else(localtime::LocalTime::now)
    }

    /// Whether debug mode is on.
    pub fn debug() -> bool {
        var(RAD_DEBUG).is_ok()
    }

    /// Whether or not to show hints.
    pub fn hints() -> bool {
        var(RAD_HINT).is_ok()
    }

    /// Get the configured pager program from the environment.
    pub fn pager() -> Option<String> {
        // On Windows, custom pagers configured via Git are not supported,
        // because of the complexity surrounding how the pager command is
        // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
        #[cfg(not(windows))]
        if let Ok(cfg) = crate::git::raw::Config::open_default() {
            if let Ok(pager) = cfg.get_string("core.pager") {
                return Some(pager);
            }
        }
        if let Ok(pager) = var("PAGER") {
            return Some(pager);
        }
        None
    }

    /// Get the Radicle passphrase from the environment.
    pub fn passphrase() -> Option<super::Passphrase> {
        let Ok(passphrase) = var(RAD_PASSPHRASE) else {
            return None;
        };
        if passphrase.is_empty() {
            // `ssh-keygen` treats the empty string as no passphrase,
            // so we do the same.
            log::trace!(target: "radicle", "Treating empty passphrase as no passphrase.");
            return None;
        }
        Some(super::Passphrase::from(passphrase))
    }

    /// Get a random number generator from the environment.
    pub fn rng() -> fastrand::Rng {
        if let Ok(seed) = var(RAD_RNG_SEED) {
            let Ok(seed) = seed.parse() else {
                panic!("env::rng: invalid seed specified in `{RAD_RNG_SEED}`");
            };
            fastrand::Rng::with_seed(seed)
        } else {
            fastrand::Rng::new()
        }
    }

    /// Return the seed stored in the [`RAD_KEYGEN_SEED`] environment variable,
    /// or generate a random one.
    pub fn seed() -> crypto::Seed {
        if let Ok(seed) = var(RAD_KEYGEN_SEED) {
            let Ok(seed) = (0..seed.len())
                .step_by(2)
                .map(|i| u8::from_str_radix(&seed[i..i + 2], 16))
                .collect::<Result<Vec<u8>, _>>()
            else {
                panic!("env::seed: invalid hexadecimal value set in `{RAD_KEYGEN_SEED}`");
            };
            let Ok(seed): Result<[u8; 32], _> = seed.try_into() else {
                panic!("env::seed: invalid seed length set in `{RAD_KEYGEN_SEED}`");
            };
            crypto::Seed::new(seed)
        } else {
            crypto::Seed::generate()
        }
    }

    fn time(key: &str) -> Option<localtime::LocalTime> {
        if let Ok(s) = var(key) {
            match s.trim().parse::<u64>() {
                Ok(t) => return Some(localtime::LocalTime::from_secs(t)),
                Err(e) => {
                    panic!("env::time: invalid value {s:?} for `{key}` environment variable: {e}");
                }
            }
        }
        None
    }
}

#[derive(Debug, Error)]
pub enum Error {
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error(transparent)]
    InitConfig(#[from] config::InitError),
    #[error(transparent)]
    LoadConfig(#[from] config::LoadError),
    #[error(transparent)]
    Node(#[from] node::Error),
    #[error(transparent)]
    Routing(#[from] node::routing::Error),
    #[error(transparent)]
    Keystore(#[from] keystore::Error),
    #[error("no Radicle profile found at path '{0}'")]
    NotFound(PathBuf),
    #[error(transparent)]
    PolicyStore(#[from] node::policy::store::Error),
    #[error(transparent)]
    NotificationsStore(#[from] node::notifications::store::Error),
    #[error(transparent)]
    DatabaseStore(#[from] node::db::Error),
    #[error(transparent)]
    Repository(#[from] storage::RepositoryError),
    #[error(transparent)]
    CobsCache(#[from] cob::cache::Error),
    #[error(transparent)]
    Storage(#[from] storage::Error),
}

#[derive(Debug, Error)]
pub enum SignerError {
    #[error(transparent)]
    MemorySigner(#[from] keystore::MemorySignerError),

    #[error(transparent)]
    Agent(#[from] crate::crypto::ssh::agent::AgentError),

    #[error("Radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
    KeyNotRegistered(PublicKey),

    #[error(transparent)]
    Keystore(#[from] keystore::Error),

    #[error("error connecting to ssh-agent: {source}")]
    AgentConnection {
        source: crate::crypto::ssh::agent::ConnectError,
    },
}

impl SignerError {
    /// Some signer errors are potentially recoverable by prompting the user
    /// for a password.
    pub fn prompt_for_passphrase(&self) -> bool {
        matches!(
            self,
            Self::AgentConnection { .. } | Self::KeyNotRegistered(_)
        )
    }
}

#[derive(Debug, Clone)]
pub struct Profile {
    pub home: Home,
    pub storage: Storage,
    pub keystore: Keystore,
    pub public_key: PublicKey,
    pub config: Config,
}

impl Profile {
    pub fn init(
        home: Home,
        alias: Alias,
        passphrase: Option<Passphrase>,
        seed: crypto::Seed,
    ) -> Result<Self, Error> {
        let keystore = Keystore::new(&home.keys());
        let public_key = keystore.init("radicle", passphrase, seed)?;
        let config = Config::init(alias.clone(), home.config().as_path())?;
        let storage = Storage::open(
            home.storage(),
            git::UserInfo {
                alias,
                key: public_key,
            },
        )?;
        // Create DBs.
        home.policies_mut()?;
        home.notifications_mut()?;
        home.database_mut(config.node.database)?.init(
            &public_key,
            config.node.features(),
            &config.node.alias,
            &config.node.user_agent(),
            LocalTime::now().into(),
            config.node.external_addresses.iter(),
        )?;

        // Migrate COBs cache.
        let mut cobs = home.cobs_db_mut()?;
        cobs.migrate(migrate::ignore)?;

        transport::local::register(storage.clone());

        Ok(Profile {
            home,
            storage,
            keystore,
            public_key,
            config,
        })
    }

    pub fn load() -> Result<Self, Error> {
        let home = self::home()?;
        let keystore = Keystore::new(&home.keys());
        let public_key = keystore
            .public_key()?
            .ok_or_else(|| Error::NotFound(home.path().to_path_buf()))?;
        let config = Config::load(home.config().as_path())?;
        let storage = Storage::open(
            home.storage(),
            git::UserInfo {
                alias: config.alias().clone(),
                key: public_key,
            },
        )?;
        transport::local::register(storage.clone());

        Ok(Profile {
            home,
            storage,
            keystore,
            public_key,
            config,
        })
    }

    pub fn id(&self) -> &PublicKey {
        &self.public_key
    }

    pub fn info(&self) -> git::UserInfo {
        git::UserInfo {
            alias: self.config.alias().clone(),
            key: *self.id(),
        }
    }

    pub fn hints(&self) -> bool {
        if env::hints() {
            return true;
        }
        self.config.cli.hints
    }

    pub fn did(&self) -> Did {
        Did::from(self.public_key)
    }

    pub fn signer(&self) -> Result<BoxedDevice, SignerError> {
        if !self.keystore.is_encrypted()? {
            let signer = keystore::MemorySigner::load(&self.keystore, None)?;
            return Ok(Device::from(signer).boxed());
        }

        if let Some(passphrase) = env::passphrase() {
            let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
            return Ok(Device::from(signer).boxed());
        }

        let agent = Agent::connect().map_err(|source| SignerError::AgentConnection { source })?;
        let signer = agent.signer(self.public_key);
        if signer.is_ready()? {
            Ok(Device::from(signer).boxed())
        } else {
            Err(SignerError::KeyNotRegistered(self.public_key))
        }
    }

    /// Get Radicle home.
    pub fn home(&self) -> &Home {
        &self.home
    }

    /// Return a read-only handle to the policies of the node.
    pub fn policies(&self) -> Result<policy::config::Config<Read>, policy::store::Error> {
        let path = self.node().join(node::POLICIES_DB_FILE);
        let config = policy::config::Config::new(
            self.config.node.seeding_policy.into(),
            policy::store::Store::reader(path)?,
        );
        Ok(config)
    }

    /// Return a multi-source store for aliases.
    pub fn aliases(&self) -> Aliases {
        let policies = self.home.policies().ok();
        let db = self.home.database(self.config.node.database).ok();

        Aliases { policies, db }
    }

    /// Add the repo to our inventory.
    /// If the node is offline, adds it directly to the database.
    pub fn add_inventory(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
        match node.add_inventory(rid) {
            Ok(updated) => Ok(updated),
            Err(e) if e.is_connection_err() => {
                let now = LocalTime::now();
                let mut db = self.database_mut()?;
                let updates =
                    node::routing::Store::add_inventory(&mut db, [&rid], *self.id(), now.into())?;

                Ok(!updates.is_empty())
            }
            Err(e) => Err(e.into()),
        }
    }

    /// Seed a repository by first trying to seed through the node, and if the node isn't running,
    /// by updating the policy database directly. If the repo is available locally, we also add it
    /// to our inventory.
    pub fn seed(&self, rid: RepoId, scope: Scope, node: &mut Node) -> Result<bool, Error> {
        match node.seed(rid, scope) {
            Ok(updated) => Ok(updated),
            Err(e) if e.is_connection_err() => {
                let mut config = self.policies_mut()?;
                let updated = config.seed(&rid, scope)?;

                Ok(updated)
            }
            Err(e) => Err(e.into()),
        }
    }

    /// Unseed a repository by first trying to unseed through the node, and if the node isn't
    /// running, by updating the policy database directly.
    pub fn unseed(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
        match node.unseed(rid) {
            Ok(updated) => Ok(updated),
            Err(e) if e.is_connection_err() => {
                let mut config = self.policies_mut()?;
                let result = config.unseed(&rid)?;

                let mut db = self.database_mut()?;
                node::routing::Store::remove_inventory(&mut db, &rid, self.id())?;

                Ok(result)
            }
            Err(e) => Err(e.into()),
        }
    }

    /// Return a handle to the database of the node, with SQLite configuration
    /// from [`Self::config`] applied.
    pub fn database_mut(&self) -> Result<node::Database, node::db::Error> {
        self.home.database_mut(self.config.node.database)
    }

    /// Return a handle to a read-only database of the node, with SQLite
    /// configuration from [`Self::config`] applied.
    pub fn database(&self) -> Result<node::Database, node::db::Error> {
        self.home.database(self.config.node.database)
    }

    /// Returns the routing store, with SQLite
    /// configuration from [`Self::config`] applied.
    pub fn routing(&self) -> Result<impl node::routing::Store + use<>, node::db::Error> {
        self.home.routing(self.config.node.database)
    }
}

impl std::ops::Deref for Profile {
    type Target = Home;

    fn deref(&self) -> &Self::Target {
        &self.home
    }
}

impl std::ops::DerefMut for Profile {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.home
    }
}

impl AliasStore for Profile {
    fn alias(&self, nid: &NodeId) -> Option<Alias> {
        self.aliases().alias(nid)
    }

    fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
        self.aliases().reverse_lookup(alias)
    }
}

/// Holds multiple alias stores, and will try
/// them one by one when asking for an alias.
pub struct Aliases {
    policies: Option<policy::store::StoreReader>,
    db: Option<node::Database>,
}

impl AliasStore for Aliases {
    /// Retrieve `alias` of given node.
    /// First looks in `policies.db` and then `addresses.db`.
    fn alias(&self, nid: &NodeId) -> Option<Alias> {
        self.policies
            .as_ref()
            .and_then(|db| db.alias(nid))
            .or_else(|| self.db.as_ref().and_then(|db| db.alias(nid)))
    }

    fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
        let mut nodes = BTreeMap::new();
        if let Some(db) = self.policies.as_ref() {
            nodes.extend(db.reverse_lookup(alias));
        }
        if let Some(db) = self.db.as_ref() {
            nodes.extend(db.reverse_lookup(alias));
        }
        nodes
    }
}

/// Get the path to the Radicle home folder.
pub fn home() -> Result<Home, io::Error> {
    #[cfg(unix)]
    const ERROR_MESSAGE_UNSET: &str =
        "Environment variables `RAD_HOME` and `HOME` are both unset or not valid Unicode.";

    #[cfg(windows)]
    const ERROR_MESSAGE_UNSET: &str = "Environment variables `RAD_HOME`, `HOME`, and `USERPROFILE` are all unset or not valid Unicode.";

    struct DetectedHome {
        path: String,

        /// Depending on the detection method, we may need to join `.radicle` to the detected path.
        join_dot_radicle: bool,
    }

    let detected = {
        match env::var(env::RAD_HOME).ok() {
            Some(path) => Some(DetectedHome {
                path,
                join_dot_radicle: false,
            }),
            None => env::var("HOME")
                .ok()
                .or_else(|| {
                    cfg!(windows)
                        .then(|| env::var("USERPROFILE").ok())
                        .flatten()
                })
                .map(|path| DetectedHome {
                    path,
                    join_dot_radicle: true,
                }),
        }
    };

    match detected {
        Some(DetectedHome {
            path,
            join_dot_radicle,
        }) => {
            let home = {
                let path = PathBuf::from(path);

                if join_dot_radicle {
                    path.join(".radicle")
                } else {
                    path
                }
            };

            Ok(Home::new(home)?)
        }
        None => Err(io::Error::new(
            io::ErrorKind::NotFound,
            ERROR_MESSAGE_UNSET.to_string(),
        )),
    }
}

/// Radicle home.
#[derive(Debug, Clone)]
pub struct Home {
    path: PathBuf,
}

impl Home {
    /// Creates the Radicle Home directories.
    ///
    /// The `home` path is used as the base directory for all
    /// necessary subdirectories.
    ///
    /// If `home` does not already exist then it and any
    /// subdirectories are created using [`fs::create_dir_all`].
    ///
    /// The `home` path is also canonicalized using [`fs::canonicalize`].
    ///
    /// All necessary subdirectories are also created.
    pub fn new(home: impl Into<PathBuf>) -> Result<Self, io::Error> {
        let path = home.into();
        if !path.exists() {
            fs::create_dir_all(path.clone())?;
        }
        let home = Self {
            path: dunce::canonicalize(path)?,
        };

        for dir in &home.subdirectories() {
            if !dir.exists() {
                fs::create_dir_all(dir)?;
            }
        }

        Ok(home)
    }

    /// Load existing Radicle Home directories.
    ///
    /// The `home` path is the expected base directory for all necessary
    /// subdirectories.
    ///
    /// # Errors
    ///
    /// If `home` or any of the subdirectories are missing an [`io::Error`] is
    /// returned.
    pub fn load<P>(home: P) -> Result<Self, io::Error>
    where
        P: AsRef<Path>,
    {
        let path = dunce::canonicalize(home.as_ref())?;
        if !path.exists() {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                format!("Radicle home directory does not exist: {}", path.display()),
            ));
        }
        let home = Self { path };

        let missing = home
            .subdirectories()
            .into_iter()
            .filter(|dir| !dir.exists())
            .collect::<Vec<_>>();

        if !missing.is_empty() {
            let missing = missing
                .into_iter()
                .map(|dir| dir.display().to_string())
                .collect::<Vec<_>>()
                .join(",");
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                format!("Required Radicle directories are missing: [{}]", missing),
            ));
        }

        Ok(home)
    }

    /// The set of directories found under the [`Home`] directory path.
    ///
    /// List of directories:
    /// - [`Home::storage`]
    /// - [`Home::keys`]
    /// - [`Home::node`]
    /// - [`Home::cobs`]
    fn subdirectories(&self) -> [PathBuf; 4] {
        [self.storage(), self.keys(), self.node(), self.cobs()]
    }

    pub fn path(&self) -> &Path {
        self.path.as_path()
    }

    /// The `/storage` directory under [`Home::path`].
    pub fn storage(&self) -> PathBuf {
        self.path.join("storage")
    }

    /// The `config.json` file path under [`Home::path`].
    pub fn config(&self) -> PathBuf {
        self.path.join("config.json")
    }

    /// The `/keys` directory under [`Home::path`].
    pub fn keys(&self) -> PathBuf {
        self.path.join("keys")
    }

    /// The `/node` directory under [`Home::path`].
    pub fn node(&self) -> PathBuf {
        self.path.join("node")
    }

    /// The `/cobs` directory under [`Home::path`].
    pub fn cobs(&self) -> PathBuf {
        self.path.join("cobs")
    }

    /// The location of the control socket of the node.
    /// If the environment variable with name [`env::RAD_SOCKET`] is set,
    /// its value is used.
    /// Otherwise, the default socket name, which is relative to this
    /// [`Home`], is used (see [`Self::socket_default`]).
    pub fn socket_from_env(&self) -> PathBuf {
        env::var_os(env::RAD_SOCKET)
            .map(PathBuf::from)
            .unwrap_or_else(|| self.socket_default())
    }

    /// The default location of the control socket of the node.
    /// The returned value only depends on `self`, and not on
    /// any environment variables.
    ///
    /// See also [`Self::socket_from_env`].
    pub fn socket_default(&self) -> PathBuf {
        const DEFAULT_SOCKET_NAME: &str = "control.sock";
        self.node().join(DEFAULT_SOCKET_NAME)
    }

    /// Return a read-write handle to the notifications database.
    pub fn notifications_mut(
        &self,
    ) -> Result<notifications::StoreWriter, notifications::store::Error> {
        let path = self.node().join(node::NOTIFICATIONS_DB_FILE);
        let db = notifications::Store::open(path)?;

        Ok(db)
    }

    /// Return a read-write handle to the policies store of the node.
    pub fn policies_mut(&self) -> Result<policy::store::StoreWriter, policy::store::Error> {
        let path = self.node().join(node::POLICIES_DB_FILE);
        let config = policy::store::Store::open(path)?;

        Ok(config)
    }

    /// Return a handle to a read-only database of the node.
    pub fn database(
        &self,
        config: node::db::config::Config,
    ) -> Result<node::Database, node::db::Error> {
        let path = self.node().join(node::NODE_DB_FILE);
        let db = node::Database::reader(path, config)?;

        Ok(db)
    }

    /// Return a handle to the database of the node.
    pub fn database_mut(
        &self,
        config: node::db::config::Config,
    ) -> Result<node::Database, node::db::Error> {
        let path = self.node().join(node::NODE_DB_FILE);
        let db = node::Database::open(path, config)?;

        Ok(db)
    }

    /// Returns the address store.
    pub fn addresses(
        &self,
        config: node::db::config::Config,
    ) -> Result<impl node::address::Store + use<>, node::db::Error> {
        self.database_mut(config)
    }

    /// Returns the routing store.
    pub fn routing(
        &self,
        config: node::db::config::Config,
    ) -> Result<impl node::routing::Store + use<>, node::db::Error> {
        self.database(config)
    }

    /// Returns the routing store, mutably.
    pub fn routing_mut(
        &self,
        config: node::db::config::Config,
    ) -> Result<impl node::routing::Store + use<>, node::db::Error> {
        self.database_mut(config)
    }

    /// Get read access to the COBs cache.
    pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
        let db = cob::cache::Store::reader(path)?;

        Ok(db)
    }

    /// Get write access to the COBs cache.
    pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
        let db = cob::cache::Store::open(path)?;

        Ok(db)
    }

    /// Return a read-only handle for the issues cache.
    pub fn issues<'a, Repo>(
        &self,
        repository: &'a Repo,
    ) -> Result<cob::issue::Cache<'a, Repo, ReadOnly, cob::cache::StoreReader>, Error>
    where
        Repo: ReadRepository + cob::Store<Namespace = NodeId>,
    {
        let db = self.cobs_db()?;

        let store = cob::issue::Issues::open(repository, ReadOnly)?;

        db.check_version()?;

        Ok(cob::issue::Cache::reader(store, db))
    }

    /// Return a read-write handle for the issues cache.
    pub fn issues_mut<'a, 'b, Repo, Signer>(
        &self,
        repository: &'a Repo,
        signer: &'b Signer,
    ) -> Result<cob::issue::Cache<'a, Repo, WriteAs<'b, Signer>, cob::cache::StoreWriter>, Error>
    where
        Repo: ReadRepository + cob::Store<Namespace = NodeId>,
    {
        let db = self.cobs_db_mut()?;
        let store = cob::issue::Issues::open(repository, WriteAs::new(signer))?;

        db.check_version()?;

        Ok(cob::issue::Cache::open(store, db))
    }

    /// Return a read-only handle for the patches cache.
    pub fn patches<'a, Repo>(
        &self,
        repository: &'a Repo,
    ) -> Result<cob::patch::Cache<'a, Repo, ReadOnly, cob::cache::StoreReader>, Error>
    where
        Repo: ReadRepository + cob::Store<Namespace = NodeId>,
    {
        let db = self.cobs_db()?;
        let store = cob::patch::Patches::open(repository, ReadOnly)?;

        db.check_version()?;

        Ok(cob::patch::Cache::reader(store, db))
    }

    /// Return a read-write handle for the patches cache.
    pub fn patches_mut<'a, 'b, Repo, Signer>(
        &self,
        repository: &'a Repo,
        signer: &'b Signer,
    ) -> Result<cob::patch::Cache<'a, Repo, WriteAs<'b, Signer>, cob::cache::StoreWriter>, Error>
    where
        Repo: ReadRepository + cob::Store<Namespace = NodeId>,
    {
        let db = self.cobs_db_mut()?;
        let store = cob::patch::Patches::open(repository, WriteAs::new(signer))?;

        db.check_version()?;

        Ok(cob::patch::Cache::open(store, db))
    }
}

// Private methods.
impl Home {
    /// Return a read-only handle to the policies store of the node.
    fn policies(&self) -> Result<policy::store::StoreReader, policy::store::Error> {
        let path = self.node().join(node::POLICIES_DB_FILE);
        let config = policy::store::Store::reader(path)?;

        Ok(config)
    }
}

#[cfg(test)]
#[cfg(not(target_os = "macos"))]
#[allow(clippy::unwrap_used)]
mod test {
    use std::fs;

    use serde_json as json;

    use super::*;

    // Checks that if we have:
    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Home/Radicle'
    //
    // that it gets normalized to:
    // '/run/user/1000/.tmpqfK6ih/Home/Radicle'
    #[test]
    fn canonicalize_home() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("Home").join("Radicle");
        fs::create_dir_all(path.clone()).unwrap();
        let path = dunce::canonicalize(path).unwrap();

        let last = tmp.path().components().next_back().unwrap();
        let home = Home::new(
            tmp.path()
                .join("..")
                .join(last)
                .join("Home")
                .join("Radicle"),
        )
        .unwrap();

        assert_eq!(home.path, path);
    }

    #[test]
    fn test_config() {
        let cfg = json::from_value::<Config>(json::json!({
          "publicExplorer": "https://app.radicle.example.com/nodes/$host/$rid$path",
          "preferredSeeds": [],
          "web": {
            "pinned": {
              "repositories": [
                "rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi",
                "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"
              ]
            }
          },
          "cli": { "hints": true },
          "node": {
            "alias": "seed.radicle.example.com",
            "listen": [],
            "peers": { "type": "dynamic", "target": 8 },
            "connect": [
              "z6MkmJzKhSjQz1USfh8NBtaAFyz5gJace9eBV9yFcfMY5BN5@a.radicle.example.com:8776",
              "z6MkrUZHwJD3pqerEBugSZRxDFdVqKnMUbyPHcFe5gkfFvTe@b.radicle.example.com:8776"
            ],
            "externalAddresses": [ "seed.radicle.example.com:8776" ],
            "db": { "journalMode": "wal" },
            "network": "main",
            "log": "INFO",
            "relay": "always",
            "limits": {
              "routingMaxSize": 1000,
              "routingMaxAge": 604800,
              "gossipMaxAge": 604800,
              "fetchConcurrency": 1,
              "maxOpenFiles": 4096,
              "rate": {
                "inbound": { "fillRate": 10.0, "capacity": 2048 },
                "outbound": { "fillRate": 10.0, "capacity": 2048 }
              },
              "connection": { "inbound": 128, "outbound": 16 }
            },
            "workers": 32,
            "policy": "allow",
            "scope": "all"
          }
        }))
        .unwrap();

        assert!(cfg.node.extra.contains_key("db"));
        assert!(cfg.node.extra.contains_key("policy"));
        assert!(cfg.node.extra.contains_key("scope"));
    }
}