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#[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}