Skip to main content

hashtree_ffi/
lib.rs

1use std::sync::{Arc, OnceLock};
2
3use futures::io::AllowStdIo;
4use futures::StreamExt;
5use hashtree_blossom::{BlossomClient, BlossomStore};
6use hashtree_core::{
7    nhash_decode, nhash_encode_full, Cid, HashTree, HashTreeConfig, MemoryStore, NHashData, Store,
8};
9use nostr::Keys;
10use tokio::io::AsyncWriteExt;
11
12/// FFI-friendly hashtree error type.
13#[derive(Debug, Clone, thiserror::Error, uniffi::Error)]
14pub enum HashtreeError {
15    #[error("{0}")]
16    Message(String),
17}
18
19fn runtime() -> &'static tokio::runtime::Runtime {
20    static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
21    RUNTIME.get_or_init(|| {
22        tokio::runtime::Builder::new_multi_thread()
23            .enable_all()
24            .build()
25            .expect("failed to initialize hashtree_ffi runtime")
26    })
27}
28
29#[uniffi::export]
30pub fn hashtree_nhash_from_file(file_path: String) -> Result<String, HashtreeError> {
31    runtime().block_on(nhash_from_file_impl(&file_path))
32}
33
34#[uniffi::export]
35pub fn hashtree_upload_file(
36    privkey_hex: String,
37    file_path: String,
38    read_servers: Vec<String>,
39    write_servers: Vec<String>,
40) -> Result<String, HashtreeError> {
41    runtime().block_on(upload_file_impl(
42        &privkey_hex,
43        &file_path,
44        read_servers,
45        write_servers,
46    ))
47}
48
49#[uniffi::export]
50pub fn hashtree_download_bytes(
51    nhash: String,
52    read_servers: Vec<String>,
53) -> Result<Vec<u8>, HashtreeError> {
54    runtime().block_on(download_bytes_impl(&nhash, read_servers))
55}
56
57#[uniffi::export]
58pub fn hashtree_download_to_file(
59    nhash: String,
60    output_path: String,
61    read_servers: Vec<String>,
62) -> Result<(), HashtreeError> {
63    runtime().block_on(download_to_file_impl(&nhash, &output_path, read_servers))
64}
65
66async fn nhash_from_file_impl(_file_path: &str) -> Result<String, HashtreeError> {
67    let (cid, _store) = put_file_to_memory(_file_path).await?;
68    encode_nhash_from_cid(&cid)
69}
70
71async fn upload_file_impl(
72    privkey_hex: &str,
73    file_path: &str,
74    read_servers: Vec<String>,
75    write_servers: Vec<String>,
76) -> Result<String, HashtreeError> {
77    let (cid, local_store) = put_file_to_memory(file_path).await?;
78    let client = build_upload_client(privkey_hex, read_servers, write_servers)?;
79
80    let mut hashes = local_store.keys();
81    hashes.sort();
82
83    for hash in hashes {
84        let data = local_store
85            .get(&hash)
86            .await
87            .map_err(|e| HashtreeError::Message(format!("failed to read local blob: {}", e)))?
88            .ok_or_else(|| {
89                HashtreeError::Message("blob missing from local hashtree upload store".to_string())
90            })?;
91
92        client
93            .upload_if_missing(&data)
94            .await
95            .map_err(|e| HashtreeError::Message(format!("blossom upload failed: {}", e)))?;
96    }
97
98    encode_nhash_from_cid(&cid)
99}
100
101async fn download_bytes_impl(
102    nhash: &str,
103    read_servers: Vec<String>,
104) -> Result<Vec<u8>, HashtreeError> {
105    let cid = parse_nhash_to_cid(nhash)?;
106    let store = build_download_store(read_servers)?;
107    let tree = HashTree::new(HashTreeConfig::new(store));
108
109    tree.get(&cid, None)
110        .await
111        .map_err(|e| HashtreeError::Message(format!("failed to download attachment: {}", e)))?
112        .ok_or_else(|| HashtreeError::Message("attachment not found".to_string()))
113}
114
115async fn download_to_file_impl(
116    nhash: &str,
117    output_path: &str,
118    read_servers: Vec<String>,
119) -> Result<(), HashtreeError> {
120    let cid = parse_nhash_to_cid(nhash)?;
121    let store = build_download_store(read_servers)?;
122    let tree = HashTree::new(HashTreeConfig::new(store));
123
124    let output = std::path::Path::new(output_path);
125    if let Some(parent) = output.parent() {
126        if !parent.as_os_str().is_empty() {
127            std::fs::create_dir_all(parent).map_err(|e| {
128                HashtreeError::Message(format!(
129                    "failed to create output directory {}: {}",
130                    parent.display(),
131                    e
132                ))
133            })?;
134        }
135    }
136
137    let mut file = tokio::fs::File::create(output)
138        .await
139        .map_err(|e| HashtreeError::Message(format!("failed to create output file: {}", e)))?;
140
141    let mut wrote_any_chunk = false;
142    let mut stream = tree.get_stream(&cid);
143    while let Some(chunk) = stream.next().await {
144        wrote_any_chunk = true;
145        let chunk = chunk
146            .map_err(|e| HashtreeError::Message(format!("failed to stream attachment: {}", e)))?;
147        file.write_all(&chunk)
148            .await
149            .map_err(|e| HashtreeError::Message(format!("failed to write output file: {}", e)))?;
150    }
151
152    if !wrote_any_chunk {
153        return Err(HashtreeError::Message("attachment not found".to_string()));
154    }
155
156    file.flush()
157        .await
158        .map_err(|e| HashtreeError::Message(format!("failed to flush output file: {}", e)))?;
159
160    Ok(())
161}
162
163fn parse_nhash_to_cid(nhash: &str) -> Result<Cid, HashtreeError> {
164    let decoded = nhash_decode(nhash)
165        .map_err(|e| HashtreeError::Message(format!("invalid nhash attachment id: {}", e)))?;
166    Ok(Cid {
167        hash: decoded.hash,
168        key: decoded.decrypt_key,
169    })
170}
171
172fn encode_nhash_from_cid(cid: &Cid) -> Result<String, HashtreeError> {
173    nhash_encode_full(&NHashData {
174        hash: cid.hash,
175        decrypt_key: cid.key,
176    })
177    .map_err(|e| HashtreeError::Message(format!("failed to encode nhash: {}", e)))
178}
179
180fn normalize_servers(servers: Vec<String>) -> Vec<String> {
181    let mut out = Vec::new();
182    for server in servers {
183        let trimmed = server.trim();
184        if trimmed.is_empty() {
185            continue;
186        }
187        let normalized = trimmed.trim_end_matches('/').to_string();
188        if !out.iter().any(|existing| existing == &normalized) {
189            out.push(normalized);
190        }
191    }
192    out
193}
194
195fn build_upload_client(
196    privkey_hex: &str,
197    read_servers: Vec<String>,
198    write_servers: Vec<String>,
199) -> Result<BlossomClient, HashtreeError> {
200    let keys = Keys::parse(privkey_hex).map_err(|e| {
201        HashtreeError::Message(format!(
202            "invalid blossom signing key (hex or nsec expected): {}",
203            e
204        ))
205    })?;
206
207    let read = normalize_servers(read_servers);
208    let mut write = normalize_servers(write_servers);
209    if write.is_empty() && !read.is_empty() {
210        write = read.clone();
211    }
212
213    let mut client = BlossomClient::new_empty(keys);
214    if !read.is_empty() {
215        client = client.with_read_servers(read);
216    }
217    if !write.is_empty() {
218        client = client.with_write_servers(write);
219    }
220
221    if client.write_servers().is_empty() {
222        return Err(HashtreeError::Message(
223            "no write servers provided for blossom upload".to_string(),
224        ));
225    }
226
227    Ok(client)
228}
229
230fn build_download_store(read_servers: Vec<String>) -> Result<Arc<BlossomStore>, HashtreeError> {
231    let read = normalize_servers(read_servers);
232    if read.is_empty() {
233        return Err(HashtreeError::Message(
234            "no read servers provided for attachment download".to_string(),
235        ));
236    }
237
238    let client = BlossomClient::new_empty(Keys::generate()).with_read_servers(read);
239    Ok(Arc::new(BlossomStore::new(client)))
240}
241
242async fn put_file_to_memory(file_path: &str) -> Result<(Cid, MemoryStore), HashtreeError> {
243    let file = std::fs::File::open(file_path)
244        .map_err(|e| HashtreeError::Message(format!("failed to open {}: {}", file_path, e)))?;
245
246    let store = MemoryStore::new();
247    let tree = HashTree::new(HashTreeConfig::new(Arc::new(store.clone())));
248    let (cid, _size) = tree
249        .put_stream(AllowStdIo::new(file))
250        .await
251        .map_err(|e| HashtreeError::Message(format!("failed to hash/encrypt file: {}", e)))?;
252
253    Ok((cid, store))
254}
255
256uniffi::setup_scaffolding!();
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use axum::{
262        body::Bytes,
263        extract::{DefaultBodyLimit, Path as AxumPath, State},
264        http::{header, HeaderMap, StatusCode},
265        response::IntoResponse,
266        routing::{get, put},
267        Router,
268    };
269    use hashtree_blossom::compute_sha256;
270    use nostr::Keys;
271    use std::{
272        collections::HashMap,
273        sync::{Arc, RwLock},
274    };
275    use tempfile::TempDir;
276
277    #[derive(Clone, Default)]
278    struct TestServerState {
279        blobs: Arc<RwLock<HashMap<String, Vec<u8>>>>,
280    }
281
282    impl TestServerState {
283        fn blob_count(&self) -> usize {
284            self.blobs.read().unwrap().len()
285        }
286    }
287
288    async fn upload_blob(State(state): State<TestServerState>, body: Bytes) -> impl IntoResponse {
289        let hash = compute_sha256(&body);
290        let mut blobs = state.blobs.write().unwrap();
291        if blobs.contains_key(&hash) {
292            return StatusCode::OK;
293        }
294        blobs.insert(hash, body.to_vec());
295        StatusCode::CREATED
296    }
297
298    async fn get_blob(
299        AxumPath(blob): AxumPath<String>,
300        State(state): State<TestServerState>,
301    ) -> impl IntoResponse {
302        let hash = blob.strip_suffix(".bin").unwrap_or(blob.as_str());
303        let blobs = state.blobs.read().unwrap();
304        if let Some(data) = blobs.get(hash) {
305            let mut headers = HeaderMap::new();
306            headers.insert(
307                header::CONTENT_TYPE,
308                header::HeaderValue::from_static("application/octet-stream"),
309            );
310            headers.insert(
311                header::CONTENT_LENGTH,
312                header::HeaderValue::from_str(&data.len().to_string()).unwrap(),
313            );
314            return (StatusCode::OK, headers, data.clone()).into_response();
315        }
316
317        StatusCode::NOT_FOUND.into_response()
318    }
319
320    async fn head_blob(
321        AxumPath(blob): AxumPath<String>,
322        State(state): State<TestServerState>,
323    ) -> impl IntoResponse {
324        let hash = blob.strip_suffix(".bin").unwrap_or(blob.as_str());
325        let blobs = state.blobs.read().unwrap();
326        if let Some(data) = blobs.get(hash) {
327            let mut headers = HeaderMap::new();
328            headers.insert(
329                header::CONTENT_TYPE,
330                header::HeaderValue::from_static("application/octet-stream"),
331            );
332            headers.insert(
333                header::CONTENT_LENGTH,
334                header::HeaderValue::from_str(&data.len().to_string()).unwrap(),
335            );
336            return (StatusCode::OK, headers).into_response();
337        }
338
339        StatusCode::NOT_FOUND.into_response()
340    }
341
342    async fn start_test_server() -> (String, TestServerState) {
343        let state = TestServerState::default();
344        let app = Router::new()
345            .route("/upload", put(upload_blob))
346            .route("/:blob", get(get_blob).head(head_blob))
347            .layer(DefaultBodyLimit::disable())
348            .with_state(state.clone());
349
350        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
351        let addr = listener.local_addr().unwrap();
352
353        tokio::spawn(async move {
354            axum::serve(listener, app).await.unwrap();
355        });
356
357        (format!("http://{}", addr), state)
358    }
359
360    fn write_fixture_file(temp_dir: &TempDir) -> (String, Vec<u8>) {
361        let file_path = temp_dir.path().join("fixture.bin");
362        let mut data = vec![0u8; 5_100_123];
363        for (i, b) in data.iter_mut().enumerate() {
364            *b = (i % 251) as u8;
365        }
366        std::fs::write(&file_path, &data).unwrap();
367        (file_path.to_string_lossy().to_string(), data)
368    }
369
370    #[tokio::test]
371    async fn nhash_from_file_is_deterministic() {
372        let temp_dir = tempfile::tempdir().unwrap();
373        let (file_path, _) = write_fixture_file(&temp_dir);
374
375        let first = nhash_from_file_impl(&file_path).await.unwrap();
376        let second = nhash_from_file_impl(&file_path).await.unwrap();
377        assert_eq!(first, second);
378        assert!(first.starts_with("nhash1"));
379    }
380
381    #[tokio::test]
382    async fn upload_and_download_chunked_file_roundtrips() {
383        let (server_url, state) = start_test_server().await;
384        let temp_dir = tempfile::tempdir().unwrap();
385        let (file_path, data) = write_fixture_file(&temp_dir);
386
387        let keys = Keys::generate();
388        let nhash = upload_file_impl(
389            &keys.secret_key().to_secret_hex(),
390            &file_path,
391            vec![server_url.clone()],
392            vec![server_url.clone()],
393        )
394        .await
395        .unwrap();
396
397        let downloaded = download_bytes_impl(&nhash, vec![server_url]).await.unwrap();
398        assert_eq!(downloaded, data);
399        assert!(
400            state.blob_count() > 1,
401            "expected chunked upload to produce multiple blobs"
402        );
403    }
404
405    #[tokio::test]
406    async fn download_to_file_writes_exact_bytes() {
407        let (server_url, _state) = start_test_server().await;
408        let temp_dir = tempfile::tempdir().unwrap();
409        let (file_path, data) = write_fixture_file(&temp_dir);
410
411        let keys = Keys::generate();
412        let nhash = upload_file_impl(
413            &keys.secret_key().to_secret_hex(),
414            &file_path,
415            vec![server_url.clone()],
416            vec![server_url.clone()],
417        )
418        .await
419        .unwrap();
420
421        let out_path = temp_dir.path().join("downloaded.bin");
422        download_to_file_impl(&nhash, &out_path.to_string_lossy(), vec![server_url])
423            .await
424            .unwrap();
425
426        let written = std::fs::read(out_path).unwrap();
427        assert_eq!(written, data);
428    }
429}