1pub use super::protocol::BrowserPaymentNetwork;
4use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint};
5use super::BrowserChunkInfo;
6use serde::{Deserialize, Serialize};
7
8pub const BROWSER_MANIFEST_VERSION: u16 = 6;
10const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024;
11const MAX_FILE_CHUNKS: usize = 1024;
12
13pub type BrowserManifestEndpoint = BrowserEndpoint;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct PublicFileDescriptor {
19 pub name: String,
21 pub address: String,
23 pub size: usize,
25 pub content_type: String,
27 pub blake3: String,
29 pub data_map_size: usize,
31 pub chunks: Vec<BrowserChunkInfo>,
33 pub replicas: usize,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct BrowserManifest {
40 pub version: u16,
42 pub network_id: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub created_at: Option<String>,
47 pub endpoints: Vec<BrowserManifestEndpoint>,
49 pub payment: BrowserPaymentNetwork,
51 #[serde(default)]
53 pub files: Vec<PublicFileDescriptor>,
54}
55
56#[derive(Debug, thiserror::Error)]
58#[error("invalid browser manifest: {0}")]
59pub struct BrowserManifestError(pub String);
60
61pub fn parse_browser_manifest(
63 value: serde_json::Value,
64) -> Result<BrowserManifest, BrowserManifestError> {
65 let mut manifest: BrowserManifest =
66 serde_json::from_value(value).map_err(|error| BrowserManifestError(error.to_string()))?;
67 if manifest.version != BROWSER_MANIFEST_VERSION {
68 return Err(BrowserManifestError(format!(
69 "unsupported browser manifest version {}",
70 manifest.version
71 )));
72 }
73 if manifest.network_id.is_empty() {
74 return Err(BrowserManifestError(
75 "browser manifest has no network ID".to_string(),
76 ));
77 }
78 if manifest.endpoints.is_empty() {
79 return Err(BrowserManifestError(
80 "browser manifest contains no WebRtcDirect endpoints".to_string(),
81 ));
82 }
83 for endpoint in &mut manifest.endpoints {
84 let parsed = parse_webrtc_direct_multiaddr(&endpoint.multiaddr)
85 .map_err(|error| BrowserManifestError(error.to_string()))?;
86 endpoint.multiaddr = parsed.multiaddr;
87 }
88 manifest.payment = validate_browser_payment_network(manifest.payment)?;
89 for file in &mut manifest.files {
90 normalize_file(file)?;
91 }
92 Ok(manifest)
93}
94
95pub fn validate_browser_payment_network(
98 mut payment: BrowserPaymentNetwork,
99) -> Result<BrowserPaymentNetwork, BrowserManifestError> {
100 if payment.chain_id > 9_007_199_254_740_991 {
103 return Err(BrowserManifestError(
104 "payment chain ID exceeds JavaScript's safe integer range".to_string(),
105 ));
106 }
107 payment.payment_token_address = format!(
108 "0x{}",
109 normalize_hex(&payment.payment_token_address, 20).map_err(BrowserManifestError)?
110 );
111 payment.payment_vault_address = format!(
112 "0x{}",
113 normalize_hex(&payment.payment_vault_address, 20).map_err(BrowserManifestError)?
114 );
115 Ok(payment)
116}
117
118#[cfg(any(all(target_arch = "wasm32", feature = "browser-wasm"), test))]
120pub(crate) fn assert_upload_node(
121 hello: &super::protocol::BrowserHello,
122 expected: &BrowserPaymentNetwork,
123) -> Result<(), String> {
124 if !hello
125 .capabilities
126 .iter()
127 .any(|value| value == "quote_chunk")
128 || !hello.capabilities.iter().any(|value| value == "put_chunk")
129 {
130 return Err("node does not advertise paid browser uploads".to_string());
131 }
132 let advertised = &hello.payment;
133 if advertised.chain_id != expected.chain_id
134 || !advertised
135 .payment_token_address
136 .eq_ignore_ascii_case(&expected.payment_token_address)
137 || !advertised
138 .payment_vault_address
139 .eq_ignore_ascii_case(&expected.payment_vault_address)
140 {
141 return Err("node advertises a different payment network than the client".to_string());
142 }
143 Ok(())
144}
145
146fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifestError> {
147 if file.name.is_empty() {
148 return Err(BrowserManifestError(
149 "browser manifest file has no name".to_string(),
150 ));
151 }
152 file.address = normalize_hex(&file.address, 32).map_err(BrowserManifestError)?;
153 if !(self_encryption::MIN_ENCRYPTABLE_BYTES..=super::MAX_BROWSER_FILE_BYTES)
154 .contains(&file.size)
155 {
156 return Err(BrowserManifestError(format!(
157 "invalid public file size {}",
158 file.size
159 )));
160 }
161 file.blake3 = normalize_hex(&file.blake3, 32).map_err(BrowserManifestError)?;
162 if !(1..=MAX_DATA_MAP_BYTES).contains(&file.data_map_size) {
163 return Err(BrowserManifestError(format!(
164 "invalid DataMap size {}",
165 file.data_map_size
166 )));
167 }
168 if !(3..=MAX_FILE_CHUNKS).contains(&file.chunks.len()) {
169 return Err(BrowserManifestError(
170 "public file has an invalid self-encryption chunk list".to_string(),
171 ));
172 }
173 file.chunks.sort_by_key(|chunk| chunk.index);
174 let mut reconstructed_size = 0usize;
175 for (expected_index, chunk) in file.chunks.iter_mut().enumerate() {
176 if chunk.index != expected_index {
177 return Err(BrowserManifestError(
178 "file chunk indices must be contiguous from zero".to_string(),
179 ));
180 }
181 chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?;
182 chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?;
183 if chunk.src_size == 0 {
184 return Err(BrowserManifestError(format!(
185 "invalid plaintext chunk size {}",
186 chunk.src_size
187 )));
188 }
189 reconstructed_size = reconstructed_size
190 .checked_add(chunk.src_size)
191 .ok_or_else(|| BrowserManifestError("file size overflow".to_string()))?;
192 }
193 if reconstructed_size != file.size {
194 return Err(BrowserManifestError(format!(
195 "file chunk sizes total {reconstructed_size}, expected {}",
196 file.size
197 )));
198 }
199 if file.content_type.is_empty() {
200 file.content_type = "application/octet-stream".to_string();
201 }
202 Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
209 use base64::Engine as _;
210
211 fn endpoint() -> String {
212 let mut multihash = vec![0x12, 0x20];
213 multihash.extend([0xbb; 32]);
214 format!(
215 "/ip4/127.0.0.1/udp/22000/webrtc-direct/certhash/u{}/p2p/{}",
216 URL_SAFE_NO_PAD.encode(multihash),
217 "AA".repeat(32)
218 )
219 }
220
221 #[test]
222 fn upload_identity_checks_chain_and_both_contracts() {
223 let payment = BrowserPaymentNetwork {
224 chain_id: 31337,
225 payment_token_address: format!("0x{}", "ab".repeat(20)),
226 payment_vault_address: format!("0x{}", "cd".repeat(20)),
227 };
228 let mut hello = super::super::protocol::BrowserHello {
229 response_type: "hello".into(),
230 protocol: super::super::protocol::BROWSER_PROTOCOL_NAME.into(),
231 peer_id: "aa".repeat(32),
232 endpoint: BrowserEndpoint {
233 multiaddr: endpoint(),
234 },
235 max_chunk_size: 4 * 1024 * 1024,
236 capabilities: vec!["quote_chunk".into(), "put_chunk".into()],
237 payment: payment.clone(),
238 };
239 assert!(assert_upload_node(&hello, &payment).is_ok());
240 hello.payment.payment_token_address = payment.payment_token_address.to_uppercase();
241 assert!(assert_upload_node(&hello, &payment).is_ok());
242 hello.payment.chain_id = 1;
243 assert!(assert_upload_node(&hello, &payment).is_err());
244 hello.payment = payment.clone();
245 hello.payment.payment_token_address = "11".repeat(20);
246 assert!(assert_upload_node(&hello, &payment).is_err());
247 hello.payment = payment.clone();
248 hello.payment.payment_vault_address = "22".repeat(20);
249 assert!(assert_upload_node(&hello, &payment).is_err());
250 hello.payment = payment.clone();
251 hello
252 .capabilities
253 .retain(|capability| capability != "put_chunk");
254 assert!(assert_upload_node(&hello, &payment).is_err());
255 }
256
257 #[test]
258 fn validates_and_normalizes_manifest() {
259 let value = serde_json::json!({
260 "version": 6,
261 "network_id": "local-test",
262 "created_at": "2026-08-03T00:00:00Z",
263 "payment": {
264 "chain_id": 31337,
265 "payment_token_address": format!("0x{}", "11".repeat(20)),
266 "payment_vault_address": format!("0x{}", "22".repeat(20)),
267 },
268 "endpoints": [{ "multiaddr": endpoint() }],
269 "files": [{
270 "name": "hello.txt",
271 "address": "CC".repeat(32),
272 "size": 12,
273 "content_type": "text/plain",
274 "blake3": "DD".repeat(32),
275 "data_map_size": 128,
276 "chunks": [
277 { "index": 2, "dst_hash": "13".repeat(32), "src_hash": "23".repeat(32), "src_size": 4 },
278 { "index": 0, "dst_hash": "11".repeat(32), "src_hash": "21".repeat(32), "src_size": 4 },
279 { "index": 1, "dst_hash": "12".repeat(32), "src_hash": "22".repeat(32), "src_size": 4 }
280 ],
281 "replicas": 5
282 }]
283 });
284 let manifest = parse_browser_manifest(value).expect("valid manifest");
285 assert_eq!(manifest.files[0].address, "cc".repeat(32));
286 assert_eq!(manifest.files[0].chunks[0].index, 0);
287 assert_eq!(manifest.payment.chain_id, 31337);
288 }
289}