Skip to main content

hashtree_client/
lib.rs

1//! Verified public Hashtree reads, sharing an existing daemon when available.
2//! No daemon database is opened and no background service is started.
3
4mod config;
5mod store;
6pub use config::ClientConfig;
7pub use store::BlobStore;
8
9use anyhow::{ensure, Context, Result};
10use hashtree_core::{Cid, HashTree, HashTreeConfig};
11use hashtree_resolver::{
12    nostr::{NostrResolverConfig, NostrRootResolver},
13    RootResolver,
14};
15use nostr::{nips::nip19::ToBech32, PublicKey};
16use percent_encoding::percent_decode_str;
17use std::{path::Path, sync::Arc};
18use tokio::sync::OnceCell;
19
20/// A public mutable tree reference. The first path segment is the tree name;
21/// percent-encoded slashes belong to that name. Later segments select a directory.
22#[derive(Clone, Debug)]
23pub struct Reference {
24    pub key: String,
25    pub path: String,
26}
27
28impl Reference {
29    pub fn parse(value: &str) -> Result<Self> {
30        let url = reqwest::Url::parse(value)?;
31        ensure!(
32            url.scheme() == "htree"
33                && url.username().is_empty()
34                && url.password().is_none()
35                && url.port().is_none()
36                && url.query().is_none()
37                && url.fragment().is_none(),
38            "expected a public htree:// publisher/tree URL"
39        );
40        let author = PublicKey::parse(url.host_str().context("missing publisher")?)?.to_bech32()?;
41        let mut parts = url.path().trim_start_matches('/').splitn(2, '/');
42        let tree = percent_decode_str(parts.next().unwrap_or_default()).decode_utf8()?;
43        ensure!(!tree.is_empty(), "missing Hashtree tree name");
44        let path = percent_decode_str(parts.next().unwrap_or_default())
45            .decode_utf8()?
46            .into_owned();
47        ensure!(
48            !path
49                .split('/')
50                .any(|s| s == "." || s == ".." || s.contains('\\') || s.contains('\0')),
51            "invalid directory path"
52        );
53        Ok(Self {
54            key: format!("{author}/{tree}"),
55            path,
56        })
57    }
58}
59
60pub struct Client {
61    config: ClientConfig,
62    store: Arc<BlobStore>,
63    daemon: OnceCell<Option<String>>,
64}
65
66impl Client {
67    pub fn new(config: ClientConfig, cache: &Path) -> Result<Self> {
68        let store = Arc::new(BlobStore::new(cache, config.clone())?);
69        Ok(Self {
70            config,
71            store,
72            daemon: OnceCell::new(),
73        })
74    }
75
76    pub fn store(&self) -> Arc<BlobStore> {
77        self.store.clone()
78    }
79
80    pub async fn daemon_url(&self) -> Option<&str> {
81        self.daemon
82            .get_or_init(|| async {
83                let url = self.config.daemon_url.as_ref()?;
84                let response = self
85                    .store
86                    .http
87                    .get(format!("{url}/health"))
88                    .timeout(std::time::Duration::from_millis(500))
89                    .send()
90                    .await
91                    .ok()?;
92                response.status().is_success().then(|| url.clone())
93            })
94            .await
95            .as_deref()
96    }
97
98    async fn observe(&self, key: &str, relays: Vec<String>) -> Result<Cid> {
99        let resolver = NostrRootResolver::new(NostrResolverConfig {
100            relays,
101            resolve_timeout: self.config.resolve_window,
102            secret_key: None,
103        })
104        .await?;
105        let result = resolver.resolve_open(key, self.config.resolve_window).await;
106        resolver.stop().await?;
107        Ok(result?)
108    }
109
110    /// Observe signed roots for the configured window, including after EOSE.
111    /// A quiet/failed local subscription falls back to configured relays unless
112    /// local-daemon-only was explicitly requested. Absence is reported as timeout.
113    pub async fn resolve(&self, reference: &Reference) -> Result<Cid> {
114        let local_result = if let Some(daemon) = self.daemon_url().await {
115            let mut url = reqwest::Url::parse(daemon)?;
116            url.set_scheme("ws")
117                .map_err(|_| anyhow::anyhow!("invalid local daemon URL"))?;
118            url.set_path("/ws");
119            Some(self.observe(&reference.key, vec![url.to_string()]).await)
120        } else {
121            None
122        };
123        let root = match local_result {
124            Some(Ok(root)) => root,
125            Some(Err(error)) if self.config.local_only => return Err(error),
126            None if self.config.local_only => {
127                anyhow::bail!("HTREE_LOCAL_DAEMON_ONLY requires a running local htree daemon")
128            }
129            _ => {
130                self.observe(&reference.key, self.config.relays.clone())
131                    .await?
132            }
133        };
134        if reference.path.is_empty() {
135            return Ok(root);
136        }
137        self.tree()
138            .resolve(&root, &reference.path)
139            .await?
140            .context("Hashtree directory is missing")
141    }
142
143    fn tree(&self) -> HashTree<BlobStore> {
144        HashTree::new(HashTreeConfig::new(self.store.clone()))
145    }
146
147    /// Read a logical file from an immutable root, verifying every raw block.
148    pub async fn read_file(&self, root: &Cid, path: &str, limit: usize) -> Result<Vec<u8>> {
149        let cid = self
150            .tree()
151            .resolve(root, path)
152            .await?
153            .context("Hashtree file is missing")?;
154        self.tree()
155            .get(&cid, Some(limit as u64))
156            .await?
157            .context("Hashtree file is unavailable")
158    }
159}