1pub 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
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 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 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 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 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 pub fn home(&self) -> &Home {
346 &self.home
347 }
348
349 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 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 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 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 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
443pub struct Aliases {
446 policies: Option<policy::store::StoreReader>,
447 db: Option<node::Database>,
448}
449
450impl AliasStore for Aliases {
451 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
472pub 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 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#[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 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 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 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 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 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 pub fn addresses(&self) -> Result<impl node::address::Store, node::db::Error> {
666 self.database_mut()
667 }
668
669 pub fn routing(&self) -> Result<impl node::routing::Store, node::db::Error> {
671 self.database()
672 }
673
674 pub fn routing_mut(&self) -> Result<impl node::routing::Store, node::db::Error> {
676 self.database_mut()
677 }
678
679 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 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 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 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 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 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
760impl Home {
762 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 #[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}