Skip to main content

ant_core/
browser.rs

1//! Cross-platform Autonomi client logic and browser bindings.
2//!
3//! The manifest, protocol, payment, and immutable-data modules are portable
4//! Rust shared by native clients and the browser WASM package. The
5//! `browser-wasm` feature additionally provides the `web-sys` WebRTC Direct
6//! host adapter. Higher-level browser integration is owned by the companion
7//! `ant-client-browser-sdk` project.
8
9pub mod manifest;
10pub mod payment;
11#[cfg(any(all(target_arch = "wasm32", feature = "browser-wasm"), test))]
12mod peer_records;
13pub mod protocol;
14
15pub use manifest::{
16    parse_browser_manifest, validate_browser_payment_network, BrowserManifest,
17    BrowserManifestEndpoint, PublicFileDescriptor, BROWSER_MANIFEST_VERSION,
18};
19pub use payment::{storage_payment_total, verify_storage_quote, VerifiedStorageQuote};
20pub use protocol::{
21    parse_webrtc_direct_multiaddr, BrowserPaymentNetwork, BrowserQuoteArtifact,
22    WebRtcDirectEndpoint, BROWSER_PROTOCOL_NAME, BROWSER_PROTOCOL_VERSION,
23    WEBRTC_DIRECT_DATA_CHANNEL,
24};
25
26#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
27mod wasm_transport;
28
29use bytes::Bytes;
30use self_encryption::{DataMap, EncryptedChunk};
31use serde::{Deserialize, Serialize};
32use std::collections::HashSet;
33
34/// Maximum file size accepted by the browser API (1 GB decimal).
35///
36/// The page upload path streams through a worker and browser storage. Complete
37/// downloads and the legacy whole-buffer encryption binding remain memory-bound.
38pub const MAX_BROWSER_FILE_BYTES: usize = 1_000_000_000;
39
40/// One native self-encryption chunk descriptor exposed to the browser.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct BrowserChunkInfo {
43    /// Zero-based chunk index.
44    pub index: usize,
45    /// BLAKE3 address of the encrypted record.
46    pub dst_hash: String,
47    /// BLAKE3 hash of the plaintext chunk.
48    pub src_hash: String,
49    /// Plaintext chunk size.
50    pub src_size: usize,
51}
52
53/// One content-addressed record ready for network upload.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct BrowserRecord {
56    /// Lowercase hexadecimal BLAKE3 record address.
57    pub address: String,
58    /// Raw record bytes. `serde_bytes` maps this to `Uint8Array` in WASM.
59    #[serde(with = "serde_bytes")]
60    pub content: Vec<u8>,
61}
62
63/// Metadata for a content-addressed record staged outside WASM memory.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct BrowserRecordInfo {
66    /// Lowercase hexadecimal BLAKE3 record address.
67    pub address: String,
68    /// Raw record size in bytes.
69    pub size: usize,
70}
71
72/// Result of streaming self-encryption whose record bytes live in browser storage.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct BrowserStagedFile {
75    /// Display filename supplied by the selected browser `File`.
76    pub name: String,
77    /// Browser MIME type, or `application/octet-stream` when none was supplied.
78    pub content_type: String,
79    /// Public DataMap record address.
80    pub address: String,
81    /// Whole-file plaintext BLAKE3 hash.
82    #[serde(default)]
83    pub blake3: String,
84    /// Plaintext file size.
85    #[serde(default)]
86    pub size: usize,
87    /// Serialized public DataMap size.
88    #[serde(default)]
89    pub data_map_size: usize,
90    /// Native root DataMap chunk descriptors.
91    #[serde(default)]
92    pub chunks: Vec<BrowserChunkInfo>,
93    /// Staged encrypted records followed by the public DataMap record.
94    pub records: Vec<BrowserRecordInfo>,
95}
96
97/// Result of native public-file self-encryption for a browser upload.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct BrowserEncryptedFile {
100    /// Public DataMap record address.
101    pub address: String,
102    /// Whole-file plaintext BLAKE3 hash.
103    pub blake3: String,
104    /// Serialized public DataMap size.
105    pub data_map_size: usize,
106    /// Native root DataMap chunk descriptors.
107    pub chunks: Vec<BrowserChunkInfo>,
108    /// Encrypted data records followed by the public DataMap record.
109    pub records: Vec<BrowserRecord>,
110}
111
112/// Browser immutable-data processing error.
113#[derive(Debug, thiserror::Error)]
114pub enum BrowserError {
115    /// Input did not satisfy the browser API limits.
116    #[error("invalid browser data: {0}")]
117    Invalid(String),
118    /// Native self-encryption failed.
119    #[error("self-encryption failed: {0}")]
120    SelfEncryption(String),
121    /// Public DataMap encoding or decoding failed.
122    #[error("DataMap serialization failed: {0}")]
123    DataMap(String),
124}
125
126/// BLAKE3-address bytes using the same lowercase hexadecimal representation as
127/// the native chunk protocol.
128#[must_use]
129pub fn content_address(content: &[u8]) -> String {
130    hex::encode(ant_protocol::compute_address(content))
131}
132
133/// Verify raw record bytes against a lowercase or uppercase hexadecimal BLAKE3
134/// address.
135pub fn verify_record(address: &str, content: &[u8]) -> Result<(), BrowserError> {
136    let expected = address.strip_prefix("0x").unwrap_or(address);
137    if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
138        return Err(BrowserError::Invalid(
139            "record address must be 32 hexadecimal bytes".to_string(),
140        ));
141    }
142    let mut address = [0; 32];
143    hex::decode_to_slice(expected, &mut address)
144        .map_err(|e| BrowserError::Invalid(e.to_string()))?;
145    crate::record::verify(&address, content).map_err(BrowserError::Invalid)
146}
147
148/// Encrypt a complete public file with the native `self_encryption 0.36`
149/// implementation and append its MessagePack DataMap as a public record.
150pub fn encrypt_public_file(content: &[u8]) -> Result<BrowserEncryptedFile, BrowserError> {
151    if content.len() < self_encryption::MIN_ENCRYPTABLE_BYTES {
152        return Err(BrowserError::Invalid(format!(
153            "self-encryption requires at least {} bytes",
154            self_encryption::MIN_ENCRYPTABLE_BYTES
155        )));
156    }
157    if content.len() > MAX_BROWSER_FILE_BYTES {
158        return Err(BrowserError::Invalid(format!(
159            "browser files are limited to {MAX_BROWSER_FILE_BYTES} bytes"
160        )));
161    }
162
163    let whole_file_hash = blake3::hash(content).to_hex().to_string();
164    let (published_data_map, encrypted_chunks) =
165        self_encryption::encrypt(Bytes::copy_from_slice(content))
166            .map_err(|error| BrowserError::SelfEncryption(error.to_string()))?;
167    let root_data_map = {
168        let encrypted_by_address = encrypted_chunks
169            .iter()
170            .map(|chunk| {
171                (
172                    ant_protocol::compute_address(&chunk.content),
173                    &chunk.content,
174                )
175            })
176            .collect::<std::collections::HashMap<_, _>>();
177        let mut get_local_chunk = |address: self_encryption::XorName| {
178            encrypted_by_address
179                .get(&address.0)
180                .map(|content| (*content).clone())
181                .ok_or_else(|| {
182                    self_encryption::Error::Generic(format!(
183                        "self-encryption output omitted DataMap chunk {}",
184                        hex::encode(address.0)
185                    ))
186                })
187        };
188        self_encryption::get_root_data_map(published_data_map.clone(), &mut get_local_chunk)
189            .map_err(|error| BrowserError::SelfEncryption(error.to_string()))?
190    };
191    let chunks = chunk_infos(&root_data_map);
192
193    let mut records: Vec<BrowserRecord> = encrypted_chunks
194        .into_iter()
195        .map(|chunk| BrowserRecord {
196            address: content_address(&chunk.content),
197            content: chunk.content.to_vec(),
198        })
199        .collect();
200    let (address, encoded_data_map) =
201        crate::client_engine::files::public_map_record(&published_data_map)
202            .map_err(BrowserError::DataMap)?;
203    let address = hex::encode(address);
204    let data_map_size = encoded_data_map.len();
205    records.push(BrowserRecord {
206        address: address.clone(),
207        content: encoded_data_map.to_vec(),
208    });
209
210    Ok(BrowserEncryptedFile {
211        address,
212        blake3: whole_file_hash,
213        data_map_size,
214        chunks,
215        records,
216    })
217}
218
219/// Decode and normalize a native public DataMap.
220pub fn decode_public_data_map(content: &[u8]) -> Result<Vec<BrowserChunkInfo>, BrowserError> {
221    let data_map: DataMap =
222        rmp_serde::from_slice(content).map_err(|error| BrowserError::DataMap(error.to_string()))?;
223    Ok(chunk_infos(&data_map))
224}
225
226/// Reconstruct a public file with native self-encryption after verifying every
227/// encrypted record against its DataMap destination address.
228pub fn decrypt_public_file(
229    data_map_content: &[u8],
230    encrypted_contents: &[Vec<u8>],
231) -> Result<Vec<u8>, BrowserError> {
232    let data_map: DataMap = rmp_serde::from_slice(data_map_content)
233        .map_err(|error| BrowserError::DataMap(error.to_string()))?;
234    let available = encrypted_contents
235        .iter()
236        .map(|content| ant_protocol::compute_address(content))
237        .collect::<HashSet<_>>();
238    for info in data_map.infos() {
239        if !available.contains(&info.dst_hash.0) {
240            return Err(BrowserError::Invalid(format!(
241                "record set does not contain DataMap chunk {}; a record may be missing or corrupt",
242                hex::encode(info.dst_hash.0)
243            )));
244        }
245    }
246    let encrypted_chunks = encrypted_contents
247        .iter()
248        .map(|content| EncryptedChunk {
249            content: Bytes::copy_from_slice(content),
250        })
251        .collect::<Vec<_>>();
252    self_encryption::decrypt(&data_map, &encrypted_chunks)
253        .map(|bytes| bytes.to_vec())
254        .map_err(|error| BrowserError::SelfEncryption(error.to_string()))
255}
256
257fn chunk_infos(data_map: &DataMap) -> Vec<BrowserChunkInfo> {
258    data_map
259        .infos()
260        .iter()
261        .map(|info| BrowserChunkInfo {
262            index: info.index,
263            dst_hash: hex::encode(info.dst_hash.0),
264            src_hash: hex::encode(info.src_hash.0),
265            src_size: info.src_size,
266        })
267        .collect()
268}
269
270#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))]
271mod wasm {
272    use super::manifest::parse_browser_manifest;
273    use super::payment::{payment_quote_hash, verify_storage_quote, BrowserQuoteArtifact};
274    use super::protocol::{
275        ice_password_from_sdp, parse_response_frame, parse_webrtc_direct_multiaddr,
276        server_answer_sdp, v2_server_ice_credential, BrowserEndpointInput,
277    };
278    use super::{
279        chunk_infos, content_address, decrypt_public_file, encrypt_public_file, verify_record,
280        BrowserRecord, BrowserRecordInfo, BrowserStagedFile, MAX_BROWSER_FILE_BYTES,
281    };
282    use ant_protocol::transport::{
283        run_iterative_lookup, IterativeLookup, LookupConfig, LookupKey, LookupNode, LookupQuery,
284        LookupQueryOutcome,
285    };
286    use bytes::Bytes;
287    use js_sys::{Array, Function, Promise, Uint8Array};
288    use serde::{Deserialize, Serialize};
289    use std::cell::{Cell, RefCell};
290    use std::collections::HashMap;
291    use std::rc::Rc;
292    use wasm_bindgen::prelude::*;
293    use wasm_bindgen::JsCast;
294    use wasm_bindgen_futures::JsFuture;
295
296    #[derive(Debug, Serialize)]
297    struct BrowserSessionDescription {
298        #[serde(rename = "type")]
299        description_type: &'static str,
300        sdp: String,
301    }
302
303    #[derive(Debug, Clone, Serialize, Deserialize)]
304    #[serde(untagged)]
305    enum BrowserLookupEndpoint {
306        Structured { multiaddr: String },
307        Multiaddr(String),
308    }
309
310    #[derive(Debug, Clone, Serialize, Deserialize)]
311    struct BrowserLookupNode {
312        peer_id: String,
313        #[serde(default)]
314        native_addresses: Vec<String>,
315        #[serde(default)]
316        reliability: f64,
317        #[serde(default)]
318        webrtc_direct: Option<BrowserLookupEndpoint>,
319    }
320
321    #[derive(Debug, Serialize)]
322    struct BrowserLookupBatch {
323        target: String,
324        count: usize,
325        iteration: usize,
326        candidates: Vec<BrowserLookupNode>,
327    }
328
329    #[derive(Debug, Deserialize)]
330    #[serde(tag = "status", rename_all = "snake_case")]
331    enum BrowserLookupQueryOutcome {
332        Succeeded {
333            responder: String,
334            #[serde(default)]
335            candidates: Vec<BrowserLookupNode>,
336        },
337        Failed {
338            responder: String,
339        },
340        Unresponsive {
341            responder: String,
342        },
343    }
344
345    #[derive(Debug, Clone)]
346    struct BrowserLookupCandidate {
347        peer_id: LookupKey,
348        wire: BrowserLookupNode,
349    }
350
351    impl LookupNode for BrowserLookupCandidate {
352        fn lookup_peer_id(&self) -> LookupKey {
353            self.peer_id
354        }
355    }
356
357    impl BrowserLookupCandidate {
358        fn parse(mut wire: BrowserLookupNode) -> Result<Self, JsValue> {
359            let peer_id = parse_lookup_key(&wire.peer_id, "peer ID")?;
360            wire.peer_id = hex::encode(peer_id);
361            Ok(Self { peer_id, wire })
362        }
363    }
364
365    /// Shared Saorsa iterative lookup state driven by browser WebRtcDirect.
366    #[wasm_bindgen(js_name = BrowserIterativeLookup)]
367    pub struct BrowserIterativeLookup {
368        lookup: IterativeLookup<BrowserLookupCandidate>,
369        known_endpoints: HashMap<LookupKey, BrowserLookupEndpoint>,
370    }
371
372    #[wasm_bindgen(js_class = BrowserIterativeLookup)]
373    impl BrowserIterativeLookup {
374        /// Construct a browser lookup using the same scheduler as native QUIC.
375        #[wasm_bindgen(constructor)]
376        pub fn new(
377            target: &str,
378            count: usize,
379            alpha: usize,
380            max_iterations: usize,
381        ) -> Result<Self, JsValue> {
382            let target = parse_lookup_key(target, "lookup target")?;
383            let config = LookupConfig {
384                count,
385                alpha,
386                max_iterations,
387                ..LookupConfig::saorsa(count)
388            };
389            let lookup = IterativeLookup::new(target, config)
390                .map_err(|error| JsValue::from_str(&error.to_string()))?;
391            Ok(Self {
392                lookup,
393                known_endpoints: HashMap::new(),
394            })
395        }
396
397        /// Add validated bootstrap or FIND_NODE candidates.
398        #[wasm_bindgen(js_name = addCandidates)]
399        pub fn add_candidates(&mut self, nodes: JsValue) -> Result<(), JsValue> {
400            for candidate in parse_lookup_nodes(nodes)? {
401                self.add_candidate(candidate);
402            }
403            Ok(())
404        }
405
406        /// Run the complete shared Saorsa walk through a WebRtcDirect batch callback.
407        #[wasm_bindgen(js_name = run)]
408        pub async fn run(&mut self, query_batch: Function) -> Result<String, JsValue> {
409            let mut query = BrowserLookupQuery {
410                callback: query_batch,
411                known_endpoints: &mut self.known_endpoints,
412            };
413            run_iterative_lookup(
414                &mut self.lookup,
415                &mut query,
416                gloo_timers::future::TimeoutFuture::new(
417                    ant_protocol::transport::LOOKUP_TIMEOUT_SECS * 1_000,
418                ),
419            )
420            .await
421            .map(|termination| format!("{termination:?}"))
422            .map_err(|error| JsValue::from_str(&error.to_string()))
423        }
424
425        /// Successful responders in final closest-first order.
426        #[wasm_bindgen(js_name = results)]
427        pub fn results(&self) -> Result<JsValue, JsValue> {
428            let nodes = self
429                .lookup
430                .results()
431                .into_iter()
432                .map(|candidate| candidate.wire)
433                .collect::<Vec<_>>();
434            serde_wasm_bindgen::to_value(&nodes)
435                .map_err(|error| JsValue::from_str(&error.to_string()))
436        }
437
438        /// Peer IDs selected for network queries, in query order.
439        #[wasm_bindgen(js_name = queriedPeers)]
440        pub fn queried_peers(&self) -> Result<JsValue, JsValue> {
441            let peers = self
442                .lookup
443                .queried_peers()
444                .iter()
445                .map(hex::encode)
446                .collect::<Vec<_>>();
447            serde_wasm_bindgen::to_value(&peers)
448                .map_err(|error| JsValue::from_str(&error.to_string()))
449        }
450    }
451
452    impl BrowserIterativeLookup {
453        fn add_candidate(&mut self, candidate: BrowserLookupCandidate) {
454            if let Some(candidate) =
455                resolve_candidate_endpoint(&mut self.known_endpoints, candidate)
456            {
457                let _ = self.lookup.add_candidate(candidate);
458            }
459        }
460    }
461
462    struct BrowserLookupQuery<'a> {
463        callback: Function,
464        known_endpoints: &'a mut HashMap<LookupKey, BrowserLookupEndpoint>,
465    }
466
467    impl LookupQuery<BrowserLookupCandidate> for BrowserLookupQuery<'_> {
468        type Error = String;
469
470        async fn query_batch(
471            &mut self,
472            target: LookupKey,
473            count: usize,
474            iteration: usize,
475            batch: Vec<BrowserLookupCandidate>,
476        ) -> Result<Vec<LookupQueryOutcome<BrowserLookupCandidate>>, Self::Error> {
477            let request = BrowserLookupBatch {
478                target: hex::encode(target),
479                count,
480                iteration,
481                candidates: batch.into_iter().map(|candidate| candidate.wire).collect(),
482            };
483            let request = serde_wasm_bindgen::to_value(&request)
484                .map_err(|error| format!("could not encode lookup batch: {error}"))?;
485            let returned = self
486                .callback
487                .call1(&JsValue::NULL, &request)
488                .map_err(js_error_message)?;
489            let returned = JsFuture::from(Promise::resolve(&returned))
490                .await
491                .map_err(js_error_message)?;
492            let outcomes: Vec<BrowserLookupQueryOutcome> = serde_wasm_bindgen::from_value(returned)
493                .map_err(|error| format!("invalid lookup batch response: {error}"))?;
494
495            outcomes
496                .into_iter()
497                .map(|outcome| match outcome {
498                    BrowserLookupQueryOutcome::Succeeded {
499                        responder,
500                        candidates,
501                    } => {
502                        let responder = parse_lookup_key(&responder, "lookup responder")
503                            .map_err(js_error_message)?;
504                        let candidates = candidates
505                            .into_iter()
506                            .map(BrowserLookupCandidate::parse)
507                            .collect::<Result<Vec<_>, _>>()
508                            .map_err(js_error_message)?
509                            .into_iter()
510                            .filter_map(|candidate| {
511                                resolve_candidate_endpoint(self.known_endpoints, candidate)
512                            })
513                            .collect();
514                        Ok(LookupQueryOutcome::Succeeded {
515                            responder,
516                            candidates,
517                        })
518                    }
519                    BrowserLookupQueryOutcome::Failed { responder } => {
520                        parse_lookup_key(&responder, "lookup responder")
521                            .map(|responder| LookupQueryOutcome::Failed { responder })
522                            .map_err(js_error_message)
523                    }
524                    BrowserLookupQueryOutcome::Unresponsive { responder } => {
525                        parse_lookup_key(&responder, "lookup responder")
526                            .map(|responder| LookupQueryOutcome::Unresponsive { responder })
527                            .map_err(js_error_message)
528                    }
529                })
530                .collect()
531        }
532    }
533
534    fn resolve_candidate_endpoint(
535        known_endpoints: &mut HashMap<LookupKey, BrowserLookupEndpoint>,
536        mut candidate: BrowserLookupCandidate,
537    ) -> Option<BrowserLookupCandidate> {
538        if let Some(endpoint) = candidate.wire.webrtc_direct.clone() {
539            known_endpoints.insert(candidate.peer_id, endpoint);
540        } else if let Some(endpoint) = known_endpoints.get(&candidate.peer_id) {
541            candidate.wire.webrtc_direct = Some(endpoint.clone());
542        }
543        candidate.wire.webrtc_direct.as_ref()?;
544        Some(candidate)
545    }
546
547    fn js_error_message(value: JsValue) -> String {
548        value
549            .as_string()
550            .unwrap_or_else(|| format!("JavaScript lookup callback failed: {value:?}"))
551    }
552
553    fn parse_lookup_nodes(value: JsValue) -> Result<Vec<BrowserLookupCandidate>, JsValue> {
554        let nodes: Vec<BrowserLookupNode> = serde_wasm_bindgen::from_value(value)
555            .map_err(|error| JsValue::from_str(&format!("invalid lookup nodes: {error}")))?;
556        nodes
557            .into_iter()
558            .map(BrowserLookupCandidate::parse)
559            .collect()
560    }
561
562    fn parse_lookup_key(value: &str, label: &str) -> Result<LookupKey, JsValue> {
563        let value = value.strip_prefix("0x").unwrap_or(value);
564        let bytes = hex::decode(value)
565            .map_err(|error| JsValue::from_str(&format!("invalid {label}: {error}")))?;
566        bytes.try_into().map_err(|bytes: Vec<u8>| {
567            JsValue::from_str(&format!(
568                "invalid {label}: expected 32 bytes, received {}",
569                bytes.len()
570            ))
571        })
572    }
573
574    /// Install a readable panic hook for browser developer tools.
575    #[wasm_bindgen(start)]
576    pub fn start() {
577        console_error_panic_hook::set_once();
578    }
579
580    /// Validate and normalize a WebRTC Direct multiaddress in shared Rust.
581    #[wasm_bindgen(js_name = parseWebRtcDirectMultiaddr)]
582    pub fn parse_webrtc_direct_multiaddr_wasm(endpoint: JsValue) -> Result<JsValue, JsValue> {
583        let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint)
584            .map_err(|error| JsValue::from_str(&error.to_string()))?;
585        let parsed = parse_webrtc_direct_multiaddr(input.multiaddr())
586            .map_err(|error| JsValue::from_str(&error.to_string()))?;
587        serde_wasm_bindgen::to_value(&parsed).map_err(|error| JsValue::from_str(&error.to_string()))
588    }
589
590    /// Decode and bound-check a complete WebRTC browser response frame.
591    #[wasm_bindgen(js_name = parseResponseFrame)]
592    pub fn parse_response_frame_wasm(frame: &[u8]) -> Result<JsValue, JsValue> {
593        let parsed =
594            parse_response_frame(frame).map_err(|error| JsValue::from_str(&error.to_string()))?;
595        parsed
596            .serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true))
597            .map_err(|error| JsValue::from_str(&error.to_string()))
598    }
599
600    /// Build the ICE-lite answer pinned by a WebRTC Direct endpoint.
601    #[wasm_bindgen(js_name = serverAnswerFromEndpoint)]
602    pub fn server_answer_from_endpoint_wasm(
603        endpoint: JsValue,
604        ice_credential: &str,
605    ) -> Result<JsValue, JsValue> {
606        let input: BrowserEndpointInput = serde_wasm_bindgen::from_value(endpoint)
607            .map_err(|error| JsValue::from_str(&error.to_string()))?;
608        let endpoint = parse_webrtc_direct_multiaddr(input.multiaddr())
609            .map_err(|error| JsValue::from_str(&error.to_string()))?;
610        let sdp = server_answer_sdp(&endpoint, ice_credential)
611            .map_err(|error| JsValue::from_str(&error.to_string()))?;
612        serde_wasm_bindgen::to_value(&BrowserSessionDescription {
613            description_type: "answer",
614            sdp,
615        })
616        .map_err(|error| JsValue::from_str(&error.to_string()))
617    }
618
619    /// Derive the v2 server ufrag from an unchanged browser local description.
620    #[wasm_bindgen(js_name = webRtcDirectV2ServerCredential)]
621    pub fn web_rtc_direct_v2_server_credential_wasm(local_sdp: &str) -> Result<String, JsValue> {
622        let password = ice_password_from_sdp(local_sdp)
623            .map_err(|error| JsValue::from_str(&error.to_string()))?;
624        v2_server_ice_credential(&password).map_err(|error| JsValue::from_str(&error.to_string()))
625    }
626
627    /// Read bundled mainnet WebRTC seeds and evmlib payment defaults without I/O.
628    #[wasm_bindgen(js_name = mainnetNetworkDefaults)]
629    pub fn mainnet_network_defaults_wasm() -> Result<JsValue, JsValue> {
630        let defaults = crate::network_defaults::browser_mainnet_defaults()
631            .map_err(|error| JsValue::from_str(&error.to_string()))?;
632        serde_wasm_bindgen::to_value(&defaults)
633            .map_err(|error| JsValue::from_str(&error.to_string()))
634    }
635
636    /// Validate and normalize browser bootstrap and public-file metadata.
637    #[wasm_bindgen(js_name = parseBrowserManifest)]
638    pub fn parse_browser_manifest_wasm(value: JsValue) -> Result<JsValue, JsValue> {
639        let value: serde_json::Value = serde_wasm_bindgen::from_value(value)
640            .map_err(|error| JsValue::from_str(&error.to_string()))?;
641        let manifest =
642            parse_browser_manifest(value).map_err(|error| JsValue::from_str(&error.to_string()))?;
643        serde_wasm_bindgen::to_value(&manifest)
644            .map_err(|error| JsValue::from_str(&error.to_string()))
645    }
646
647    /// Compute the native EVM `PaymentQuote` hash.
648    #[wasm_bindgen(js_name = paymentQuoteHash)]
649    #[must_use]
650    pub fn payment_quote_hash_wasm(
651        signed_bytes: &[u8],
652        public_key: &[u8],
653        signature: &[u8],
654    ) -> String {
655        hex::encode(payment_quote_hash(signed_bytes, public_key, signature))
656    }
657
658    /// Fully verify a storage quote before exposing it to a wallet signer.
659    #[wasm_bindgen(js_name = verifyStorageQuote)]
660    pub fn verify_storage_quote_wasm(
661        quote: JsValue,
662        expected_address: &str,
663        expected_peer_id: &str,
664    ) -> Result<JsValue, JsValue> {
665        let quote: BrowserQuoteArtifact = serde_wasm_bindgen::from_value(quote)
666            .map_err(|error| JsValue::from_str(&error.to_string()))?;
667        let verified = verify_storage_quote(quote, expected_address, expected_peer_id)
668            .map_err(|error| JsValue::from_str(&error.to_string()))?;
669        serde_wasm_bindgen::to_value(&verified)
670            .map_err(|error| JsValue::from_str(&error.to_string()))
671    }
672
673    /// Decode a confirmed vault event using the native ABI and prepared request.
674    #[wasm_bindgen(js_name = decodeMerklePaymentReceipt)]
675    pub fn decode_merkle_payment_receipt(
676        request: JsValue,
677        vault: &str,
678        logs: JsValue,
679    ) -> Result<JsValue, JsValue> {
680        let request = serde_wasm_bindgen::from_value(request)
681            .map_err(|e| JsValue::from_str(&e.to_string()))?;
682        let logs: Vec<super::payment::PaymentLog> =
683            serde_wasm_bindgen::from_value(logs).map_err(|e| JsValue::from_str(&e.to_string()))?;
684        let result = super::payment::decode_merkle_receipt(&request, vault, &logs)
685            .map_err(|e| JsValue::from_str(&e.to_string()))?;
686        serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
687    }
688
689    /// Native `self_encryption` plus public DataMap generation.
690    #[wasm_bindgen(js_name = encryptPublicFile)]
691    pub fn encrypt_public_file_wasm(content: &[u8]) -> Result<JsValue, JsValue> {
692        let encrypted =
693            encrypt_public_file(content).map_err(|error| JsValue::from_str(&error.to_string()))?;
694        serde_wasm_bindgen::to_value(&encrypted)
695            .map_err(|error| JsValue::from_str(&error.to_string()))
696    }
697
698    /// Incremental self-encryptor used from a worker with a synchronous file reader.
699    ///
700    /// Each call to `nextRecord` materializes at most one encrypted record. This
701    /// lets JavaScript persist the record before asking WASM for the next one,
702    /// keeping plaintext and ciphertext file-sized buffers out of the page.
703    #[wasm_bindgen(js_name = BrowserFileEncryptor)]
704    pub struct BrowserFileEncryptor {
705        stream: self_encryption::EncryptionStream<Box<dyn Iterator<Item = Bytes>>>,
706        file_size: usize,
707        bytes_read: Rc<Cell<usize>>,
708        read_error: Rc<RefCell<Option<String>>>,
709        whole_file_hasher: Rc<RefCell<blake3::Hasher>>,
710        data_map_records: HashMap<[u8; 32], Bytes>,
711        records: Vec<BrowserRecordInfo>,
712        data_map_record_yielded: bool,
713    }
714
715    #[wasm_bindgen(js_class = BrowserFileEncryptor)]
716    impl BrowserFileEncryptor {
717        /// Create an encryptor around a synchronous `(offset, length) => Uint8Array` reader.
718        ///
719        /// Browsers expose synchronous `File` reads only inside dedicated workers,
720        /// so page code should construct this class there rather than on the UI thread.
721        #[wasm_bindgen(constructor)]
722        pub fn new(file_size: usize, read_chunk: Function) -> Result<Self, JsValue> {
723            if file_size < self_encryption::MIN_ENCRYPTABLE_BYTES {
724                return Err(JsValue::from_str(&format!(
725                    "self-encryption requires at least {} bytes",
726                    self_encryption::MIN_ENCRYPTABLE_BYTES
727                )));
728            }
729            if file_size > MAX_BROWSER_FILE_BYTES {
730                return Err(JsValue::from_str(&format!(
731                    "browser files are limited to {MAX_BROWSER_FILE_BYTES} bytes"
732                )));
733            }
734
735            let bytes_read = Rc::new(Cell::new(0usize));
736            let iterator_bytes_read = Rc::clone(&bytes_read);
737            let read_error = Rc::new(RefCell::new(None));
738            let iterator_error = Rc::clone(&read_error);
739            let whole_file_hasher = Rc::new(RefCell::new(blake3::Hasher::new()));
740            let iterator_hasher = Rc::clone(&whole_file_hasher);
741            let iterator = std::iter::from_fn(move || {
742                if iterator_error.borrow().is_some() {
743                    return None;
744                }
745                let offset = iterator_bytes_read.get();
746                if offset >= file_size {
747                    return None;
748                }
749                let length = (file_size - offset).min(self_encryption::MAX_CHUNK_SIZE);
750                let returned = match read_chunk.call2(
751                    &JsValue::NULL,
752                    &JsValue::from_f64(offset as f64),
753                    &JsValue::from_f64(length as f64),
754                ) {
755                    Ok(returned) => returned,
756                    Err(error) => {
757                        *iterator_error.borrow_mut() = Some(js_error_message(error));
758                        return None;
759                    }
760                };
761                if !returned.is_instance_of::<Uint8Array>() {
762                    *iterator_error.borrow_mut() = Some(format!(
763                        "file reader returned a non-Uint8Array at byte offset {offset}"
764                    ));
765                    return None;
766                }
767                let returned = Uint8Array::new(&returned);
768                let actual = returned.length() as usize;
769                if actual != length {
770                    *iterator_error.borrow_mut() = Some(format!(
771                        "file reader returned {actual} bytes at offset {offset}, expected {length}"
772                    ));
773                    return None;
774                }
775                let mut content = vec![0u8; actual];
776                returned.copy_to(&mut content);
777                iterator_hasher.borrow_mut().update(&content);
778                iterator_bytes_read.set(offset + actual);
779                Some(Bytes::from(content))
780            });
781            let stream = self_encryption::stream_encrypt(
782                file_size,
783                Box::new(iterator) as Box<dyn Iterator<Item = Bytes>>,
784            )
785            .map_err(|error| JsValue::from_str(&error.to_string()))?;
786
787            Ok(Self {
788                stream,
789                file_size,
790                bytes_read,
791                read_error,
792                whole_file_hasher,
793                data_map_records: HashMap::new(),
794                records: Vec::new(),
795                data_map_record_yielded: false,
796            })
797        }
798
799        /// Produce the next encrypted record, or `undefined` once all records are staged.
800        #[wasm_bindgen(js_name = nextRecord)]
801        pub fn next_record(&mut self) -> Result<JsValue, JsValue> {
802            if self.data_map_record_yielded {
803                return Ok(JsValue::UNDEFINED);
804            }
805
806            let next = self.stream.chunks().next();
807            if let Some(error) = self.read_error.borrow().as_ref() {
808                return Err(JsValue::from_str(error));
809            }
810            if let Some(result) = next {
811                let (hash, content) = result.map_err(|error| {
812                    JsValue::from_str(&format!("self-encryption failed: {error}"))
813                })?;
814                // Once `datamap()` becomes available, stream output consists only
815                // of the small encrypted child DataMaps needed to resolve the root.
816                if self.stream.datamap().is_some() {
817                    self.data_map_records.insert(hash.0, content.clone());
818                }
819                return self.serialize_record(content_address(&content), content.to_vec());
820            }
821
822            let published_data_map = self.stream.datamap().ok_or_else(|| {
823                JsValue::from_str("self-encryption ended before producing a DataMap")
824            })?;
825            let (address, encoded) =
826                crate::client_engine::files::public_map_record(published_data_map)
827                    .map_err(|error| JsValue::from_str(&error))?;
828            let address = hex::encode(address);
829            self.data_map_record_yielded = true;
830            self.serialize_record(address, encoded.to_vec())
831        }
832
833        /// Return upload metadata after `nextRecord` has reached `undefined`.
834        pub fn finish(&self, name: &str, content_type: &str) -> Result<JsValue, JsValue> {
835            if !self.data_map_record_yielded {
836                return Err(JsValue::from_str(
837                    "all encrypted records must be staged before finishing",
838                ));
839            }
840            if self.bytes_read.get() != self.file_size {
841                return Err(JsValue::from_str(&format!(
842                    "file reader supplied {} bytes, expected {}",
843                    self.bytes_read.get(),
844                    self.file_size
845                )));
846            }
847            let published_data_map = self
848                .stream
849                .datamap()
850                .ok_or_else(|| JsValue::from_str("self-encryption did not produce a DataMap"))?;
851            let mut get_local_chunk = |address: self_encryption::XorName| {
852                self.data_map_records
853                    .get(&address.0)
854                    .cloned()
855                    .ok_or_else(|| {
856                        self_encryption::Error::Generic(format!(
857                            "streaming output omitted DataMap chunk {}",
858                            hex::encode(address.0)
859                        ))
860                    })
861            };
862            let root_data_map = self_encryption::get_root_data_map(
863                published_data_map.clone(),
864                &mut get_local_chunk,
865            )
866            .map_err(|error| JsValue::from_str(&format!("self-encryption failed: {error}")))?;
867            let public_record = self.records.last().ok_or_else(|| {
868                JsValue::from_str("self-encryption omitted the public DataMap record")
869            })?;
870            let staged = BrowserStagedFile {
871                name: name.to_string(),
872                content_type: if content_type.is_empty() {
873                    "application/octet-stream".to_string()
874                } else {
875                    content_type.to_string()
876                },
877                address: public_record.address.clone(),
878                blake3: self
879                    .whole_file_hasher
880                    .borrow()
881                    .clone()
882                    .finalize()
883                    .to_hex()
884                    .to_string(),
885                size: self.file_size,
886                data_map_size: public_record.size,
887                chunks: chunk_infos(&root_data_map),
888                records: self.records.clone(),
889            };
890            serde_wasm_bindgen::to_value(&staged)
891                .map_err(|error| JsValue::from_str(&error.to_string()))
892        }
893    }
894
895    impl BrowserFileEncryptor {
896        fn serialize_record(
897            &mut self,
898            address: String,
899            content: Vec<u8>,
900        ) -> Result<JsValue, JsValue> {
901            self.records.push(BrowserRecordInfo {
902                address: address.clone(),
903                size: content.len(),
904            });
905            serde_wasm_bindgen::to_value(&BrowserRecord { address, content })
906                .map_err(|error| JsValue::from_str(&error.to_string()))
907        }
908    }
909
910    /// Native BLAKE3 content address.
911    #[wasm_bindgen(js_name = contentAddress)]
912    #[must_use]
913    pub fn content_address_wasm(content: &[u8]) -> String {
914        content_address(content)
915    }
916
917    /// Verify one content-addressed record with native BLAKE3.
918    #[wasm_bindgen(js_name = verifyRecord)]
919    pub fn verify_record_wasm(address: &str, content: &[u8]) -> Result<String, JsValue> {
920        verify_record(address, content).map_err(|error| JsValue::from_str(&error.to_string()))?;
921        Ok(content_address(content))
922    }
923
924    /// Decode a native public DataMap for browser-side record retrieval.
925    #[wasm_bindgen(js_name = decodePublicDataMap)]
926    pub fn decode_public_data_map_wasm(content: &[u8]) -> Result<JsValue, JsValue> {
927        let chunks = super::decode_public_data_map(content)
928            .map_err(|error| JsValue::from_str(&error.to_string()))?;
929        serde_wasm_bindgen::to_value(&chunks).map_err(|error| JsValue::from_str(&error.to_string()))
930    }
931
932    /// Native public DataMap decoding and whole-file reconstruction.
933    #[wasm_bindgen(js_name = decryptPublicFile)]
934    pub fn decrypt_public_file_wasm(
935        data_map_content: &[u8],
936        encrypted_contents: Array,
937    ) -> Result<Uint8Array, JsValue> {
938        let encrypted_contents = encrypted_contents
939            .iter()
940            .map(|value| Uint8Array::new(&value).to_vec())
941            .collect::<Vec<_>>();
942        let plaintext = decrypt_public_file(data_map_content, &encrypted_contents)
943            .map_err(|error| JsValue::from_str(&error.to_string()))?;
944        Ok(Uint8Array::from(plaintext.as_slice()))
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    fn fixture() -> Vec<u8> {
953        "browser whole-file fixture\n".repeat(160).into_bytes()
954    }
955
956    #[test]
957    fn native_browser_encrypt_matches_existing_wire_vector() {
958        let encrypted = encrypt_public_file(&fixture()).expect("encrypt fixture");
959        assert_eq!(
960            encrypted
961                .chunks
962                .iter()
963                .map(|chunk| chunk.dst_hash.as_str())
964                .collect::<Vec<_>>(),
965            vec![
966                "c024c6884a2f39be7ba07c3d9636efedeb94df7397fcd38bac5ae904643c5cc9",
967                "350a88e6eb0b2a3e774107a212a272b4191af69ca4366a4b91f5a1e5872c459a",
968                "d73db5a8b0be3b571b40d2b80ff490fe45e135f1992c5863ecb78e25d00ceddb",
969            ]
970        );
971        assert_eq!(
972            encrypted.address,
973            "0d3636dd504d04a236f7e104909234766f077fa7e1ca4a18293d3d168d5f169b"
974        );
975        assert_eq!(
976            encrypted.blake3,
977            "e0e422267ac59c56bf032d6d830035d343369d20147dd5f6b63351a29b015f22"
978        );
979    }
980
981    #[test]
982    fn native_browser_round_trip_and_tamper_rejection() {
983        let content = fixture();
984        let encrypted = encrypt_public_file(&content).expect("encrypt fixture");
985        let data_map = &encrypted.records.last().expect("DataMap record").content;
986        let chunks = encrypted.records[..encrypted.records.len() - 1]
987            .iter()
988            .map(|record| record.content.clone())
989            .collect::<Vec<_>>();
990        assert_eq!(
991            decrypt_public_file(data_map, &chunks).expect("decrypt fixture"),
992            content
993        );
994
995        let mut tampered = chunks;
996        tampered[0][0] ^= 1;
997        assert!(decrypt_public_file(data_map, &tampered).is_err());
998    }
999
1000    #[test]
1001    fn nested_data_map_round_trip() {
1002        let size = 3 * self_encryption::MAX_CHUNK_SIZE + 1;
1003        let content = (0..size).map(|index| index as u8).collect::<Vec<_>>();
1004        let encrypted = encrypt_public_file(&content).expect("encrypt nested fixture");
1005        assert_eq!(encrypted.chunks.len(), 4);
1006        assert!(encrypted.records.len() > encrypted.chunks.len() + 1);
1007
1008        let data_map = &encrypted.records.last().expect("DataMap record").content;
1009        let published: DataMap = rmp_serde::from_slice(data_map).expect("decode published map");
1010        assert!(published.is_child());
1011
1012        let mut required_addresses = decode_public_data_map(data_map)
1013            .expect("decode child map")
1014            .into_iter()
1015            .map(|chunk| chunk.dst_hash)
1016            .collect::<HashSet<_>>();
1017        required_addresses.extend(encrypted.chunks.iter().map(|chunk| chunk.dst_hash.clone()));
1018        let records = encrypted.records[..encrypted.records.len() - 1]
1019            .iter()
1020            .filter(|record| required_addresses.contains(&record.address))
1021            .map(|record| record.content.clone())
1022            .collect::<Vec<_>>();
1023        assert_eq!(records.len(), encrypted.records.len() - 1);
1024        assert_eq!(
1025            decrypt_public_file(data_map, &records).expect("decrypt nested fixture"),
1026            content
1027        );
1028    }
1029}