radicle-heartwood-lfs/radicle/src/profile.rs

410 lines
12 KiB
Rust

//! 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
//!
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{fs, io, str::FromStr};
use serde::Serialize;
use thiserror::Error;
use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{keystore, Keystore, Passphrase};
use crate::crypto::{PublicKey, Signer};
use crate::node;
use crate::node::{address, routing, tracking, Alias, AliasStore};
use crate::prelude::Did;
use crate::prelude::NodeId;
use crate::storage::git::transport;
use crate::storage::git::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";
/// Get the radicle passphrase from the environment.
pub fn passphrase() -> Option<super::Passphrase> {
let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else {
return None;
};
Some(super::Passphrase::from(passphrase))
}
/// Get a random number generator from the environment.
pub fn rng() -> fastrand::Rng {
if let Ok(seed) = std::env::var(RAD_RNG_SEED) {
return fastrand::Rng::with_seed(
seed.parse()
.expect("env::rng: invalid seed specified in `RAD_RNG_SEED`"),
);
}
fastrand::Rng::new()
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Keystore(#[from] keystore::Error),
#[error(transparent)]
MemorySigner(#[from] keystore::MemorySignerError),
#[error("no profile found at path '{0}'")]
NotFound(PathBuf),
#[error("error connecting to ssh-agent: {0}")]
Agent(#[from] crate::crypto::ssh::agent::Error),
#[error("profile key `{0}` is not registered with ssh-agent")]
KeyNotRegistered(PublicKey),
#[error(transparent)]
TrackingStore(#[from] node::tracking::store::Error),
#[error(transparent)]
AddressStore(#[from] node::address::Error),
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to load node configuration from {0}: {1}")]
Io(PathBuf, io::Error),
#[error("failed to decode node configuration from {0}: {1}")]
Json(PathBuf, serde_json::Error),
}
/// Local radicle configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub node: node::Config,
}
impl Config {
/// Initialize a new configuration. Fails if the path already exists.
pub fn init(alias: Alias, path: &Path) -> io::Result<Self> {
let cfg = Self {
node: node::Config::new(alias),
};
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(path)?;
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut serializer = serde_json::Serializer::with_formatter(&file, formatter);
cfg.serialize(&mut serializer)?;
file.write_all(b"\n")?;
file.sync_all()?;
Ok(cfg)
}
/// Load a configuration from the given path.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
match fs::File::open(path) {
Ok(cfg) => {
serde_json::from_reader(cfg).map_err(|e| ConfigError::Json(path.to_path_buf(), e))
}
Err(e) => {
let Ok(user) = env::var("USER") else {
return Err(ConfigError::Io(path.to_owned(), e));
};
let Ok(alias) = Alias::from_str(&user) else {
return Err(ConfigError::Io(path.to_owned(), e));
};
Ok(Config {
node: node::Config::new(alias),
})
}
}
}
/// Get the user alias.
pub fn alias(&self) -> &Alias {
&self.node.alias
}
}
#[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>) -> Result<Self, Error> {
let storage = Storage::open(home.storage())?;
let keystore = Keystore::new(&home.keys());
let public_key = keystore.init("radicle", passphrase)?;
let config = Config::init(alias, home.config().as_path())?;
transport::local::register(storage.clone());
Ok(Profile {
home,
storage,
keystore,
public_key,
config,
})
}
pub fn load() -> Result<Self, Error> {
let home = self::home()?;
let storage = Storage::open(home.storage())?;
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())?;
transport::local::register(storage.clone());
Ok(Profile {
home,
storage,
keystore,
public_key,
config,
})
}
pub fn id(&self) -> &PublicKey {
&self.public_key
}
pub fn did(&self) -> Did {
Did::from(self.public_key)
}
pub fn signer(&self) -> Result<Box<dyn Signer>, Error> {
if !self.keystore.is_encrypted()? {
let signer = keystore::MemorySigner::load(&self.keystore, None)?;
return Ok(signer.boxed());
}
if let Some(passphrase) = env::passphrase() {
let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
return Ok(signer.boxed());
}
match Agent::connect() {
Ok(agent) => {
let signer = agent.signer(self.public_key);
if signer.is_ready()? {
Ok(signer.boxed())
} else {
Err(Error::KeyNotRegistered(self.public_key))
}
}
Err(err) => Err(err.into()),
}
}
/// Return a read-only handle to the tracking configuration of the node.
pub fn tracking(&self) -> Result<tracking::store::ConfigReader, tracking::store::Error> {
let path = self.home.node().join(node::TRACKING_DB_FILE);
let config = tracking::store::Config::reader(path)?;
Ok(config)
}
/// Return a read-only handle to the routing database of the node.
pub fn routing(&self) -> Result<routing::Table, routing::Error> {
let path = self.home.node().join(node::ROUTING_DB_FILE);
let router = routing::Table::reader(path)?;
Ok(router)
}
/// Return a handle to the addresses database of the node.
pub fn addresses(&self) -> Result<address::Book, address::Error> {
let path = self.home.node().join(node::ADDRESS_DB_FILE);
let addresses = address::Book::reader(path)?;
Ok(addresses)
}
/// Return a multi-source store for aliases.
pub fn aliases(&self) -> Aliases {
let tracking = self.tracking().ok();
let addresses = self.addresses().ok();
Aliases {
tracking,
addresses,
}
}
/// Return the path to the keys folder.
pub fn keys(&self) -> PathBuf {
self.home.keys()
}
/// Get the profile home directory.
pub fn home(&self) -> &Path {
self.home.path()
}
/// Get the path to the radicle node socket.
pub fn socket(&self) -> PathBuf {
self.home.socket()
}
}
/// Holds multiple alias stores, and will try
/// them one by one when asking for an alias.
pub struct Aliases {
tracking: Option<tracking::store::ConfigReader>,
addresses: Option<address::Book>,
}
impl AliasStore for Aliases {
/// Retrieve `alias` of given node.
/// First looks in `tracking.db` and then `addresses.db`.
fn alias(&self, nid: &NodeId) -> Option<Alias> {
self.tracking
.as_ref()
.and_then(|db| db.alias(nid))
.or_else(|| self.addresses.as_ref().and_then(|db| db.alias(nid)))
}
}
/// Get the path to the radicle home folder.
pub fn home() -> Result<Home, io::Error> {
if let Some(home) = env::var_os(env::RAD_HOME) {
Ok(Home::new(PathBuf::from(home))?)
} else if let Some(home) = env::var_os("HOME") {
Ok(Home::new(PathBuf::from(home).join(".radicle"))?)
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
"Neither `RAD_HOME` nor `HOME` are set",
))
}
}
/// Radicle home.
#[derive(Debug, Clone)]
pub struct Home {
path: PathBuf,
}
impl TryFrom<PathBuf> for Home {
type Error = io::Error;
fn try_from(home: PathBuf) -> Result<Self, Self::Error> {
Self::new(home)
}
}
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: path.canonicalize()?,
};
for dir in &[home.storage(), home.keys(), home.node()] {
if !dir.exists() {
fs::create_dir_all(dir)?;
}
}
Ok(home)
}
pub fn path(&self) -> &Path {
self.path.as_path()
}
pub fn storage(&self) -> PathBuf {
self.path.join("storage")
}
pub fn config(&self) -> PathBuf {
self.path.join("config.json")
}
pub fn keys(&self) -> PathBuf {
self.path.join("keys")
}
pub fn node(&self) -> PathBuf {
self.path.join("node")
}
pub fn socket(&self) -> PathBuf {
env::var_os(env::RAD_SOCKET)
.map(PathBuf::from)
.unwrap_or_else(|| self.node().join(node::DEFAULT_SOCKET_NAME))
}
}
#[cfg(test)]
#[cfg(not(target_os = "macos"))]
mod test {
use std::fs;
use super::Home;
// Checks that if we have:
// '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Radicle/Home'
//
// that it gets normalized to:
// '/run/user/1000/.tmpqfK6ih/Radicle/Home'
#[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 last = tmp.path().components().last().unwrap();
let home = Home::new(
tmp.path()
.join("..")
.join(last)
.join("Home")
.join("Radicle"),
)
.unwrap();
assert_eq!(home.path, path);
}
}