1pub mod config;
15pub use config::{Config, ConfigError, ConfigPath, RawConfig};
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
39pub mod env {
41 pub use std::env::*;
42
43 pub const RAD_HOME: &str = "RAD_HOME";
45 pub const RAD_SOCKET: &str = "RAD_SOCKET";
47 pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
49 pub const RAD_RNG_SEED: &str = "RAD_RNG_SEED";
51 pub const RAD_KEYGEN_SEED: &str = "RAD_KEYGEN_SEED";
53 pub const RAD_HINT: &str = "RAD_HINT";
55 pub const RAD_COMMIT_TIME: &str = "RAD_COMMIT_TIME";
60 pub const RAD_LOCAL_TIME: &str = "RAD_LOCAL_TIME";
63 pub const RAD_DEBUG: &str = "RAD_DEBUG";
65 pub const GIT_COMMITTER_DATE: &str = "GIT_COMMITTER_DATE";
68
69 pub fn commit_time() -> localtime::LocalTime {
71 time(RAD_COMMIT_TIME).unwrap_or_else(local_time)
72 }
73
74 pub fn local_time() -> localtime::LocalTime {
76 time(RAD_LOCAL_TIME).unwrap_or_else(localtime::LocalTime::now)
77 }
78
79 pub fn debug() -> bool {
81 var(RAD_DEBUG).is_ok()
82 }
83
84 pub fn hints() -> bool {
86 var(RAD_HINT).is_ok()
87 }
88
89 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 pub fn passphrase() -> Option<super::Passphrase> {
104 let Ok(passphrase) = var(RAD_PASSPHRASE) else {
105 return None;
106 };
107 if passphrase.is_empty() {
108 log::trace!(target: "radicle", "Treating empty passphrase as no passphrase.");
111 return None;
112 }
113 Some(super::Passphrase::from(passphrase))
114 }
115
116 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 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 Config(#[from] ConfigError),
167 #[error(transparent)]
168 Node(#[from] node::Error),
169 #[error(transparent)]
170 Routing(#[from] node::routing::Error),
171 #[error(transparent)]
172 Keystore(#[from] keystore::Error),
173 #[error(transparent)]
174 MemorySigner(#[from] keystore::MemorySignerError),
175 #[error("no radicle profile found at path '{0}'")]
176 NotFound(PathBuf),
177 #[error("error connecting to ssh-agent: {0}")]
178 Agent(#[from] crate::crypto::ssh::agent::Error),
179 #[error("radicle key `{0}` is not registered; run `rad auth` to register it with ssh-agent")]
180 KeyNotRegistered(PublicKey),
181 #[error(transparent)]
182 PolicyStore(#[from] node::policy::store::Error),
183 #[error(transparent)]
184 NotificationsStore(#[from] node::notifications::store::Error),
185 #[error(transparent)]
186 DatabaseStore(#[from] node::db::Error),
187 #[error(transparent)]
188 Repository(#[from] storage::RepositoryError),
189 #[error(transparent)]
190 CobsCache(#[from] cob::cache::Error),
191 #[error(transparent)]
192 Storage(#[from] storage::Error),
193}
194
195#[derive(Debug, Clone)]
196pub struct Profile {
197 pub home: Home,
198 pub storage: Storage,
199 pub keystore: Keystore,
200 pub public_key: PublicKey,
201 pub config: Config,
202}
203
204impl Profile {
205 pub fn init(
206 home: Home,
207 alias: Alias,
208 passphrase: Option<Passphrase>,
209 seed: crypto::Seed,
210 ) -> Result<Self, Error> {
211 let keystore = Keystore::new(&home.keys());
212 let public_key = keystore.init("radicle", passphrase, seed)?;
213 let config = Config::init(alias.clone(), home.config().as_path())?;
214 let storage = Storage::open(
215 home.storage(),
216 git::UserInfo {
217 alias,
218 key: public_key,
219 },
220 )?;
221 home.policies_mut()?;
223 home.notifications_mut()?;
224 home.database_mut()?
225 .journal_mode(node::db::JournalMode::default())?
226 .init(
227 &public_key,
228 config.node.features(),
229 &config.node.alias,
230 &UserAgent::default(),
231 LocalTime::now().into(),
232 config.node.external_addresses.iter(),
233 )?;
234
235 let mut cobs = home.cobs_db_mut()?;
237 cobs.migrate(migrate::ignore)?;
238
239 transport::local::register(storage.clone());
240
241 Ok(Profile {
242 home,
243 storage,
244 keystore,
245 public_key,
246 config,
247 })
248 }
249
250 pub fn load() -> Result<Self, Error> {
251 let home = self::home()?;
252 let keystore = Keystore::new(&home.keys());
253 let public_key = keystore
254 .public_key()?
255 .ok_or_else(|| Error::NotFound(home.path().to_path_buf()))?;
256 let config = Config::load(home.config().as_path())?;
257 let storage = Storage::open(
258 home.storage(),
259 git::UserInfo {
260 alias: config.alias().clone(),
261 key: public_key,
262 },
263 )?;
264 transport::local::register(storage.clone());
265
266 Ok(Profile {
267 home,
268 storage,
269 keystore,
270 public_key,
271 config,
272 })
273 }
274
275 pub fn id(&self) -> &PublicKey {
276 &self.public_key
277 }
278
279 pub fn info(&self) -> git::UserInfo {
280 git::UserInfo {
281 alias: self.config.alias().clone(),
282 key: *self.id(),
283 }
284 }
285
286 pub fn hints(&self) -> bool {
287 if env::hints() {
288 return true;
289 }
290 self.config.cli.hints
291 }
292
293 pub fn did(&self) -> Did {
294 Did::from(self.public_key)
295 }
296
297 pub fn signer(&self) -> Result<BoxedDevice, Error> {
298 if !self.keystore.is_encrypted()? {
299 let signer = keystore::MemorySigner::load(&self.keystore, None)?;
300 return Ok(Device::from(signer).boxed());
301 }
302
303 if let Some(passphrase) = env::passphrase() {
304 let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
305 return Ok(Device::from(signer).boxed());
306 }
307
308 match Agent::connect() {
309 Ok(agent) => {
310 let signer = agent.signer(self.public_key);
311 if signer.is_ready()? {
312 Ok(Device::from(signer).boxed())
313 } else {
314 Err(Error::KeyNotRegistered(self.public_key))
315 }
316 }
317 Err(err) => Err(err.into()),
318 }
319 }
320
321 pub fn home(&self) -> &Home {
323 &self.home
324 }
325
326 pub fn policies(&self) -> Result<policy::config::Config<Read>, policy::store::Error> {
328 let path = self.node().join(node::POLICIES_DB_FILE);
329 let config = policy::config::Config::new(
330 self.config.node.seeding_policy.into(),
331 policy::store::Store::reader(path)?,
332 );
333 Ok(config)
334 }
335
336 pub fn aliases(&self) -> Aliases {
338 let policies = self.home.policies().ok();
339 let db = self.home.database().ok();
340
341 Aliases { policies, db }
342 }
343
344 pub fn add_inventory(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
347 match node.add_inventory(rid) {
348 Ok(updated) => Ok(updated),
349 Err(e) if e.is_connection_err() => {
350 let now = LocalTime::now();
351 let mut db = self.database_mut()?;
352 let updates =
353 node::routing::Store::add_inventory(&mut db, [&rid], *self.id(), now.into())?;
354
355 Ok(!updates.is_empty())
356 }
357 Err(e) => Err(e.into()),
358 }
359 }
360
361 pub fn seed(&self, rid: RepoId, scope: Scope, node: &mut Node) -> Result<bool, Error> {
365 match node.seed(rid, scope) {
366 Ok(updated) => Ok(updated),
367 Err(e) if e.is_connection_err() => {
368 let mut config = self.policies_mut()?;
369 let updated = config.seed(&rid, scope)?;
370
371 Ok(updated)
372 }
373 Err(e) => Err(e.into()),
374 }
375 }
376
377 pub fn unseed(&self, rid: RepoId, node: &mut Node) -> Result<bool, Error> {
380 match node.unseed(rid) {
381 Ok(updated) => Ok(updated),
382 Err(e) if e.is_connection_err() => {
383 let mut config = self.policies_mut()?;
384 let result = config.unseed(&rid)?;
385
386 let mut db = self.database_mut()?;
387 node::routing::Store::remove_inventory(&mut db, &rid, self.id())?;
388
389 Ok(result)
390 }
391 Err(e) => Err(e.into()),
392 }
393 }
394}
395
396impl std::ops::Deref for Profile {
397 type Target = Home;
398
399 fn deref(&self) -> &Self::Target {
400 &self.home
401 }
402}
403
404impl std::ops::DerefMut for Profile {
405 fn deref_mut(&mut self) -> &mut Self::Target {
406 &mut self.home
407 }
408}
409
410impl AliasStore for Profile {
411 fn alias(&self, nid: &NodeId) -> Option<Alias> {
412 self.aliases().alias(nid)
413 }
414
415 fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
416 self.aliases().reverse_lookup(alias)
417 }
418}
419
420pub struct Aliases {
423 policies: Option<policy::store::StoreReader>,
424 db: Option<node::Database>,
425}
426
427impl AliasStore for Aliases {
428 fn alias(&self, nid: &NodeId) -> Option<Alias> {
431 self.policies
432 .as_ref()
433 .and_then(|db| db.alias(nid))
434 .or_else(|| self.db.as_ref().and_then(|db| db.alias(nid)))
435 }
436
437 fn reverse_lookup(&self, alias: &Alias) -> BTreeMap<Alias, BTreeSet<NodeId>> {
438 let mut nodes = BTreeMap::new();
439 if let Some(db) = self.policies.as_ref() {
440 nodes.extend(db.reverse_lookup(alias));
441 }
442 if let Some(db) = self.db.as_ref() {
443 nodes.extend(db.reverse_lookup(alias));
444 }
445 nodes
446 }
447}
448
449pub fn home() -> Result<Home, io::Error> {
451 if let Some(home) = env::var_os(env::RAD_HOME) {
452 Ok(Home::new(PathBuf::from(home))?)
453 } else if let Some(home) = env::var_os("HOME") {
454 Ok(Home::new(PathBuf::from(home).join(".radicle"))?)
455 } else {
456 Err(io::Error::new(
457 io::ErrorKind::NotFound,
458 "Neither `RAD_HOME` nor `HOME` are set",
459 ))
460 }
461}
462
463#[derive(Debug, Clone)]
465pub struct Home {
466 path: PathBuf,
467}
468
469impl TryFrom<PathBuf> for Home {
470 type Error = io::Error;
471
472 fn try_from(home: PathBuf) -> Result<Self, Self::Error> {
473 Self::new(home)
474 }
475}
476
477impl Home {
478 pub fn new(home: impl Into<PathBuf>) -> Result<Self, io::Error> {
490 let path = home.into();
491 if !path.exists() {
492 fs::create_dir_all(path.clone())?;
493 }
494 let home = Self {
495 path: path.canonicalize()?,
496 };
497
498 for dir in &[home.storage(), home.keys(), home.node(), home.cobs()] {
499 if !dir.exists() {
500 fs::create_dir_all(dir)?;
501 }
502 }
503
504 Ok(home)
505 }
506
507 pub fn path(&self) -> &Path {
508 self.path.as_path()
509 }
510
511 pub fn storage(&self) -> PathBuf {
512 self.path.join("storage")
513 }
514
515 pub fn config(&self) -> PathBuf {
516 self.path.join("config.json")
517 }
518
519 pub fn keys(&self) -> PathBuf {
520 self.path.join("keys")
521 }
522
523 pub fn node(&self) -> PathBuf {
524 self.path.join("node")
525 }
526
527 pub fn cobs(&self) -> PathBuf {
528 self.path.join("cobs")
529 }
530
531 pub fn socket(&self) -> PathBuf {
532 env::var_os(env::RAD_SOCKET)
533 .map(PathBuf::from)
534 .unwrap_or_else(|| self.node().join(node::DEFAULT_SOCKET_NAME))
535 }
536
537 pub fn notifications_mut(
539 &self,
540 ) -> Result<notifications::StoreWriter, notifications::store::Error> {
541 let path = self.node().join(node::NOTIFICATIONS_DB_FILE);
542 let db = notifications::Store::open(path)?;
543
544 Ok(db)
545 }
546
547 pub fn policies_mut(&self) -> Result<policy::store::StoreWriter, policy::store::Error> {
549 let path = self.node().join(node::POLICIES_DB_FILE);
550 let config = policy::store::Store::open(path)?;
551
552 Ok(config)
553 }
554
555 pub fn database(&self) -> Result<node::Database, node::db::Error> {
557 let path = self.node().join(node::NODE_DB_FILE);
558 let db = node::Database::reader(path)?;
559
560 Ok(db)
561 }
562
563 pub fn database_mut(&self) -> Result<node::Database, node::db::Error> {
565 let path = self.node().join(node::NODE_DB_FILE);
566 let db = node::Database::open(path)?;
567
568 Ok(db)
569 }
570
571 pub fn addresses(&self) -> Result<impl node::address::Store, node::db::Error> {
573 self.database_mut()
574 }
575
576 pub fn routing(&self) -> Result<impl node::routing::Store, node::db::Error> {
578 self.database()
579 }
580
581 pub fn routing_mut(&self) -> Result<impl node::routing::Store, node::db::Error> {
583 self.database_mut()
584 }
585
586 pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
588 let path = self.cobs().join(cob::cache::COBS_DB_FILE);
589 let db = cob::cache::Store::reader(path)?;
590
591 Ok(db)
592 }
593
594 pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
596 let path = self.cobs().join(cob::cache::COBS_DB_FILE);
597 let db = cob::cache::Store::open(path)?;
598
599 Ok(db)
600 }
601
602 pub fn issues<'a, R>(
604 &self,
605 repository: &'a R,
606 ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
607 where
608 R: ReadRepository + cob::Store<Namespace = NodeId>,
609 {
610 let db = self.cobs_db()?;
611 let store = cob::issue::Issues::open(repository)?;
612
613 db.check_version()?;
614
615 Ok(cob::issue::Cache::reader(store, db))
616 }
617
618 pub fn issues_mut<'a, R>(
620 &self,
621 repository: &'a R,
622 ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
623 where
624 R: ReadRepository + cob::Store<Namespace = NodeId>,
625 {
626 let db = self.cobs_db_mut()?;
627 let store = cob::issue::Issues::open(repository)?;
628
629 db.check_version()?;
630
631 Ok(cob::issue::Cache::open(store, db))
632 }
633
634 pub fn patches<'a, R>(
636 &self,
637 repository: &'a R,
638 ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
639 where
640 R: ReadRepository + cob::Store<Namespace = NodeId>,
641 {
642 let db = self.cobs_db()?;
643 let store = cob::patch::Patches::open(repository)?;
644
645 db.check_version()?;
646
647 Ok(cob::patch::Cache::reader(store, db))
648 }
649
650 pub fn patches_mut<'a, R>(
652 &self,
653 repository: &'a R,
654 ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
655 where
656 R: ReadRepository + cob::Store<Namespace = NodeId>,
657 {
658 let db = self.cobs_db_mut()?;
659 let store = cob::patch::Patches::open(repository)?;
660
661 db.check_version()?;
662
663 Ok(cob::patch::Cache::open(store, db))
664 }
665}
666
667impl Home {
669 fn policies(&self) -> Result<policy::store::StoreReader, policy::store::Error> {
671 let path = self.node().join(node::POLICIES_DB_FILE);
672 let config = policy::store::Store::reader(path)?;
673
674 Ok(config)
675 }
676}
677
678#[cfg(test)]
679#[cfg(not(target_os = "macos"))]
680#[allow(clippy::unwrap_used)]
681mod test {
682 use std::fs;
683
684 use serde_json as json;
685
686 use super::*;
687
688 #[test]
694 fn canonicalize_home() {
695 let tmp = tempfile::tempdir().unwrap();
696 let path = tmp.path().join("Home").join("Radicle");
697 fs::create_dir_all(path.clone()).unwrap();
698
699 let last = tmp.path().components().last().unwrap();
700 let home = Home::new(
701 tmp.path()
702 .join("..")
703 .join(last)
704 .join("Home")
705 .join("Radicle"),
706 )
707 .unwrap();
708
709 assert_eq!(home.path, path);
710 }
711
712 #[test]
713 fn test_config() {
714 let cfg = json::from_value::<Config>(json::json!({
715 "publicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
716 "preferredSeeds": [],
717 "web": {
718 "pinned": {
719 "repositories": [
720 "rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi",
721 "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"
722 ]
723 }
724 },
725 "cli": { "hints": true },
726 "node": {
727 "alias": "seed.radicle.xyz",
728 "listen": [],
729 "peers": { "type": "dynamic", "target": 8 },
730 "connect": [
731 "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776",
732 "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776"
733 ],
734 "externalAddresses": [ "seed.radicle.xyz:8776" ],
735 "db": { "journalMode": "wal" },
736 "network": "main",
737 "log": "INFO",
738 "relay": "always",
739 "limits": {
740 "routingMaxSize": 1000,
741 "routingMaxAge": 604800,
742 "gossipMaxAge": 604800,
743 "fetchConcurrency": 1,
744 "maxOpenFiles": 4096,
745 "rate": {
746 "inbound": { "fillRate": 10.0, "capacity": 2048 },
747 "outbound": { "fillRate": 10.0, "capacity": 2048 }
748 },
749 "connection": { "inbound": 128, "outbound": 16 }
750 },
751 "workers": 32,
752 "policy": "allow",
753 "scope": "all"
754 }
755 }))
756 .unwrap();
757
758 assert!(cfg.node.extra.contains_key("db"));
759 assert!(cfg.node.extra.contains_key("policy"));
760 assert!(cfg.node.extra.contains_key("scope"));
761 }
762}