Skip to main content

ant_core/browser/
manifest.rs

1//! Cross-platform validation for browser bootstrap and public-file metadata.
2
3pub use super::protocol::BrowserPaymentNetwork;
4use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint};
5use super::BrowserChunkInfo;
6use serde::{Deserialize, Serialize};
7
8/// Current browser testnet manifest version.
9pub const BROWSER_MANIFEST_VERSION: u16 = 6;
10const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024;
11const MAX_FILE_CHUNKS: usize = 1024;
12
13/// A validated WebRTC Direct bootstrap endpoint.
14pub type BrowserManifestEndpoint = BrowserEndpoint;
15
16/// Complete public-file metadata shared by native tooling and the web client.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct PublicFileDescriptor {
19    /// Display and save-as filename.
20    pub name: String,
21    /// Public DataMap content address.
22    pub address: String,
23    /// Plaintext file size.
24    pub size: usize,
25    /// Browser MIME type.
26    pub content_type: String,
27    /// Whole-file plaintext BLAKE3 hash.
28    pub blake3: String,
29    /// Encoded public DataMap size.
30    pub data_map_size: usize,
31    /// Self-encryption chunk descriptors.
32    pub chunks: Vec<BrowserChunkInfo>,
33    /// Minimum confirmed record replica count.
34    pub replicas: usize,
35}
36
37/// Validated bootstrap, payment, and public-file description.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct BrowserManifest {
40    /// Manifest schema version.
41    pub version: u16,
42    /// Network instance identifier.
43    pub network_id: String,
44    /// Optional manifest creation timestamp.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub created_at: Option<String>,
47    /// Stable WebRTC Direct bootstrap addresses.
48    pub endpoints: Vec<BrowserManifestEndpoint>,
49    /// Storage payment configuration.
50    pub payment: BrowserPaymentNetwork,
51    /// Known public files offered by the manifest.
52    #[serde(default)]
53    pub files: Vec<PublicFileDescriptor>,
54}
55
56/// Manifest validation error.
57#[derive(Debug, thiserror::Error)]
58#[error("invalid browser manifest: {0}")]
59pub struct BrowserManifestError(pub String);
60
61/// Decode, validate, and normalize an untrusted browser manifest.
62pub 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
95/// Validate and normalize payment configuration supplied independently of a
96/// manifest, such as to the browser network client WASM binding.
97pub fn validate_browser_payment_network(
98    mut payment: BrowserPaymentNetwork,
99) -> Result<BrowserPaymentNetwork, BrowserManifestError> {
100    // The JS SDK exposes chain IDs as numbers; reject identities that cannot
101    // survive that boundary exactly.
102    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/// Check upload capabilities and payment identity without comparing RPC providers.
119#[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}