From 99701e092730be4bb749c1efe48b66b4eada4b93 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:16:48 +0200 Subject: [PATCH 01/15] feat(wasm): support large-file range reads and streaming downloads --- README.md | 40 + .../examples/generate-browser-large-file.rs | 47 + ant-core/src/browser.rs | 4 +- ant-core/src/browser/manifest.rs | 42 +- ant-core/src/browser/wasm_transport.rs | 212 +- .../src/browser/wasm_transport/download.rs | 269 ++ ant-core/src/client_engine/files.rs | 213 +- ant-core/src/data/client/data.rs | 51 +- ant-core/wasm-tests/README.md | 10 + ant-core/wasm-tests/fixtures/large-file.json | 4136 +++++++++++++++++ ant-core/wasm-tests/large-download.test.mjs | 169 + 11 files changed, 4976 insertions(+), 217 deletions(-) create mode 100644 ant-core/examples/generate-browser-large-file.rs create mode 100644 ant-core/src/browser/wasm_transport/download.rs create mode 100644 ant-core/wasm-tests/fixtures/large-file.json create mode 100644 ant-core/wasm-tests/large-download.test.mjs diff --git a/README.md b/README.md index ea1a7ade..252f13bb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,46 @@ transports use `record.rs` for BLAKE3 verification. Native `Client::data_download_range` and the browser media reader call the same range implementation. +Browser downloads and range reads have no 1 GB file cap. File sizes and offsets +use 64-bit arithmetic internally; the JavaScript API accepts exact, nonnegative +`number` positions through `Number.MAX_SAFE_INTEGER` (just under 8 PiB). The +resolved DataMap and seek index still occupy memory proportional to the number +of chunks. Individual chunk buffers remain bounded, including for seeks past +4 GiB. Uploads retain their existing size limits. + +`downloadPublicFile(file, concurrency?, onProgress?, maxMemoryBytes?)` and its +private-file counterpart allocate the output as a JavaScript `Uint8Array`, then +fill it using bounded WASM reads. They no longer retain a complete plaintext +or ciphertext file in WASM memory. The optional budget limits the output buffer, +not total process memory. Without a budget, allocation is attempted up to the +JavaScript engine's buffer limit. Allocation errors reject with guidance to use +streaming; browsers do not expose a reliable portable amount of free memory. + +For large downloads, pass a `WritableStream` to `BrowserFileReader.pipeTo`. +For example, after the application obtains a file handle from a user gesture: + +```js +const reader = await client.openPublicFile(address); +try { + const writable = await fileHandle.createWritable(); + const { bytesWritten, hash } = await reader.pipeTo(writable); +} finally { + reader.close(); +} +``` + +The same API works with `openPrivateFile({ data_map })`. `pipeTo(writable, +{ start?, end? }, onProgress?)` optionally streams a half-open byte range and +reports `(bytesWritten, totalBytes)` after each write. It fetches and verifies +at most 4 MiB of plaintext per write, waits for the destination before continuing, +closes the destination on success, aborts on failure, and releases the writer +lock. Its result contains the byte count and BLAKE3 of the written range. +`reader.close()` cancels at a read/write boundary. `readRange(start, length)` +remains available for media and custom streaming sinks, with a 4 MiB per-call +limit. A sink must persist or consume the bytes instead of accumulating them to +keep memory bounded. The application chooses a memory budget and supplies its +disk destination; the core does not open file pickers or silently select a path. + The adapters supply QUIC or WebRTC discovery/GET requests, runtime timers, caches, progress callbacks, and output handling. Browser descriptors and wallet callbacks remain browser API concerns. Native filesystem streaming still uses diff --git a/ant-core/examples/generate-browser-large-file.rs b/ant-core/examples/generate-browser-large-file.rs new file mode 100644 index 00000000..adf98413 --- /dev/null +++ b/ant-core/examples/generate-browser-large-file.rs @@ -0,0 +1,47 @@ +//! Regenerate the compact native fixture used by the WASM large-file tests: +//! cargo run -p ant-core --release --example generate-browser-large-file +//! +//! Encrypts >4 GiB with bounded memory. Repeated plaintext compresses well; +//! all encrypted records fit in a small fixture without allocating the file. +use bytes::Bytes; +use std::collections::HashMap; + +fn main() { + let size = (1u64 << 32) + 2 * self_encryption::MAX_CHUNK_SIZE as u64 + 123; + let block = Bytes::from(vec![0x5a; self_encryption::MAX_CHUNK_SIZE]); + let mut remaining = size as usize; + let mut hasher = blake3::Hasher::new(); + let input = std::iter::from_fn(|| { + if remaining == 0 { + return None; + } + let length = remaining.min(block.len()); + remaining -= length; + hasher.update(&block[..length]); + Some(block.slice(..length)) + }); + let mut encryptor = self_encryption::stream_encrypt(size as usize, input).unwrap(); + let records: HashMap<_, _> = encryptor.chunks().map(|item| item.unwrap()).collect(); + let map = encryptor.into_datamap().unwrap(); + let root = + self_encryption::get_root_data_map(map.clone(), &mut |hash| Ok(records[&hash].clone())) + .unwrap(); + assert_eq!(root.original_file_size() as u64, size); + let mut retained = records; + let map_bytes = rmp_serde::to_vec(&map).unwrap(); + let address = *blake3::hash(&map_bytes).as_bytes(); + retained.insert(self_encryption::XorName(address), Bytes::from(map_bytes)); + let mut records: Vec<_> = retained.into_iter().map(|(hash, content)| { + serde_json::json!({"address": hex::encode(hash.0), "content": hex::encode(content)}) + }).collect(); + records.sort_by(|a, b| a["address"].as_str().cmp(&b["address"].as_str())); + let fixture = serde_json::json!({ + "size": size, "byte": 0x5a, "hash": hasher.finalize().to_hex().to_string(), + "address": hex::encode(address), "records": records, + }); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("wasm-tests/fixtures/large-file.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + eprintln!("Wrote {}-byte file fixture to {}", size, path.display()); +} diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 5b4b5086..0d524734 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -31,10 +31,10 @@ use self_encryption::{DataMap, EncryptedChunk}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -/// Maximum file size accepted by the browser API (1 GB decimal). +/// Maximum file size accepted by the browser upload API (1 GB decimal). /// /// The page upload path streams through a worker and browser storage. Complete -/// downloads and the legacy whole-buffer encryption binding remain memory-bound. +/// downloads use bounded ranges; the legacy encryption binding remains memory-bound. pub const MAX_BROWSER_FILE_BYTES: usize = 1_000_000_000; /// One native self-encryption chunk descriptor exposed to the browser. diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs index 6b2dc01d..cc8eaec6 100644 --- a/ant-core/src/browser/manifest.rs +++ b/ant-core/src/browser/manifest.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; /// Current browser testnet manifest version. pub const BROWSER_MANIFEST_VERSION: u16 = 6; const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024; -const MAX_FILE_CHUNKS: usize = 1024; +/// Largest exact byte position exposed through the JavaScript number API. +pub const MAX_BROWSER_FILE_POSITION: u64 = 9_007_199_254_740_991; /// A validated WebRTC Direct bootstrap endpoint. pub type BrowserManifestEndpoint = BrowserEndpoint; @@ -21,7 +22,7 @@ pub struct PublicFileDescriptor { /// Public DataMap content address. pub address: String, /// Plaintext file size. - pub size: usize, + pub size: u64, /// Browser MIME type. pub content_type: String, /// Whole-file plaintext BLAKE3 hash. @@ -150,7 +151,7 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest )); } file.address = normalize_hex(&file.address, 32).map_err(BrowserManifestError)?; - if !(self_encryption::MIN_ENCRYPTABLE_BYTES..=super::MAX_BROWSER_FILE_BYTES) + if !(self_encryption::MIN_ENCRYPTABLE_BYTES as u64..=MAX_BROWSER_FILE_POSITION) .contains(&file.size) { return Err(BrowserManifestError(format!( @@ -165,13 +166,13 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest file.data_map_size ))); } - if !(3..=MAX_FILE_CHUNKS).contains(&file.chunks.len()) { + if file.chunks.len() < 3 { return Err(BrowserManifestError( "public file has an invalid self-encryption chunk list".to_string(), )); } file.chunks.sort_by_key(|chunk| chunk.index); - let mut reconstructed_size = 0usize; + let mut reconstructed_size = 0u64; for (expected_index, chunk) in file.chunks.iter_mut().enumerate() { if chunk.index != expected_index { return Err(BrowserManifestError( @@ -180,14 +181,14 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest } chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?; chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?; - if chunk.src_size == 0 { + if chunk.src_size == 0 || chunk.src_size > self_encryption::MAX_CHUNK_SIZE { return Err(BrowserManifestError(format!( "invalid plaintext chunk size {}", chunk.src_size ))); } reconstructed_size = reconstructed_size - .checked_add(chunk.src_size) + .checked_add(chunk.src_size as u64) .ok_or_else(|| BrowserManifestError("file size overflow".to_string()))?; } if reconstructed_size != file.size { @@ -286,4 +287,31 @@ mod tests { assert_eq!(manifest.files[0].chunks[0].index, 0); assert_eq!(manifest.payment.chain_id, 31337); } + + #[test] + fn file_descriptors_allow_more_than_four_gib_and_1024_chunks() { + let mut file = PublicFileDescriptor { + name: "large.bin".into(), + address: "11".repeat(32), + size: 1030 * self_encryption::MAX_CHUNK_SIZE as u64, + content_type: "application/octet-stream".into(), + blake3: "22".repeat(32), + data_map_size: 256, + replicas: 4, + chunks: (0..1030) + .map(|index| BrowserChunkInfo { + index, + src_size: self_encryption::MAX_CHUNK_SIZE, + src_hash: "33".repeat(32), + dst_hash: "44".repeat(32), + }) + .collect(), + }; + assert!(file.size > u32::MAX as u64); + normalize_file(&mut file).unwrap(); + file.size += 1; + assert!(normalize_file(&mut file).is_err()); + file.size = MAX_BROWSER_FILE_POSITION + 1; + assert!(normalize_file(&mut file).is_err()); + } } diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index 5355a084..e273f8a2 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -117,6 +117,8 @@ thread_local! { } mod bootstrap; +mod download; +pub use download::BrowserFileReader; mod failed_payment; mod inbox; mod multiplex; @@ -2026,8 +2028,8 @@ fn parse_lookup_key(value: &str, label: &str) -> Result { #[derive(Debug, Serialize)] struct BrowserDownloadResult { - #[serde(with = "serde_bytes")] - content: Vec, + #[serde(with = "serde_wasm_bindgen::preserve")] + content: Uint8Array, hash: String, file: PublicFileDescriptor, /// Node that served a public DataMap; absent for a caller-held private DataMap. @@ -2241,88 +2243,6 @@ impl RecordPlacement { } } -/// Random-access public-file reader for media playback and bounded downloads. -#[wasm_bindgen(js_name = BrowserFileReader)] -pub struct BrowserFileReader { - shared: Rc, - file: PublicFileDescriptor, - root_data_map: self_encryption::DataMap, - read_ahead: Rc, - closed: Cell, -} - -#[wasm_bindgen(js_class = BrowserFileReader)] -impl BrowserFileReader { - /// Plaintext file size in bytes. - #[wasm_bindgen(getter)] - pub fn size(&self) -> usize { - self.file.size - } - - /// Browser MIME type advertised by the file descriptor. - #[wasm_bindgen(getter, js_name = contentType)] - pub fn content_type(&self) -> String { - self.file.content_type.clone() - } - - /// Display filename advertised by the file descriptor. - #[wasm_bindgen(getter)] - pub fn name(&self) -> String { - self.file.name.clone() - } - - /// Fetch and decrypt one plaintext byte range without reconstructing the file. - #[wasm_bindgen(js_name = readRange)] - pub async fn read_range(&self, start: usize, length: usize) -> Result { - let content = self - .read_range_inner(start, length) - .await - .map_err(|error| JsValue::from_str(&error))?; - Ok(Uint8Array::from(content.as_slice())) - } - - /// Release cached encrypted records held for playback read-ahead and seeks. - pub fn close(&self) { - self.closed.set(true); - self.read_ahead.close(); - self.shared.chunk_cache().clear(); - } -} - -impl Drop for BrowserFileReader { - fn drop(&mut self) { - self.read_ahead.close(); - } -} - -impl BrowserFileReader { - async fn read_range_inner(&self, start: usize, length: usize) -> Result, String> { - if self.closed.get() { - return Err("browser file reader is closed".to_string()); - } - if length > MAX_BROWSER_RANGE_BYTES { - return Err(format!( - "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" - )); - } - let lease = self.read_ahead.begin_read(start, length); - let bytes = self - .shared - .data_download_range_with(&self.root_data_map, start, length, |address| { - lease.record(address) - }) - .await; - if matches!(bytes, Err(crate::data::Error::Encryption(_))) { - // The records do not decrypt as the DataMap declares, so reading - // further ahead would only spend bandwidth and memory. - self.read_ahead.close(); - } - bytes - .map(|bytes| bytes.to_vec()) - .map_err(|error| error.to_string()) - } -} - #[derive(Default, Clone)] struct UploadCheckpoint { mode: PaymentMode, @@ -2483,32 +2403,48 @@ impl BrowserNetworkClient { serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) } - /// Download and reconstruct a complete public Autonomi file. + /// Download into one JavaScript Uint8Array, using bounded WASM buffers. + /// Optional max_memory_bytes limits the output allocation. For disk-backed + /// downloads use openPublicFile followed by reader.pipeTo(writable). #[wasm_bindgen(js_name = downloadPublicFile)] pub async fn download_public_file( &self, file: JsValue, concurrency: Option, on_progress: Option, + max_memory_bytes: Option, ) -> Result { let file: BrowserPublicFileInput = serde_wasm_bindgen::from_value(file) .map_err(|error| JsValue::from_str(&error.to_string()))?; - self.download_file(BrowserFileSource::Public(file), concurrency, on_progress) - .await + self.download_file( + BrowserFileSource::Public(file), + concurrency, + on_progress, + max_memory_bytes, + ) + .await } - /// Download and reconstruct a private file from the DataMap its uploader kept. + /// Download a private file into one JavaScript Uint8Array. The optional + /// max_memory_bytes limits output allocation; openPrivateFile and pipeTo + /// support files that cannot fit in one JavaScript buffer. #[wasm_bindgen(js_name = downloadPrivateFile)] pub async fn download_private_file( &self, file: JsValue, concurrency: Option, on_progress: Option, + max_memory_bytes: Option, ) -> Result { let file: BrowserPrivateFileInput = serde_wasm_bindgen::from_value(file) .map_err(|error| JsValue::from_str(&error.to_string()))?; - self.download_file(BrowserFileSource::Private(file), concurrency, on_progress) - .await + self.download_file( + BrowserFileSource::Private(file), + concurrency, + on_progress, + max_memory_bytes, + ) + .await } /// Resolve and validate a public file for random-access range reads. @@ -2729,10 +2665,16 @@ impl BrowserNetworkClient { source: BrowserFileSource, concurrency: Option, on_progress: Option, + max_memory_bytes: Option, ) -> Result { let progress = ProgressReporter::from_js(on_progress); let result = self - .download_file_inner(source, concurrency.unwrap_or(usize::MAX), &progress) + .download_file_inner( + source, + concurrency.unwrap_or(usize::MAX), + &progress, + max_memory_bytes, + ) .await .map_err(|error| JsValue::from_str(&error))?; serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) @@ -2745,25 +2687,17 @@ impl BrowserNetworkClient { options: BrowserFileReaderOptions, ) -> Result { let resolved = self.resolve_file(source, &progress).await?; - let file = resolved.file; progress.report(&format!( "Ready to stream {} ({} bytes, {} chunks)", - file.name, - file.size, - file.chunks.len() + resolved.file.name, + resolved.file.size, + resolved.file.chunks.len() )); - let read_ahead = read_ahead::ReadAhead::new( - &self.read_ahead, + BrowserFileReader::new( + Rc::clone(&self.shared), + resolved.file, &resolved.root_data_map, - options.streaming, - ); - Ok(BrowserFileReader { - shared: Rc::clone(&self.shared), - file, - root_data_map: resolved.root_data_map, - read_ahead, - closed: Cell::new(false), - }) + ) } async fn download_file_inner( @@ -2771,43 +2705,35 @@ impl BrowserNetworkClient { source: BrowserFileSource, concurrency: usize, progress: &ProgressReporter, + max_memory_bytes: Option, ) -> Result { if concurrency == 0 { return Err("download concurrency must be a positive integer".to_string()); } - let mut resolved = self.resolve_file(source, progress).await?; - let content = self - .shared - .data_download_with_progress( - &resolved.root_data_map, - concurrency, - &|completed, total| { - progress.report(&format!("Downloaded chunk {completed}/{total}")); - }, - ) - .await - .map_err(|error| error.to_string())? - .to_vec(); - if content.len() != resolved.file.size { - return Err(format!( - "reconstructed file has {} bytes, expected {}", - content.len(), - resolved.file.size - )); - } - let hash = hex::encode(blake3::hash(&content).as_bytes()); - if let Some(expected_hash) = resolved.expected_hash.as_ref() { - super::verify_record(expected_hash, &content).map_err(|error| error.to_string())?; + let memory_budget = max_memory_bytes + .map(|value| download::file_position(value, "memory budget")) + .transpose()?; + let resolved = self.resolve_file(source, progress).await?; + let reader = BrowserFileReader::new( + Rc::clone(&self.shared), + resolved.file, + &resolved.root_data_map, + )?; + let (content, hash) = reader.collect(concurrency, memory_budget, progress).await?; + if resolved + .expected_hash + .as_ref() + .is_some_and(|expected| expected != &hash) + { + return Err("complete file BLAKE3 mismatch".into()); } - resolved.file.blake3 = hash.clone(); - progress.report(&format!( - "Verified complete {} as {hash}", - resolved.file.name - )); + let mut file = reader.into_file(); + file.blake3 = hash.clone(); + progress.report(&format!("Verified complete {} as {hash}", file.name)); Ok(BrowserDownloadResult { content, hash, - file: resolved.file, + file, data_map_node: resolved.data_map_node, }) } @@ -2915,15 +2841,9 @@ impl BrowserNetworkClient { if actual_chunks.len() < 3 { return Err("ant-core returned an invalid public DataMap".to_string()); } - let resolved_size = actual_chunks.iter().try_fold(0usize, |total, chunk| { - total - .checked_add(chunk.src_size) - .ok_or_else(|| "resolved public file size overflow".to_string()) - })?; - if !(self_encryption::MIN_ENCRYPTABLE_BYTES..=super::MAX_BROWSER_FILE_BYTES) - .contains(&resolved_size) - { - return Err(format!("invalid public file size {resolved_size}")); + let resolved_size = crate::client_engine::files::FileIndex::new(&root_data_map)?.size(); + if resolved_size > super::manifest::MAX_BROWSER_FILE_POSITION { + return Err("file size exceeds JavaScript's exact integer range".into()); } file.size = resolved_size; @@ -2977,7 +2897,7 @@ impl BrowserNetworkClient { let descriptor = PublicFileDescriptor { name: name.to_string(), address: encrypted.address, - size: content.len(), + size: content.len() as u64, content_type: normalized_content_type(content_type), blake3: encrypted.blake3, data_map_size: encrypted.data_map_size, @@ -3076,7 +2996,7 @@ impl BrowserNetworkClient { let descriptor = PublicFileDescriptor { name: staged.name, address: staged.address, - size: staged.size, + size: staged.size as u64, content_type: staged.content_type, blake3: staged.blake3, data_map_size: staged.data_map_size, diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs new file mode 100644 index 00000000..39ee05da --- /dev/null +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -0,0 +1,269 @@ +//! Bounded plaintext reads with 64-bit positions and caller-owned destinations. +use super::*; +use crate::client_engine::files::FileIndex; + +pub(super) fn file_position(value: f64, label: &str) -> Result { + if !value.is_finite() + || value < 0.0 + || value.fract() != 0.0 + || value > super::super::manifest::MAX_BROWSER_FILE_POSITION as f64 + { + return Err(format!("{label} must be a nonnegative safe integer")); + } + Ok(value as u64) +} + +fn method(object: &JsValue, name: &str) -> Result { + js_sys::Reflect::get(object, &JsValue::from_str(name))? + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("destination has no {name} method"))) +} + +/// Release the writer even if the Rust future is dropped before it completes. +struct Writer(JsValue); +impl Drop for Writer { + fn drop(&mut self) { + if let Ok(release) = method(&self.0, "releaseLock") { + let _ = release.call0(&self.0); + } + } +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct PipeOptions { + start: Option, + end: Option, +} + +#[derive(Serialize)] +struct PipeResult { + #[serde(rename = "bytesWritten")] + bytes_written: u64, + hash: String, +} + +/// Random-access file reader. Content memory is bounded independently of file +/// size. The resolved chunk map and seek index remain resident in memory. +#[wasm_bindgen(js_name = BrowserFileReader)] +pub struct BrowserFileReader { + shared: Rc, + file: PublicFileDescriptor, + index: FileIndex, + closed: Cell, +} + +#[wasm_bindgen(js_class = BrowserFileReader)] +impl BrowserFileReader { + /// Exact plaintext byte count as a JavaScript safe integer. + #[wasm_bindgen(getter)] + pub fn size(&self) -> f64 { + self.file.size as f64 + } + + #[wasm_bindgen(getter, js_name = contentType)] + pub fn content_type(&self) -> String { + self.file.content_type.clone() + } + + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.file.name.clone() + } + + /// Read up to 4 MiB. Positions above 4 GiB are supported without truncation. + /// Fractions, negatives, and integers not exactly representable in JS fail. + #[wasm_bindgen(js_name = readRange)] + pub async fn read_range(&self, start: f64, length: f64) -> Result { + let result = async { + let start = file_position(start, "range start")?; + let length = file_position(length, "range length")?; + if length > MAX_BROWSER_RANGE_BYTES as u64 { + return Err(format!( + "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" + )); + } + self.read(start, length as usize, usize::MAX).await + } + .await + .map_err(|error| JsValue::from_str(&error))?; + Ok(Uint8Array::from(result.as_ref())) + } + + /// Write the file to a WritableStream (including a file-system writable). + /// Awaits every write before fetching the next range. Closes on success, + /// aborts on failure, and always releases the writer lock. Optional `start` + /// and exclusive `end` select a range. Returns its BLAKE3 and byte count. + /// Calling close() cancels at the next read/write boundary. + #[wasm_bindgen(js_name = pipeTo)] + pub async fn pipe_to( + &self, + writable: JsValue, + options: Option, + on_progress: Option, + ) -> Result { + let options: PipeOptions = options + .filter(|value| !value.is_null()) + .map(serde_wasm_bindgen::from_value) + .transpose() + .map_err(|error| JsValue::from_str(&error.to_string()))? + .unwrap_or_default(); + let start = file_position(options.start.unwrap_or(0.0), "stream start") + .map_err(|error| JsValue::from_str(&error))?; + let end = file_position(options.end.unwrap_or(self.size()), "stream end") + .map_err(|error| JsValue::from_str(&error))?; + if start > end || end > self.file.size { + return Err(JsValue::from_str("stream range is outside the file")); + } + self.ensure_open() + .map_err(|error| JsValue::from_str(&error))?; + let writer = Writer(method(&writable, "getWriter")?.call0(&writable)?); + let result = async { + let write = method(&writer.0, "write")?; + let mut position = start; + let mut hasher = blake3::Hasher::new(); + while position < end { + let length = (end - position).min(MAX_BROWSER_RANGE_BYTES as u64) as usize; + let bytes = self + .read(position, length, usize::MAX) + .await + .map_err(|error| JsValue::from_str(&error))?; + let value = Uint8Array::from(bytes.as_ref()); + hasher.update(&bytes); + drop(bytes); + JsFuture::from(Promise::resolve(&write.call1(&writer.0, &value)?)).await?; + self.ensure_open() + .map_err(|error| JsValue::from_str(&error))?; + position += length as u64; + if let Some(callback) = &on_progress { + callback.call2( + &JsValue::NULL, + &JsValue::from_f64((position - start) as f64), + &JsValue::from_f64((end - start) as f64), + )?; + } + } + self.ensure_open() + .map_err(|error| JsValue::from_str(&error))?; + JsFuture::from(Promise::resolve( + &method(&writer.0, "close")?.call0(&writer.0)?, + )) + .await?; + serde_wasm_bindgen::to_value(&PipeResult { + bytes_written: end - start, + hash: hasher.finalize().to_hex().to_string(), + }) + .map_err(|error| JsValue::from_str(&error.to_string())) + } + .await; + if let Err(error) = &result { + if let Ok(abort) = method(&writer.0, "abort") { + if let Ok(promise) = abort.call1(&writer.0, error) { + let _ = JsFuture::from(Promise::resolve(&promise)).await; + } + } + } + result + } + + /// Cancel this reader and release the shared encrypted-record cache. + pub fn close(&self) { + self.closed.set(true); + self.shared.chunk_cache().clear(); + } +} + +impl BrowserFileReader { + pub(super) fn new( + shared: Rc, + file: PublicFileDescriptor, + map: &self_encryption::DataMap, + ) -> Result { + Ok(Self { + shared, + file, + index: FileIndex::new(map)?, + closed: Cell::new(false), + }) + } + + pub(super) fn into_file(self) -> PublicFileDescriptor { + self.file + } + + fn ensure_open(&self) -> Result<(), String> { + if self.closed.get() { + Err("browser file reader is closed".into()) + } else { + Ok(()) + } + } + + async fn read( + &self, + start: u64, + length: usize, + concurrency: usize, + ) -> Result { + self.ensure_open()?; + let bytes = self + .shared + .data_download_indexed_range(&self.index, start, length, concurrency) + .await + .map_err(|error| error.to_string())?; + self.ensure_open()?; + Ok(bytes) + } + + /// Allocate output in JS, then copy bounded plaintext ranges into it. Never + /// retain the whole ciphertext/plaintext in WASM or serialize a file-sized Vec. + /// A caller can impose its own memory budget; allocation failures recommend + /// the disk-backed path without downloading the file first. + pub(super) async fn collect( + &self, + concurrency: usize, + budget: Option, + progress: &ProgressReporter, + ) -> Result<(Uint8Array, String), String> { + const STREAM_HINT: &str = + "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; + if budget.is_some_and(|limit| self.file.size > limit) { + return Err(format!( + "file exceeds the download memory budget; {STREAM_HINT}" + )); + } + let constructor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Uint8Array")) + .map_err(js_error_message)? + .dyn_into::() + .map_err(js_error_message)?; + let args = Array::new(); + args.push(&JsValue::from_f64(self.size())); + let output = js_sys::Reflect::construct(&constructor, &args) + .map_err(|error| { + format!( + "cannot allocate download buffer: {}; {STREAM_HINT}", + js_error_message(error) + ) + })? + .unchecked_into::(); + // Reflect calls preserve Number offsets above u32::MAX and catch JS + // allocation exceptions instead of turning them into WASM traps. + let set = method(output.as_ref(), "set").map_err(js_error_message)?; + let mut position = 0u64; + let mut hasher = blake3::Hasher::new(); + while position < self.file.size { + let length = (self.file.size - position).min(MAX_BROWSER_RANGE_BYTES as u64) as usize; + let bytes = self.read(position, length, concurrency).await?; + hasher.update(&bytes); + set.call2( + output.as_ref(), + &Uint8Array::from(bytes.as_ref()), + &JsValue::from_f64(position as f64), + ) + .map_err(js_error_message)?; + position += length as u64; + progress.report(&format!("Downloaded {position}/{} bytes", self.file.size)); + } + Ok((output, hasher.finalize().to_hex().to_string())) + } +} diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index a32d38bd..5beadab9 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -200,25 +200,98 @@ impl RecordLayout { } } -/// Half-open plaintext ranges. EOF and zero-length reads have the same -/// semantics on every platform. -pub(crate) fn range_records( +/// Validated root-map index. File positions are independent of the platform's +/// pointer width; only individual chunk buffers and indices use `usize`. +/// Build once per reader so each seek is O(log chunks + overlapping chunks). +pub(crate) struct FileIndex { + infos: Vec, + hashes: Vec, + offsets: Vec, +} + +impl FileIndex { + pub(crate) fn new(map: &DataMap) -> Result { + if map.is_child() { + return Err("range reads require a resolved root DataMap".into()); + } + let mut infos = map.infos().to_vec(); + infos.sort_by_key(|info| info.index); + if infos.len() < 3 { + return Err("DataMap requires at least three chunks".into()); + } + let mut offsets = Vec::with_capacity(infos.len() + 1); + offsets.push(0u64); + for (index, info) in infos.iter().enumerate() { + if info.index != index { + return Err("DataMap chunk indices must be contiguous".into()); + } + if info.src_size == 0 || info.src_size > self_encryption::MAX_CHUNK_SIZE { + return Err("invalid DataMap plaintext chunk size".into()); + } + let end = offsets[index] + .checked_add(info.src_size as u64) + .ok_or("DataMap plaintext size overflow")?; + offsets.push(end); + } + let hashes = infos.iter().map(|info| info.src_hash).collect(); + Ok(Self { + infos, + hashes, + offsets, + }) + } + + pub(crate) fn size(&self) -> u64 { + *self.offsets.last().expect("index includes zero offset") + } + + fn range_records(&self, start: u64, length: usize) -> (usize, Vec) { + let end = start.saturating_add(length as u64).min(self.size()); + if start >= end { + return (0, Vec::new()); + } + let first = self.offsets.partition_point(|offset| *offset <= start) - 1; + let last = self.offsets.partition_point(|offset| *offset < end); + let records = self.infos[first..last] + .iter() + .map(|info| (info.index, info.dst_hash.0)) + .collect(); + ((end - start) as usize, records) + } +} + +#[cfg(test)] +fn range_records( map: &DataMap, start: usize, length: usize, ) -> Result<(usize, Vec), String> { - let layout = RecordLayout::new(map)?; - let records = layout - .overlapping(start, length) - .map(|index| (index, layout.address(index))) - .collect(); - let end = start.saturating_add(length).min(layout.size()); - Ok((end.saturating_sub(start), records)) + Ok(FileIndex::new(map)?.range_records(start as u64, length)) } pub(crate) async fn read_range( root: &DataMap, - start: usize, + start: u64, + length: usize, + fetch: &F, + cap: &C, + sleep: &S, + retryable: fn(&E) -> bool, +) -> Result> +where + F: Fn([u8; 32]) -> Fut, + Fut: Future>, + C: Fn() -> usize, + S: Fn(std::time::Duration) -> SF, + SF: Future, +{ + let index = FileIndex::new(root).map_err(ReadError::Invalid)?; + read_indexed_range(&index, start, length, fetch, cap, sleep, retryable).await +} + +pub(crate) async fn read_indexed_range( + index: &FileIndex, + start: u64, length: usize, fetch: &F, cap: &C, @@ -232,39 +305,41 @@ where S: Fn(std::time::Duration) -> SF, SF: Future, { - let (length, required) = range_records(root, start, length).map_err(ReadError::Invalid)?; + let (length, required) = index.range_records(start, length); if length == 0 { return Ok(Bytes::new()); } - let records = fetch_records_deferred(required.clone(), fetch, cap, sleep, retryable).await?; - let available: HashMap<_, _> = records.into_iter().collect(); - let fetch_cached = |batch: &[(usize, XorName)]| { - batch - .iter() - .map(|(index, _)| { - available - .get(index) - .cloned() - .map(|content| (*index, content)) - .ok_or_else(|| { - self_encryption::Error::Generic(format!("range omitted chunk {index}")) - }) - }) - .collect() - }; - let stream = - self_encryption::streaming_decrypt_with_batch_size(root, fetch_cached, required.len()) + let records = fetch_records_deferred(required, fetch, cap, sleep, retryable).await?; + let mut output = Vec::new(); + output + .try_reserve_exact(length) + .map_err(|e| ReadError::Invalid(format!("cannot allocate range: {e}")))?; + let end = start + length as u64; + for (position, encrypted) in records { + let info = &index.infos[position]; + // The dependency's get_range uses usize file offsets. Its public + // per-chunk primitive has no whole-file arithmetic and retains the + // canonical KDF (original chunk index and neighbouring hashes). + let plaintext = self_encryption::decrypt_chunk(position, &encrypted, &index.hashes, 0) .map_err(|e| ReadError::Invalid(e.to_string()))?; - let bytes = stream - .get_range(start, length) - .map_err(|e| ReadError::Invalid(e.to_string()))?; - if bytes.len() != length { + if plaintext.len() != info.src_size { + return Err(ReadError::Invalid( + "decrypted chunk size differs from DataMap".into(), + )); + } + crate::record::verify(&info.src_hash.0, &plaintext).map_err(ReadError::Invalid)?; + let chunk_start = index.offsets[position]; + let from = start.saturating_sub(chunk_start) as usize; + let to = (end - chunk_start).min(info.src_size as u64) as usize; + output.extend_from_slice(&plaintext[from..to]); + } + if output.len() != length { return Err(ReadError::Invalid(format!( "range returned {} bytes, expected {length}", - bytes.len() + output.len() ))); } - Ok(bytes) + Ok(Bytes::from(output)) } /// Native file-download retry rounds: retry missing records together after the @@ -460,9 +535,17 @@ mod tests { let bytes = records[&address].clone(); async move { Ok::<_, String>(bytes) } }; - let actual = read_range(&map, start, length, &fetch, &|| 3, &|_| async {}, |_| true) - .await - .unwrap(); + let actual = read_range( + &map, + start as u64, + length, + &fetch, + &|| 3, + &|_| async {}, + |_| true, + ) + .await + .unwrap(); let expected = &content[start.min(content.len())..start.saturating_add(length).min(content.len())]; assert_eq!(actual.as_ref(), expected); @@ -608,4 +691,56 @@ mod tests { infos[0].src_size = usize::MAX; assert!(range_records(&DataMap::new(infos), 0, 1).is_err()); } + + #[test] + fn indexed_ranges_cross_four_gib_without_pointer_width_arithmetic() { + let size = self_encryption::MAX_CHUNK_SIZE; + let infos = (0..1030) + .map(|index| self_encryption::ChunkInfo { + index, + src_size: size, + src_hash: XorName([1; 32]), + dst_hash: XorName([2; 32]), + }) + .collect(); + let index = FileIndex::new(&DataMap::new(infos)).unwrap(); + assert_eq!(index.size(), 1030 * size as u64); + let start = (1u64 << 32) - 4; + let (length, records) = index.range_records(start, 8); + assert_eq!(length, 8); + assert_eq!(records[0].0 as u64, start / size as u64); + let boundary = 1026 * size as u64; + assert!(boundary > u32::MAX as u64); + let (length, records) = index.range_records(boundary - 1, 2); + assert_eq!(length, 2); + assert_eq!( + records.iter().map(|(i, _)| *i).collect::>(), + vec![1025, 1026] + ); + assert_eq!(index.range_records(index.size() - 1, 10).0, 1); + assert_eq!(index.range_records(u64::MAX, 10).0, 0); + } + + #[tokio::test] + async fn ranges_reject_mismatched_plaintext_size() { + let (_, map, records) = fixture(); + let mut infos = map.infos().to_vec(); + infos[0].src_size += 1; + let fetch = |address| { + let bytes = records[&address].clone(); + async move { Ok::<_, String>(bytes) } + }; + let error = read_range( + &DataMap::new(infos), + 0, + 1, + &fetch, + &|| 1, + &|_| async {}, + |_| true, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("chunk size differs")); + } } diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index f836158b..5fbc9f02 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -22,7 +22,6 @@ use bytes::Bytes; #[cfg(feature = "native")] use futures::stream::StreamExt; use self_encryption::{encrypt, DataMap}; -use std::future::{ready, Future}; use std::num::NonZeroUsize; use tracing::{debug, info}; @@ -419,7 +418,7 @@ impl Client { start: usize, length: usize, ) -> Result { - self.data_download_range_with(data_map, start, length, |_| ready(None)) + self.data_download_range_u64(data_map, start as u64, length) .await } @@ -441,30 +440,14 @@ impl Client { .map_err(map_read_error) } - /// As [`Client::data_download_range`], taking records from `held` and - /// fetching those it resolves to `None`. A record that `held` resolves to - /// an error counts as a failed fetch attempt, which the read retries by - /// fetching. Every record is requested at once, within the fetch cap, and - /// held records are verified too. - pub(crate) async fn data_download_range_with( + /// Download a bounded range with a 64-bit file offset, including on wasm32. + pub async fn data_download_range_u64( &self, data_map: &DataMap, - start: usize, + start: u64, length: usize, - held: impl Fn([u8; 32]) -> H, - ) -> Result - where - H: Future>>, - { - let fetch = |address| { - let held = held(address); - async move { - match held.await { - Some(result) => result, - None => self.fetch_data_record(address).await, - } - } - }; + ) -> Result { + let fetch = |address| self.fetch_data_record(address); let cap = || self.controller().fetch.current(); let root = crate::client_engine::files::resolve(data_map, &fetch, &cap) .await @@ -482,6 +465,28 @@ impl Client { .map_err(map_read_error) } + /// Reuse a validated index across browser seeks and sequential downloads. + #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] + pub(crate) async fn data_download_indexed_range( + &self, + index: &crate::client_engine::files::FileIndex, + start: u64, + length: usize, + concurrency: usize, + ) -> Result { + crate::client_engine::files::read_indexed_range( + index, + start, + length, + &|address| self.fetch_data_record(address), + &|| self.controller().fetch.current().min(concurrency), + &crate::runtime::sleep, + retry_data_fetch, + ) + .await + .map_err(map_read_error) + } + async fn fetch_data_record(&self, address: [u8; 32]) -> Result { self.chunk_get_observed(&address) .await? diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index 83d1b59d..038f7e7b 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -63,6 +63,16 @@ on a current-thread runtime, verified records, and range boundaries. Generated WASM additionally downloads a native shrunk DataMap through the mock network and checks media ranges across chunk boundaries and EOF. +`large-download.test.mjs` streams a native-encrypted 4,303,347,835-byte file +through the real WASM decryptor and checks its complete BLAKE3 without retaining +the plaintext. It also covers public/private seeks across 4 GiB, actual disk +writes, write backpressure, cancellation, writer errors, JS allocation failures, +memory budgets and invalid offsets. The compressed fixture is about 250 KiB; +regenerate it from the repository root with +`cargo run -p ant-core --release --example generate-browser-large-file`. +Generation processes the entire plaintext using bounded buffers. The full-stream +test normally takes around twenty seconds locally; it has a three-minute timeout. + Upload discovery regressions verify native's twenty-to-seven fallback, reject persistent five-peer results without payment or PUT, and ensure the browser adapter does not bypass genuine failed-connection suppression on fallback. diff --git a/ant-core/wasm-tests/fixtures/large-file.json b/ant-core/wasm-tests/fixtures/large-file.json new file mode 100644 index 00000000..7b2e3ec7 --- /dev/null +++ b/ant-core/wasm-tests/fixtures/large-file.json @@ -0,0 +1,4136 @@ +{ + "address": "bdf831b9fd46588ecd85bd7563732d44430ccf6fce27e81514a00057881bca37", + "byte": 90, + "hash": "1b382a68936c50a2be8f4caa7f579c90cf649544a46f07ce5adeeffc20f5a3cd", + "records": [ + { + "address": "00a383fd8e74834056468a713393d8ba510e7e1358f32d22ac36e04516f05559", + "content": "6f08f9643d040a9c37ecdc0eb28a03bf2eb20f398e107075a018627e8b29" + }, + { + "address": "00a6757a7a690c27618435c78ff53cdc8e6f3158ed51afacaebeb4e09cf3b668", + "content": "b075fcdfba1f1d59c3061bc2feaab9d9ff5de335b74801aeb59623b98244" + }, + { + "address": "00b350b0c69634ecaa5ed566ffa3985e05a5033c66fb168dbc6172fee756e7f8", + "content": "65e1689cc91334ff11ed34a50492d6c408e13af7a0a5a3647366a12955af" + }, + { + "address": "00b85634772d274ad5f54104c3ec396c5ea430b4586b94d11c2db81d8f28365e", + "content": "fbc4ed645214ee44d1ec6875e9a8762611e6cb06d769f32116eac3598fc7" + }, + { + "address": "00d0a8130f3cc2c7016b0498133e3d364ec6784824184d0bac5d7cc1d5f01e2d", + "content": "8c7c208884ad0ef46aae1c59297f69f517c66e62401048c0572763bfe0fd" + }, + { + "address": "0185ad7f4cbb923745156e50709bce92e1db3a6d2f1d669478272f7be6514236", + "content": "56fed66a14fc916dff4e125c4f960e041b8436cfea704d885af9c645fb6c" + }, + { + "address": "024eef75532cf9e30c65ac641cdedafa2725f9052dae048cf526529f3d83383e", + "content": "ba7532472d86d484a13caf0b4c7c9881bde4e3d4dc80ed3814930fccf02e" + }, + { + "address": "027371aa5878c730b4e1642d799e20035ed2fdf820f4dd2a7cc0f573669223a6", + "content": "70dca9da9af2ff79d1ff3f992566e5b1930eb7a22e5b3c5c220d4a739d5e" + }, + { + "address": "02966b29d75f594c8c080e50d66832daf2f90cd187e59f5169166aeabd1b8c11", + "content": "5e7c065404e22cd101543ab5fea5eee2a48cfaaae23bb9d167e23d7b5d60" + }, + { + "address": "02bf99736e332c4fa0636f2242fd165e50abea6cc6bbec23af55413b3f3fdb54", + "content": "a89db21c6199670098502845235ddd00bbcdf25103cbcf91a9377438f550" + }, + { + "address": "038f75dd950d2982316f2084b90c5d9ba2ed6acb315aad0f17c8cddb45d37872", + "content": "1d8242bd3ecb786b5e50816ae78aedcf81c46c86657bf0b0f1a590b3dc4e" + }, + { + "address": "03bb546174362bb181435ed2ad82b9a7621b48fc53182ec2620ab16499f9461a", + "content": "4c0b4b7b97cd75abae0a8d8345b155bfdc050f9d7d8a04a4a7468d8eb9e5" + }, + { + "address": "03e653d780f5d26640f447cc3e7c2fe4291afc6db770fc4e5c686c2527daa0b6", + "content": "af0947156425988c6b1184be139a5e24945572d87de806097d63d599d7d1" + }, + { + "address": "043856ba53a21dad8b1870191e46c1fd44dd536796e37881b62007462c71f0c2", + "content": "347374bbcb1d82b8e1e02c417e5e34fabb5134ad395dedf99722208e116a" + }, + { + "address": "0442dfea398cb6c146cd3143427b72ff56046d7eebd97ba87487a6368216b08f", + "content": "9bdb6c24b8597d40a1629a5516cd56ece818bdeaa6ef6cb284b3e2b4159c" + }, + { + "address": "0504d0599cbe0aeda53fe3ff7f3413781c07dd2126d73ee37eefce69b33a84d2", + "content": "e9d6e8a1e82f2eec1bd7a7199a5a9c574941fba2da74ab6f248e5fcd0681" + }, + { + "address": "0520772c010c59d44d56239b04ef9a2cf8ff5510628952e50272605cf4bf98fd", + "content": "fea0ed7bf997f5f795d7d72c60263c9079e35d7b33bf5167981b53e7797d" + }, + { + "address": "059613880f9acd711e100ed2f47e5eea554b1599f862d801af2520ed3f1b997e", + "content": "4a68cb1d79fe3ed4e0e65feff516fed2b242bd12bbda4f8757ec6e45dbbd" + }, + { + "address": "05af77ff1e190ecfc6b5c003db6789a04c2a0f30e1548e55402279543c200178", + "content": "f7aa8ea7f46e72624af585fefd3f404f1f8534ad80058b5e8b1b5f5fca92" + }, + { + "address": "05bb3cc68f44ce2867531c5a8765d71a960ea1a396c5288405863e2919b6df5d", + "content": "466648c098b57f8ade691d0a63778f3bc30b52253eff31c73f0bab259b6e" + }, + { + "address": "05d6ad308a100b15d60931a2edd28aa12b0a8c1e6d98573048fa945c141ea0d0", + "content": "cf7246adeda6f14fe21e00d87fda57fceac121a2aa821b41a78b49996f48" + }, + { + "address": "06173d4c8d1a0c6f47a79c1ea042f5565fc7a2c27ec393e5044bdff7766e8814", + "content": "1f8cfbbd6c17e4dd3b9131556fe4085486d0dc5f9245d5d53a8354b3f763" + }, + { + "address": "067db82d18ebb056e2adb10c28ed19b1a5e2fc71e284e9e2256ffda1e900cd84", + "content": "fad4694ba439a012bc9d8d8a6e29e0e00c9dbf537195ec0169b82e1df8e3" + }, + { + "address": "075cb374444f59534a8e7e086847c18adc3e21ccbe3de04fb69a0580faf0dfde", + "content": "f41d6a7c383004122a122417fafbcb232915bd0a2a1ee14be197e7d1c7d0" + }, + { + "address": "075d16cf43dcb725725de578c6b1037e5cd0f8b8a6cc8cf85a0387c34c19b2dd", + "content": "1271e121e68c30dbeca4ea04c5b613d4e1b597a18637911aa9c345f1be15" + }, + { + "address": "07e801ff714ad2edcca3c516b7229bde586a55e714c6be717bf97d020a10e779", + "content": "60781e4b7f17760e86b2ef5003e5126ad3f2d39aed2ce734fd87f8aa4427" + }, + { + "address": "0865eea4183a5d2b7b9cd1c3f402532de0e9d053f0173c952ebf1032bf37d9b5", + "content": "ac3cb1644183c83dd579674e32bc10ea4def47503dae24bd0bbc6b2b829b" + }, + { + "address": "08da97e61b5d5701bc580db22f76ab479f5906f4139a362df08c35e96977186e", + "content": "c83ecc6a378f406c795f0c698d15be2ce2992f57399f48dec2c73412a14b" + }, + { + "address": "08ed166301f6b29d60cd94c5643c2862ef4c7170013302677f78eedd50287f24", + "content": "e922f1cb9983b856bbbe2628ef918cdd627c40dbb4a2a727b3d7765aff0a" + }, + { + "address": "09429a5186540a73173d486768608ae11399aefe53557e34ac091bc71b47ed4c", + "content": "940e0ac3dbef780cdeff79263a3f0009351093fd5afbc76548d18b61657c" + }, + { + "address": "0997b86567b3e26361cc57c07360faf53f2ca0e300284dfd19efd9fc03247df3", + "content": "5350a2b1633e603c1bed8d5b33978cece69c047966b41c1bdc79fe038a10" + }, + { + "address": "09e534af79a6a1ac38df494a450374d4b88c4826dae4d36fd3bb65e5ef8e83d0", + "content": "eff873b2f5284c9071167abdc26545a6714d8f3177225886b40393c227ce" + }, + { + "address": "09faadd6ce02a3387d0ab7e0d46a1745a6775e96da940ee9d7263e3900723d0b", + "content": "55a342f85382a4f37afd6383d930b46be5797a3aae942324875b909ea587" + }, + { + "address": "0a2ffd842c33377f6c688dee2326908cbcad52876100abc80a766059bd35b149", + "content": "cb0fa7b7de12817ca478d8092b5a3a7cc43a416a66c5d1f68e8f46ff6fa5" + }, + { + "address": "0a756a5f9d028ff94d4dfc44658b38cc157c85caee77e3a3c2dbb2e570a1acf3", + "content": "e81f35410c7dd10c6d1986b7731c9dfa618ceedf08eccaa4ba1c20f60002" + }, + { + "address": "0a775971b96e79fae3cdd306e45e885e32b3a8f29dc041f884bf9bf366651667", + "content": "b6666f7ee9ea620a5837d99b342894c96ba674a9ad104f7b1601eb4b4e66" + }, + { + "address": "0a84345252c34819dd77b5b13da1444d63ea9d518ba3d7831ed09abd77d463da", + "content": "d1d3ab6cbfcf467cdf3ec76d237c1cd7b5e3fc8b5b55c0317a54a540c1d3" + }, + { + "address": "0a911580c2c508d71dd2869ebc47d5fbd8943db4d454589c37e9bbee5a8d8a48", + "content": "1ed97debc21628319ca85c12bbbd557d2e21b5ec120abb000d5c69457372" + }, + { + "address": "0a9a160214716b1a0b1760cb0d2253cc28ed9e0a382e801ee4fc856b009da6e4", + "content": "adf6b18c09eea5452d84ce05a29780e5a5c94bb33ffe567a90428dd220f3" + }, + { + "address": "0b82d8a38d5d6a937ad05a29db0c2804ea592ad98678950ad703c0fb1b2fbefe", + "content": "880094f83be437f9d632b266298779002dff5206884e666f7b824ed6927b" + }, + { + "address": "0bac26271dff9db4df0c906519b7efefea79650fc3a96f817d73ab2eaea99ed8", + "content": "a47c2f6856a2eb617501cbab645d6a3ffd1a2c5680869872974ac8c69002" + }, + { + "address": "0bc5b2b4d889b9ba6977c2bfe0de9241141dfae5da8c4b96ef5fbafdb35d1e46", + "content": "5831bf765a234e46292f2813bdb1e9d48c2e748f4ff1b252b87ed3ffbd17" + }, + { + "address": "0bf1861f7daa8c755fa8d87cfdec818d1dd5a4687a2fc91cded6a8c9eb3e03e8", + "content": "f892b5c7c6e6f1f478ac644040c75f0bc4fd95942881264a018895eedb0e" + }, + { + "address": "0c2ea632527a412f559fa6bf2d53937d6bb79cf6e5f12d7d1e37054acad950b0", + "content": "916e08195566e700a1c2f58a41e79c355639dd71167873575058b3d16ca5" + }, + { + "address": "0c8be0f2f4372b464749ffaff191f4183e918d4809cce4bb0730813086b9c752", + "content": "25d6693a2294ea50f79d0b0daf6ff096c604c6936e05dbf9cd4dfd00b1e1" + }, + { + "address": "0cfde5ee7e3cf6fd22e59f8bfd9f533bc9dccdee4c747cedea496e9d65eb102c", + "content": "0aa3126e819cecc361cfc5f7cdad77e8b8e96a5495cfcf6c1973340750ee" + }, + { + "address": "0d034274681954f4a766b7df0406ded5d180beb130fe0036006a07b73ef8740a", + "content": "4d0c16de5a879e08a1c5d795a5b24b047a0e7f3a895665fbc7864632d20c" + }, + { + "address": "0d4fa403d279dd0f9c73d41b5e66143c522999cf3191dff2e5bafabcb363943f", + "content": "667f024f8c767af81b738a6f6d3978b39ede8d94ecbaab5afee80a31cfbe" + }, + { + "address": "0d6c30227b81b056393adea92c84555e3a36c7f0f3319bd21b13618186d412aa", + "content": "ab26a6bc752a4c747ae4ce013723158bb0e07569825bbdd4185aa4bb06df" + }, + { + "address": "0d724bc44a9563ee3ecb1946ceef2efd3defc7b2f6d18829c1417f5c63c9362a", + "content": "72fa400bf00ae3d55b7d085fff3c78b4335ecf1514e92065e1bb4af124f7" + }, + { + "address": "0d8c639b9591610b83900ab870825aefbb9a56f901a7dbfadf5d030a7ad3bdfb", + "content": "07fca1f9484aab049ba43fd62f22de12455f4676fa2c0ba4dadd568d5d91" + }, + { + "address": "0d9ddd9b0d3afad517a2d02a832275cf9de27ec351f916cd3b6373ff1dd80864", + "content": "4b36b0621d210dc6b54b11c432bfa1a526cc88ab80624cd80e97a5596a50" + }, + { + "address": "0da4b272b1615908a41f909a2f3000de5dbecbc450138c87ae3b80d16d49efe8", + "content": "4668f1d62e849131d1013234b670876fc253e681274d45ddcdd96018793d" + }, + { + "address": "0e66172de8284644cdc71fe3fa1985dd18948ecf357f9cae2e2d49c46d1955cf", + "content": "35b7d0f198d95654f607ac885f24517021e2574a1846e0e4416daf16aaa7" + }, + { + "address": "0e741414f7565a716deb158eeb880c30964e97077802617a5c0a2022d863bae3", + "content": "5fc22d8a8f44d4b9affe39bab1275c01410eeab4c958777775cb136fd53d" + }, + { + "address": "0f69d11c23913ed1487ce6e012c58adeb69285b85cc1722b7f00b8c896fb41ad", + "content": "f256f3a7f1f786954fa58693a7478cc5ca454426ab50d3a31315d0c21497" + }, + { + "address": "0f7568fd961b57e366c7c7a39b7bf36bc70d7f0816feef62275bf57aebcb452a", + "content": "6a6a9319a1c00d807ab219725475bafcc6397825e95645fd903e83e427de" + }, + { + "address": "0fd82b992460ed1ab42ddd42e03261e67c7c6a707123ffaee317e45a764199e2", + "content": "10da69e34aa22c544efd7abe839384666855e631b16ec0e07fdfdd1fcf5a" + }, + { + "address": "0fddfdd0870838a1e6bb743d67a972c750fbd3baa30341dd642f7c049cfd74b9", + "content": "e1e87b2fc8accf6ee93036c3db61319dd1c25ccd4cc4892afb988fa2369b" + }, + { + "address": "103ed67937cc8458c9ded709575d32bf506794239e647966e190f3bf46176d21", + "content": "b36809940b41ca58a1239c0c80a2829ccb01e2f04e99665efffb94c91573" + }, + { + "address": "1057a79935ead5f382c7b5ddd9a9725f3b8dadb429e22653afc8de92408834d2", + "content": "1d405e6806a7408aaba82471d706a5fd68be69b6b9123452990b2d41b3a3" + }, + { + "address": "1057c657bf167b159209b439b3cdb90ffcf62fbfe73d9e6f52a980beec8a5e8b", + "content": "39da4f3b0d5856cee7c7c085c74d5c5627838b11576314893173cdadb00f" + }, + { + "address": "1086218f84f9fb77e64cbed58a96ad4ec0029ff69827ef95c7e7f738886339aa", + "content": "43155e13c9aab4c45d10bfdcf4e09f4db04cbec3c8ed8490a9e783a224d8" + }, + { + "address": "108b4af7d3ce1dcbfbeeed302e2704703a76e0d4fa460aee1f46413870f6fbdb", + "content": "94b863457d0d7faf26a669580769c42d3f7192a077aa4b85aa79c4806c8b" + }, + { + "address": "10a1beae05efe5ff6180ad06da193f8bc9f07869e5454fcae067a2bf93c8f88c", + "content": "a83aa68885651f7857cd604530cfca7a44f21638ab4185976575509d9d37" + }, + { + "address": "10aacc51bef961d240cfba948e17e2892e3c350ab5a4a2397ad2dcedad362648", + "content": "be13aab4df4e8a6569a68581c036df58400959d06f4a86447b01c37db760" + }, + { + "address": "10b6701939074e8c35d1e27bc09903c6eeef6a76a5f12497ec78cb385ef71c55", + "content": "59e0e21f9c554a435c5ae00e8e17bdc6c033d32ba7016ced4ed00ac45e03" + }, + { + "address": "1132a93d32d7d8248eff09a6c5f04da79d3b584289aedc3934dba08a3d9aba42", + "content": "01fe4518f76ba25d5d25117559d14008c0720312d3050eafd8e47c4aec37" + }, + { + "address": "1181683ccfabc946403d41afef7927f7fd2ddf40d5e2c2b115d755d9b74f445a", + "content": "bc0571351beeeed9de914988bf285e1e6a9c69af55913ad3f3fcc7361644" + }, + { + "address": "124bf0dea99008557d5ef13eb59b90b04f3a8e6100d8c70288feb6d79021fe18", + "content": "96bc645821e9312474d2bf04958214d55a1266e694f6d76269e5bc19d6fc" + }, + { + "address": "125af79c6304841447220f6a14025bb73cf6f664f4b8a6019f7f08ad09054114", + "content": "166c414ded7284b948abd111a3a2a93a313e521b748525345070497d30aa" + }, + { + "address": "12698598d9ffd0228351ec3a6bfcbcfc965089b666be9e4a1636470eac3be8a4", + "content": "abbab03a47f5391213209a550bd43a8a0912375f103a0413d503f72f0f87" + }, + { + "address": "126ff5a61154b70f08e5ad286f92371ff982041f7f4e6f6e896ee1c39e3e9b3a", + "content": "b54880580819b38990cab8f5128df1fbc79de4cf0e6998fd9f321929afd9" + }, + { + "address": "12e8ecb3edacc99b36a6ea2104a116eef0c227ebe48962e0d860e1fbe7f127fd", + "content": "7ef19c05efc3935d95e66a9db1a4d4dd5139ed1a859d7a86934418e7510b" + }, + { + "address": "13527eda870261d3fc48f2366bc7acbe5060280d752e3241f0c774925e270b2e", + "content": "57217f83d101c6de88d512f04f2383b94cd3b9ac5a9743a9bce498f62bf7" + }, + { + "address": "13db866e71e3b6e57fa7d163a65e9c1444989104c6425d9f43a8d024b5dc7a9b", + "content": "fc5f9dbe51446e5530fef2f56324e9b2c067b729cc17066dca1089aabaaa" + }, + { + "address": "13df5edc9dbe6f04109b0ad523480335c3e99dc9b0edfdf25c377fefe821713b", + "content": "2b9a3e08f6b6028be0df36645ad0327fb15e0d08ed0a53458b88527cc18d" + }, + { + "address": "13fb377b2889f70c1a44ce4551837336bcd64791993fa9b17c074cb42eaecbba", + "content": "96f741df4251957c89d225831566e1b2334cab6bdcb8214b601882fd25b4" + }, + { + "address": "1419024838d9289690bf772280edfb9f30e95bb2d1f01f8070165c0415b5cbad", + "content": "ae000d9c092b32aa842eb1696a15004f41d899e1998a8988bbdf9300d728" + }, + { + "address": "149e17e4b963a256e5e4ac99e6924f3b192bbe76fecaf912bd839ae1baa08845", + "content": "9a38b58d2da07de6af9a15f6fd58aa9b66d930fa8cc18ee9bf4623d53e76" + }, + { + "address": "149ed4c89470b2ad0e3d81f81637a582f0c16c09a17f03b926495421a7331228", + "content": "cc5530e99ec749032a072287034b125d2eba761277e4dd927a6d05fa28ad" + }, + { + "address": "14d5d72b82ee5740b85a701f632cead987af48ae45cef6c2e7b87c06bcb99e27", + "content": "d89cafe03c98b61944cd9bec1cbe4746946709e5db2a1af5814447dc2014" + }, + { + "address": "14ed7958466058b156715464b247d8edc111192277add19330ffd9883e326942", + "content": "d431e0710a1dd7b0f435fef792620c48aa77c96b214b96da32203e703431" + }, + { + "address": "1610c616fdd414a9bd917de88f2fce1c42d31a605ea0426f5c80effbf844a35f", + "content": "7153df0237d64ccfd9bd6d7e48a45925d3778ea67d80e1e3795fd78acb82" + }, + { + "address": "1675ab35f3c49f26d8b9066e9d10a725fdf187d1760b98332be11e8d9cd6b63e", + "content": "8d2b4c7c3fa9d9b6cdb3c42451a6f33a5c7b4d0b0e49ca40dfeae1eeaa5e" + }, + { + "address": "168900f34c5eb0c7682569f0c5827e230e3c3526d01eddfcea7699f3e86867b0", + "content": "5c5eef3e89f0768dcd922ac05beaf47c389d905722bd758c86ac6f97a5c3" + }, + { + "address": "168ec3bb45bf942119073d1c8f012bba623aebb521b4e551f96bc0fdad1da555", + "content": "3563d9066caa8ce6e2cf0bd196de6be9241dff9289710902b7cf793fa70b" + }, + { + "address": "16b3e6ed77b36b412f7db6c0251fb4ae955dfea8a6474e80348e751af13deb17", + "content": "3befcf0dec78ff4acd741fdaea6144ad40bb6df9535b1135b06be3188a26" + }, + { + "address": "16d1f60f07c02cfe47ba24c0e1a6e99f0fc9059d1f4d7dea56534cfd506882b5", + "content": "ee1139146d7582ba418f57b2ce67a26a32e9f52924bcd6cfb066a5e99a5f" + }, + { + "address": "174242d639631c680c3688a17ae459b933efd28f76e4dc6cceb028930c274002", + "content": "5ccc5ef87eea685ced9fd13a6c5f5e2c952a9b19067107bb6e9a3e090ef2" + }, + { + "address": "1752ccc22b5aa237c2452e901ef56285ffb712dc1cdaa1c18e0d54ac3271dc4d", + "content": "f1dd1fae11fe0ccc0825529f28bc671f3980450bd07d97ea2b3b20e9dade" + }, + { + "address": "1792fd3ffda2d6f8e6cd595c379a6571fbcc4751badf5025c4f57420059bbaf1", + "content": "bcd76f85a3d8848b2e3ff4cd7d79935e1d95099067f71703ff7900f29949" + }, + { + "address": "179b41556a0528043a6793ca66ccd20af52ad72a83bc244fe4855820bc3a52df", + "content": "267506cba3a494339c3d2e7f53501f5a6294f4c02d94feea254ac57ac64a" + }, + { + "address": "17bbd5e28967e6309414ecc140f9702d40c28500f5b011318f98927df18a5345", + "content": "d749d195f0529f3567abc50e5e45e93d3fe840d39fae49350e2fed112f04" + }, + { + "address": "18ef8f6bcc1eba8f739314e85db371fab2054640234872ff1e7b700f2dd4c699", + "content": "3e6432c1b53d0d0fde4c9f46407be21703a1d75e6894a512a0841bab6865" + }, + { + "address": "191f9592a921482af7fed95efbf41cf4cdb3f14828c41317580e129b0cc0e90a", + "content": "6d728e1f6dca205fbd98200460805b1e3409f455f3419f18a8ee46f6dcd5" + }, + { + "address": "199511d7863c8113e257fe938163fa2d2fc1d482c086bdaa2d6527d6ff48733d", + "content": "83af4ffdaef1ed5d13e672f7963282ea0e946b2834dac8ba1437a698f3a6" + }, + { + "address": "19a19d88590159b77fb83de386a6da071117bf3937dd88e07f74809f146a56c7", + "content": "11550cdf0500ddc4ed53db71c8e16adf4758858048e234e616cd6c035e02" + }, + { + "address": "1a6d3ddd08a1c589ffaaca966cfbcd3237f8b1c820467067460e21fd1d0fd29d", + "content": "dff79501d39589513b238145c4eeca819f95b8444ba9bb0120c44813b932" + }, + { + "address": "1a816d5ec4f545f5ec4605f81642ca7c142677ee53046f5dac1eed4fb276939d", + "content": "fbaa3b545507c9e3eac63e64b9fd399f01d3b85db476c50ee70bf03f5a6b" + }, + { + "address": "1aad2bdb155aa891b74b56e7dd7575d15ff672d79e8f7a841b20d142d327db38", + "content": "4b585ccec438dd365d20a6c86781989f84960b6afb77328090ccf72afb9e" + }, + { + "address": "1ab473a07f621add4eb6fca0180ddc785d95317b7c354c2f52baaa56157436e1", + "content": "f21f35f606a49b4eb3e83ce73d8bde14ab104e84c4d756dab53e330492c5" + }, + { + "address": "1ae897cea702f9660254250efbe81904c954ea5981549036e56c89e7a369227c", + "content": "44920f918aa354bd6fb155169e37eb8db641e3ae19f48cf9da770aedf5b5" + }, + { + "address": "1b051fe762a69a898bb58b653cabf1e73176eca2da8d781973e06026d2c7caa4", + "content": "a3271d2290b04ca8c48b585f94811b8e18822b2ebd0099c1e0e22f20e1a7" + }, + { + "address": "1b271c1a83ee29748f7939318674d840a3a5477f047b0bd780d17670f50498a1", + "content": "9b3f8b7f3da3c07ba0b8ba0d52fdbee523be99842293d64d7ed46a88d321" + }, + { + "address": "1b3c66bbb925a99463580bc5541fa0c7824b24af9758b59e950f18c199ba3bc8", + "content": "aa662559bf6b9b41d1cb4adbc9782a4a091c392088661e304cc0681d32fa" + }, + { + "address": "1b778a10f9b7bb552bcb177e80e8d233a9918d691510732cdeed74b2138d35d6", + "content": "163191bee96d982232650240679e1753852e564a13dbebe056ff4066022d" + }, + { + "address": "1b9a22ac679c00752aa84b7156febd5c1bde35bf97aa0607da7e2714692c26f6", + "content": "87553acd4734ca571d3716ecb554b9b8d71c6ea14997ac84e5406d83c328" + }, + { + "address": "1bf997280c8634acd51d40ab8f7612b4582c4fcf3a758d4cec508fe542b8bdab", + "content": "3c596d2cc26596e736ce8c7ce2d2cfe00dd04ebb50eed4fed4c71340994b" + }, + { + "address": "1bfa31ab342b9c342dc9aaaacced7c9a27694faa6195682a9e7f5d47725d28bb", + "content": "36178481f5cdb487be6e5e341a4182a04ee3e664af3edb8d6b00976526c7" + }, + { + "address": "1c1c4644ee795707c99a5c129be68474fb3dd745fdb80a0d2d1daba5cb50988f", + "content": "c0a010462661902d344cea0ba0ac25c5767d55df7b7ceea52429c200aed3" + }, + { + "address": "1c3b709b451375ff28c48f0087532bde88d03e94d9cc01cbddab3d6b6f3f3f03", + "content": "51265dba2e4d7cc07ffec0c83e348d36d97574578acde4f3676a4601de51" + }, + { + "address": "1c7b213b1e1a760554a9de73c533b342f10f577cb30d5868879537368050e8af", + "content": "a2a836380d191e5f579751dbe9ec7fb74ea4fee45cee75f093d5358f033f" + }, + { + "address": "1c7f40a3f42a164172cef53aabdbadd78ce5cd65759bafa429ee40f658176f25", + "content": "5e52b3f7f7c26c6ebc7328655021d32691ccc86437f27bb548fcf3dadc3a" + }, + { + "address": "1c935f625edd0b0863911ddf2271786b6526d64018b38a2acfa516ca697548a5", + "content": "5e00a4fcf8dee43bd38fa9ba421a67a01c04131790a213b52db410678c23" + }, + { + "address": "1d4d5fb5150a556c8be2d31fc6b24b1ab6fcf72dd4ef1a35eaa3e30d66f27571", + "content": "37859702249ba34dee6b98653ad20c2a607dce0955b12bef3eb72fff20ee" + }, + { + "address": "1d5f155a09ca40acd2a1456e85681ddfb1ef332e0da441664f768dea54a1c87d", + "content": "93b90006ff5979304c648b7c89c43f123a8e2b61f78e699d270ebc92f16d" + }, + { + "address": "1dfd2e031072d197c58e188c2a03391c2dfa21d5d9ec8e17b798078f4f3075c5", + "content": "d19a6eade945c86c81700b8510a2c8ee0bd9d0e155fb452b31d3eba863fc" + }, + { + "address": "1e6d34851c8219e3896eb0104e6f8db53a408690b647396627dcc568ef688152", + "content": "4966dd12f205219a10921f52d1d7083f8b82033e17964fd4eb7100eb5f7c" + }, + { + "address": "1e7ce1de19da3e382463bfd3df3b438a7a640ce258e319ebcc13cfd67b6545ec", + "content": "29f3eb2c62b45ceef6bd58e02cbf14c75da0313774b394fee092f3fbd8c2" + }, + { + "address": "1e8e82c54bc79ad92f94b0361b15153943fd9381421322c052c13ede739b6384", + "content": "74c1ead22d09f79e2f9719c14b5619bec1842f526929f992115681fe94f4" + }, + { + "address": "1e924b13eb8f28b0f01fe85e327cf05c2eb2d2d3669dd1edba294172112fcc12", + "content": "119bff2067f6c5821354f0dab305c4f62e51029e6b45c7f1020dce2cdead" + }, + { + "address": "1ec6f5d5eff4fb8f2678c543aa1ed7a5e0b62127553a16901aa7e38a5085450b", + "content": "42394e2c859b6f2ce10b0b10bcb72ee82084069d581816d26eb7cce3f6b9" + }, + { + "address": "1f03b4820d57d7038abbd8ff53a31ae64ee737ce4c75cefd617356b1809d5132", + "content": "b0c5d44ee3068dcae2bf2b8ad843a569489a2ab29219e8bcdeb0d744ac99" + }, + { + "address": "1fd579900ba9e5e7b1e77ff1ed42f9dee9d7cde2abc809cc2d7e3cf178f22f01", + "content": "188897a988349d3089624c019b12454656f61269ae6dbf0ccfa602c63998" + }, + { + "address": "200cfbfc58853a8066587aeca17fc67cbe58f3fcfcf99e2fc33a22671d71b280", + "content": "a000ffc9164fcfdeed3f88d543c65ef7392f065d7180d3a89ce7859f2f75" + }, + { + "address": "2062ac0820f0476d7e0ed9dd69d827d0238d359f37466c11c6096478cc507b72", + "content": "fb1435bd98f5116122a358e5d996fff5619594cf39974766dc74426cc44b" + }, + { + "address": "20634132d14c034ae459a117c8bbff4ee5ec4fff5c66a6ee4988bd311da280e1", + "content": "e1ab4e3d83399df25d1b4831c9078e5c091eb5284c73e8963c3cbb1c98eb" + }, + { + "address": "20680a79eba1293483b9b08c09456d1ae3b3fe6b566d21c55b5e810a5970f1da", + "content": "df48499e807e9c0c07e46a94bd15a55dd992f36374992a68a80788fd2226" + }, + { + "address": "20683fbe4c59f5821466eb0237a12a501332ae1d1ec8f6609e1dc5aceb8ba8ee", + "content": "c37157b7f41ae7551eeebd0f6575447211db4536aa71d5bbf3db7216daaa" + }, + { + "address": "20f9f640d7485eeadd4ec67438ab484f090549f98498ded5a1b7f2213b5d0724", + "content": "f5c43225086cda5b12191f57085fe36dd9269317960f8609d27ed16ae162" + }, + { + "address": "20fcd4cfc9a1650433154c1a1d1d77446e29c0b7cac562b5b19fd95c0a19f5c9", + "content": "d10e02389c59b41cfcff41ed3522f984dbd104875de2b5d8d8f47fdd4539" + }, + { + "address": "21211b6207f65f620dadc759c10033caf0c7d429bdb2cd60c485e2505d2c6669", + "content": "8450e0ba2374f141a0cdb83c580093329278d72ab133229bfd11edb22f43" + }, + { + "address": "2135e23674ffe005e5f3bbe06039d79043bb35975001f0693866f3d565506ab3", + "content": "e6c2cd8a8abcbce49611793ba9d24c3cc534ecb0dc34f918c5cc0b4c393a" + }, + { + "address": "214faa56193869a75e6840dc865faae2bc50024f9ae89b791fd2d7a3124ce526", + "content": "2c422cea585b1ccad04d77304755a9d40ea6f29d7c857e7a2a2946cb5119" + }, + { + "address": "217173bc5b74f373f004cac246e7b0fd0d901a0553ba7cc678fe442053034aea", + "content": "edc97aae9d189aeecb4bbd87fd762f76aebfbae52e41d8a462caefcdbc75" + }, + { + "address": "2199916a5a26c90ee6938e04c3aa909e2ace14e79fcf8c1fad7ce99dde0c81f2", + "content": "9e7d6fbdccb2bf172cf44f647773e6a04532d404dd6f910aacaf401b10bf" + }, + { + "address": "21a05c66a63bd76ebd36775b9192c7b0b3a5b142de3ed7d99868c1fc73734c8e", + "content": "bb5f443781fba278cb891fdf78868bff84edf685e7fc0ca642865b1caaab" + }, + { + "address": "21c187d1cfd67fa30e84ac199cfb252664f581e58777fd919209648aa6112ddf", + "content": "39153b7a7c92c0b43af345f89b718b042fd0e9e7e8fc2473e7f7ad7f6ce6" + }, + { + "address": "21d290853c01781e31e803692c1d9fbdb1ecd522ed209da4af154908caa9bc69", + "content": "4b1333d994f7e6c41d6f8798499b095a73e0778f25a23395e0c5a926a1d9" + }, + { + "address": "21e917e4607e9ea5d7fe8093c809eb81deff3d476183df1be0b2146ab084e30e", + "content": "10b1d7cb2f686a57d09f7544276f931b4e1d8d39de539a8faf0fb01faf24" + }, + { + "address": "222d01fc4f8969ed5fd777c772216d3d7ce8d7a60f18325e53e76440852bfa57", + "content": "d6687709ca82b365288a04f69ab79ed91f9e1c8995fa5cf204fdf049a048" + }, + { + "address": "224854daeedd6c4ff3471adf17c45f7099eced4c94e0d7617ec81f333bca131f", + "content": "e1fccc87c3c5a200e3938f0bbc6a8100caad38be027c955c6f1db03e9600" + }, + { + "address": "22b7597b662dff22e34229602b0487544c93b3d1caa8393602a9689eaa6ae8a8", + "content": "9d0defaf8ed56a4c5b6b4a555f9b3c0815928760ce9775afdd642e1eaf8b" + }, + { + "address": "22cc4448b28d798aac3d3aab05194998cab05eadad4eb56750545264ad2d3120", + "content": "b2ddb00931d64dd07a428733cb08f3ae64259148f3697622f79b2acffb95" + }, + { + "address": "22f1fdb6a3804d7c4a6584530716be64711aaa11d6f5700f005a5a506ae0f9b7", + "content": "f3aa747b8cf623fdaeecd5cc3932b5640148b43d16942def9cfbbf7fab98" + }, + { + "address": "23565662f30c6ac16ee4a494cbf0a278491ced0dc48029922b8eb70b32105892", + "content": "887296089a6e62eb04fb8e4eef650f236a0d7f249643188486b22376854c" + }, + { + "address": "235f4e17aebf3c39122aa686b162c4c46d0808020a165edaf6bf4fba8834b6a8", + "content": "5f1f19a6efa45e89ce48d0b142859dff0fa97a56b9eeb71775beb59f7360" + }, + { + "address": "23b1dd8a245eed907ff5c5227b335c9831f28897b9a694933bbb6aab1c53b18e", + "content": "a0fb1f4a2d048164dad0311fac41df012de36a50ccfe95c72702542f0ea7" + }, + { + "address": "23b49b0bef74808323c7ac44ba4c9e281a7c267c00ad9d11e49223ee28997643", + "content": "fa2c7415a396cc4eebb0c68e68e4f15f88f1690d54a52bf0a801bdd888ae" + }, + { + "address": "240a92f629b2312b2f0dff9d2852adc3614176f495493005781c4040054bf57c", + "content": "fde77807f76deb292a69a45355e59c4f8b81435dc844c323acc5dafb7747" + }, + { + "address": "24555fda91303e4f829c995479608c5ca8b7a2e1c4e059e5c54e7abe297b5ea1", + "content": "d60eb462c51bca0a869c50bf08502712044501f37ae75b59cdfab7653193" + }, + { + "address": "24c7a933764f79e1c2054c8497142270a85c7ff3209a3abd1b322b02d3858794", + "content": "0af4ec30eb835579c448e52e7632e15fc5ae2e6f67dfc02bf879a7d7e911" + }, + { + "address": "24fab55efdd1a7a2ea906062adb9f87fe8aba90029e075d174a57cf11d73ebd2", + "content": "7950c80ef6f0bfba5efb2aa4e0b6b67fd69dce391764b23f5e631dfe54c8" + }, + { + "address": "2510f0260be2f61773d7ce9f956c39428960e2a08135683c14bf680f06e8eb41", + "content": "489ecaf7ab382309fac41a2f5ddb955975d54f963390f9925e8ca51c77da" + }, + { + "address": "25b1ed2843d8011006ccc095c74b924b1dc2e2927d43c7fee1dbf1af214b2d7b", + "content": "49004f7d4c687ee5bf4f6aa0d6c8f949b7d2abbb81027a8b5cf1f977a00b" + }, + { + "address": "265144514c1953633f0210d447c27826da51faa19bf395020b3a49eef7c3878a", + "content": "da55dde81672afae7340e020e0769d4b8cc81eac6eefb1336fe2fd5860b6" + }, + { + "address": "26c158c5f9cc063c1322163382b77ce7794a5d2df31d39671667df3963670bca", + "content": "8619d755924589256ede04c496b2a87f110278e4eca17fa5b978e45dabf9" + }, + { + "address": "26f3a2e1726067c9686455844465a66bf4858ef44c293f766abc954dd8b67181", + "content": "c4f0ba152a35a65240eaea82974a914981c1d5dd931de0aa9f83bd65cc6c" + }, + { + "address": "272d2c31bc1187bec4151de519924b19f7f9ea75c87b3dfcf59362e7e612f005", + "content": "ee668bf68bf4b492cac52fd4f78fddfdf48dd3f763c06d665f807c6d8096" + }, + { + "address": "27355b7c9a6413cfe309ca25a7c34a2bd349cbd4cf6c4bc2652f5dbd24d411ee", + "content": "170267f3cddc2726672242f9965ea0e67abff0e7fe65720e4cd92c25c074" + }, + { + "address": "273b2275d3595d39720bf8c65daf683a81c8507c17e5dd099e5e80a5be48a576", + "content": "73cf8f2d4c068c1545fdbd919787ad251a523be1d1470da239e579104483" + }, + { + "address": "274b57a0c5f98515eea69d3fe7ad4b8f47f449bd87061cb875a1544055e473a5", + "content": "a327fe24af8c45f238cce24dd4c8451f471bfc7e82297449a8ebae4a89aa" + }, + { + "address": "276114469e13e2dd3fc8106a4aefadbc884460a145a70ca52a694a3613087a21", + "content": "cbf77217747a890db96c382f88d6dc24f6e3ddf9cacf0fa6437aebdc1a6b" + }, + { + "address": "277075ca40ce80f7af3633d55b9a36eea6c9a62d5baca59b0ee9649d166b0336", + "content": "86274bd20560a640e90c48c9a2b555d1698f057953b66efbc08e62ed14da" + }, + { + "address": "27bcfd94416f55bf87fe667df151637644b730a42163f9141afaf13ce659c712", + "content": "3097cce6553715d89e91205d0036e5f4930beb586ef12a00c51eeaa24687" + }, + { + "address": "27c747878e7aea9beef1d7ad24fdfe8991be9eaef990f9bce2ad1b77b4f30221", + "content": "8854416bbfe73aa877e6c19aa767e9d902da36b79f403801b851e859bcfb" + }, + { + "address": "280b8a06a48f45f9fbe2aa2b8f38b5011bf480f5bf34fdf09d314b3c97977125", + "content": "395cf2c72391688ad9d4657f246c22dd4346cc89b5da57c76dd2984096ae" + }, + { + "address": "286090e4683cf915800494235e292b66d89dce41843bda1f5cefdc7e457cc4ec", + "content": "47ff8c23e98a7d9d2663fba8923dcc546cf444bcf6c187fa7da61c0449e2" + }, + { + "address": "28e57a8b04cc5ee40b8b384e031cc2bcc34e36944e07ed8a5a7869fc318141d0", + "content": "a0fe7ed82f5d85c6a764a7e94a2b1768f6e72c91155b91ed2b4f10d09029" + }, + { + "address": "28facd022f8dfc5e0dd6fe63d9b0eaf70d632042e76f09c954a85b6abb59c387", + "content": "d88b2a8dd4802f2e1ffed6b67ec327143e905e15202edf0e3386f1b67197" + }, + { + "address": "294ae17edc691f0bb77b7f7304b1e9b8d49b0e67f508002015814ea444da6b2d", + "content": "42530577bf9994a341aa77f32f7a14baabb82792c96502b2f47270587392" + }, + { + "address": "2a3e562863bf67de3c46925f726b181e1a9ffb1a803c65fcd2f39a37f8682816", + "content": "81aef2b19b30783ffbc21b08773a6fefe340af2fee5db821a0a563f4e36d" + }, + { + "address": "2a76d4319cb87cb98d3046296de21edd8ebd99a16122fce9927ca74045a295ed", + "content": "a6d23b7d74f7860e46c28695ca0cc653806cf988226738da72af4cf9baa4" + }, + { + "address": "2b193990e9477f1c4e905f50b6d6443437e77451eaa11f0352b0a672e1292350", + "content": "231563b2f32eb6c1d6827f1b79bdbdf40e570d3b9a6869d576fa15c15d74" + }, + { + "address": "2b86a93bcfb98f49703c23d44a0fdc4469f15101374117cdc87ebb7cd3164999", + "content": "a6238d7b0fd313518d5561426ed0714f7b4c52ca8996023211702381015f" + }, + { + "address": "2b96d5c53b830efea00c4026b2a0ae3b3d0d0ac346633ec34c3e085c6ab074ab", + "content": "567caebb906c380c922cba6e4b6aa28afac3eaa645d6a32db21f6b3a0f27" + }, + { + "address": "2bc6a8731939d180c544785012cbf19147c75c39c4e382c5c689f91d72c06520", + "content": "e45c2d636c1a839d662809a8d311e70237729b8bc5fd6b9476c767bebfcc" + }, + { + "address": "2bd099d04d16db801b8338fa43d4a472cb2f34d8b63cff371e8d65e263aa8b46", + "content": "fed94333a852a7782fe7b1ba1df837597e359e18657ef91fb859577910fc" + }, + { + "address": "2c3cf47d25e8fd3c28b58fd02db352e200d51b15b2ca8442850812afb4560011", + "content": "cdc51277e3cbeb970c114c78bb40461efaefc8f69fbc5efb4debc3bee7e9" + }, + { + "address": "2c53a77d7698d7006a1140eede079b52327a905daab3908cd9eb26336a89a134", + "content": "6003a8cdac0708e13e1c984adfc8a00cfbaca627900d903133e8c458d6b4" + }, + { + "address": "2c7b97443b6f54bdc968ca56bd50a789f5327f8fbe91278bc9e861228df85e53", + "content": "8bd7a8a7a12dd7f044fdeb9ef4e0da8c1d6934c3d1556db17e79841452d4" + }, + { + "address": "2c8b11dcdf967e64bc0ffb1643ee2c5f3387a3c24868d1545cb1c83a34335569", + "content": "fef1663effe44404c5c61b1a7b27b3718ad730310f31539f12382442d886" + }, + { + "address": "2c8d982d6cdf75e4831a734bb3fef3a25aab33aec01002bf777c6ea34355b32f", + "content": "01e45b357eb1adfbc6fc604a2ae371f9472a6a66a10891e66ecc18e6c14e" + }, + { + "address": "2ca205a6c384a2a485868ae58f35fea90f264805693b4565d9e28163fd21f67c", + "content": "fb70252c1bb361336213cca2e3482fdd186536d2b313412c91ebb1031f5b" + }, + { + "address": "2cbd4444c9965d56f25997aaa9a31ccb6a03b9e423d9d8b8b759044feb8a394c", + "content": "16486724548567a52b4155d57f737f686c6d50f87edb069202479dc6dca1" + }, + { + "address": "2cecb062143ca9598951cda7e1cc19000b1b480f53bd81b12a8f6407b6a7b268", + "content": "df78cb30664a0c9f9709ac9389defc1e860b900fbd3362503c299b962bc7" + }, + { + "address": "2cedb34c9396687100041099f392a2e793d10a5f51c6fa4e6b637fd498c628f4", + "content": "8a6d20632e795658fd461fe0827a2e1be78cb08fca8aecca695e6a5847a3" + }, + { + "address": "2d3d0a95751452d0d3281d725ee574b212be1d3af74e5b1aca1443434ff59fe3", + "content": "9b09d29646bd5c1cfd68483a06d68079aff7b9ed14f425ba93b6a73bcbb1" + }, + { + "address": "2d5d1155ebc92e1f04e40e598d97590656d3fbbc3b6ecc0b643e2353d580144c", + "content": "af7828447376bf73021121806f6c599e09e97e682da444c374acb57bc5c8" + }, + { + "address": "2d8689f07f0034d2081ec277f63aee3c7114d3f85890632e18e46a98c2c6c76f", + "content": "7d13c968080fe43f3a4c344a633534f8fa69a80e4bde1441187f0da70aaf" + }, + { + "address": "2db73928adc345c4e4a71d923e7591f30fb3187f8a05408c2e90263a6b943285", + "content": "759c94be630cd9bb83eb571b753f6976137cac0f87908c7ab0f8bc6de9b5" + }, + { + "address": "2db94cd369468946c2a4d0da6a89030185ddc9c9fb90ad98965049614f9ead3c", + "content": "531bc51b5f3ee92628d056b84abe1840c3468f6a2a836fd3094ff188aaec" + }, + { + "address": "2dbbd5437ac799dfb0780cf73d494165bb08dde1b7c351b0bfbf5a955ab3af1f", + "content": "7a97dd1840e39309b8c80ab773d6c8217747da272e7ce7e746b75d76f635" + }, + { + "address": "2dee3795957c93ee2c4e782c80b19e58613643bdd103a8066bd753db19b56a27", + "content": "d2cb7a0b69b5d4822c6e9b534c6e495e52f80a8ab2b565f54f65aad63674" + }, + { + "address": "2e098f88e6683c6691f2f0f9d333587dfda798afac207bb9eb458ac0b7e1ca11", + "content": "f6dc018a06e4935e9ec476b1d919c6ce59f73e0daaff9710a07584ddadd1" + }, + { + "address": "2e19d89d21eb095cf4751c380f0f32018fba5491fdc148332297ceea30f54389", + "content": "b81dbe346d89d66ff04fbd8f03b4cfb1aaac5cc369030bc1ad713831632f" + }, + { + "address": "2e229723517ed9d73506fd4e758176193031850f9ef60c5089e119a45b8e934a", + "content": "c1c0e5e8cdb1c42a78d96b5c4387b8c1779cd445f5f7edd0c33a54fcc6d9" + }, + { + "address": "2e2e780cc99fc4a02951115a8436d250eff246e252970ec57a9bb7b86b767929", + "content": "a1c5e06f0b1bc846771686eae7ce9ba1ad6bb16900efd8abe55f86f250a6" + }, + { + "address": "2e49592155584f87e868b44d6d9664d5c879c65dc8e3fe5f6634cce36fdeaad9", + "content": "7a5d7ec46b2c38e55884cb394f126a5cf64fd4df68bc7cece5e82f178d70" + }, + { + "address": "2e4a67a0608227b07a1708c614bfa47b5f849114b4ff133198753918dd8f28c7", + "content": "51e699a20dde5e6e062e65c2d2b47ccc438464525aecb730ee213e2eb0e8" + }, + { + "address": "2e57cc26bb2f9b8e93fdc78b1dff6d8b7a37a64f69b266f8f56268cc17e74d5e", + "content": "b206038ef994a364105b0e46b463927088da3f8790171014e32de852ac15" + }, + { + "address": "2f57b4c4d0b699ee868f6fb7be39db1ba00907fa881daa47d46c5eb3e8e4c541", + "content": "d101020ae386c2d6aa5073e09530d7ca295f923f39dac43fd6b23cdc6205" + }, + { + "address": "30403eed446f3d0e075e48d5f918217019effbabbc3b8e62899ad540b83c981a", + "content": "001228ad2bd36c94ffe4aed1209a49254047e28870e1320070d4da783eb3" + }, + { + "address": "30c637567a9ce8e16a1170cc719094d42c8d41b40c28a41596c1589b319b13ce", + "content": "1e46f84a1bca2adbe3e651575d2d63c10ae54c5f45fe7bf9f071f2aff35c" + }, + { + "address": "30cfd958137a4b88ce4a3bd09d207f79327312f202c6692a6495ea8f835ff852", + "content": "476b3d73e83c4232c2e135189c68666204336652988726e1ef2eba25a795" + }, + { + "address": "3104201cfc2b24d7a606cafc1a8801e2fb45c3ed96007fcf4588a113121ea880", + "content": "4c9d6e735a48ee0a1dfdb35564045d472690b174fedd4790c7fe8e98206f" + }, + { + "address": "310ecc17aa9033023efd15abc6fcec39230f7325570c62b7a3c6e77891c18265", + "content": "04be7c9d006a99ac659dae0eb4a31ce1983a4c3c038a8028e91480531cc5" + }, + { + "address": "31195513cf18e147dc7dfa40ae5e5ce9dbea30584f68b0c8a801f5169e3bd054", + "content": "9386c7238e83e392507ed5df9f2a04ce02fd5ad7db761fbc5cf306024a5b" + }, + { + "address": "313f17d1223534c4223134e30d92540b53105d90d18972526b7a0b2b223e965a", + "content": "3e636ab2f1357953144ca70f84c2a5b6c4ebf6e250046731ea2cc904a69e" + }, + { + "address": "3172571fd351a96acb815474d46334adcf3123cc5a88ee00da62cea1fb395b8c", + "content": "af53e7ecfb2c4fa6f8da001c38fc5e79c8124ca42107706fb6de853fc731" + }, + { + "address": "3178ee882da681ccd9dc3ae7c79cbb8ba6464563f0f138967353dec2af7c1df4", + "content": "62bbc48c161fdd7aca60abd9f84ddaca826f907acba7bba7f7bcdebf8e72" + }, + { + "address": "31c1c7e14b157bd78dfbcea3084117591e9d1bf905c355759fcb08be976f7203", + "content": "1ec2d7e1c1ffbd9b74890122e01ba41b068a28486a28e7be1451af7757a4" + }, + { + "address": "31e26f135022c268c3592d4f1283007265a256729fa339dfae596b2d1d79ad40", + "content": "175f6c9fb0eed912455e9bb807eb6f7e95db005d1c9806f07eff7d6c84d9" + }, + { + "address": "322f164fe408e6a04e2632dc5acc035bb9e22c7a84653c60dab61df70a75a6ab", + "content": "fc6d344c13e16d91898353bdeb6f2f4d4884bf5cfa69c78175d36105b2d6" + }, + { + "address": "3248301bb73dfc184bcea2ad324972503afa7cb9f3464885d5c673032be3d577", + "content": "5982c484fdba27aac8e9eb6d9c9048dfe0a50604466479e6b6a22e91292a" + }, + { + "address": "328721364a1406fe21c6c42efb723af94059a3ddce86d42e887271327ee0d3cb", + "content": "748dccd812c9a0b655fc26c7e9842acf3a2de084c300c2a84ad64657cff1" + }, + { + "address": "33357f929bf37ec7a6d8b5c8521d730c8193cabdc1e849db427b973a39a6496d", + "content": "6785617feff942040d3a35415763b906b1e6a76f226c7fcddda6bc68f3dc" + }, + { + "address": "336f262eebcbf7e1046ac8d773cb56c87f33a9982c5dd21a85a15716a7b7057d", + "content": "6ff094aacdb8d99081e67b6b9741a8308ad7a37811db706ddf5946183e78" + }, + { + "address": "33bb317ea2f455cd24ae74330f377bc3aa806d9fc109ba7ffb98e3452023afc6", + "content": "bf7db5d773d0c65fd5addda923b7992285d942476847a903338dc3fc43e2" + }, + { + "address": "33ed35ad6ab4f39d22a720776aa33b8700a7f2bc32310d96ab6db165af9f702a", + "content": "28be9932261a4ae74809574240d257f137be81d88488dec345aa734272b1" + }, + { + "address": "33edefdc298be437c0e06e12058b8cf195b7f41f922cd4fadd442d0e52a73507", + "content": "5dd28ec6cc1ed58f5cf51517443709571980475abac3ede0d91cfb1553bd" + }, + { + "address": "34086e45ace31962524c5908308a75b4404ae41b7eff87d25a393e62693fff26", + "content": "13c1331b02b1338bee1664aac03e97b5f5305f745f9d879e56478d41ef27" + }, + { + "address": "34494bbe290598da76cd2b60b28fdb540eacb9bb7df57b5e3c0ed4b67f63cc0d", + "content": "c53844453d0a69e83637b93c57799f4c8f39e5532a5b77f3bd411fb825f5" + }, + { + "address": "345ef60f8bce1e82f696c0f9e5ecf4468c3961393fd2704908e72bb2554fa13f", + "content": "85971b8cd20fe3b683257e65f683a92a294a30f8a1a3a15c4fb01c33b77e" + }, + { + "address": "347ed45a9501b731413caa185792bd0368d752576d1c34e3fa7b17766d9df917", + "content": "fe2d426d7c40663f98e59db0eba78b60fa128d76d2eb0ca0fc3782ff0627" + }, + { + "address": "348fbcbd5777d975101f3f09ce8452f1f3825fb285af11d0dbec7fc25977162e", + "content": "3b6613530ab40f653eb6dca7a170e66869fe3245a65b1d772841fabf800e" + }, + { + "address": "34a0948124c7281276b55d93e7fc305c5943d1fa871521258c102ee4484509eb", + "content": "87c75ff709f5a6492283b91ed9b9c7819f7ff33b869e3e6d5236817d4845" + }, + { + "address": "352e59775425b43b480a84cc5a4b128d0c7b92b193b40ac7d0a8aab8d204fde7", + "content": "97f8376dcacc87be4c242d410ba86db7e115a11c9ebf96837922cf65a28a" + }, + { + "address": "353f68110636fd5e2f055dbac8258dbfcac162a583b9b214aff293ab2d5e13cd", + "content": "dbda8b842135d995e6f23aa270be94e53cdf6c2b0eedb299025498c73dad" + }, + { + "address": "354395044efc86beedad09eda00ee0a97b658363e18fa742eead9fb435b7bef4", + "content": "5744fe8a4f47853b7fc5e3ee6d893908c17181720e7347cfc0510043d554" + }, + { + "address": "354cf281fc96e89955725b2f437ac47a94e4b5441f25e0787982a0358901c8e3", + "content": "53ffeb12963b67bad778dabe3ca82593e9bbdcb5168104eaf1f883e451f9" + }, + { + "address": "35c50846e4d012fd7ecf35e2689360b1e89e24506ee1e967906a6c7eb1199947", + "content": "644eb05e0697fdb43f70985ded90040a63ebb18bdc7db3b97487ce95d8cc" + }, + { + "address": "35dbc366ab2b429a89b38dbb914463075f2992718cc1463c90343874d910f864", + "content": "265cdae5b4d43ddba5bd41dcd87a136b351a6e08e4d76cfa8e8b4d9e4efa" + }, + { + "address": "35f9a81531d91b79238f97b28e9d33fdb65a63300dc9c53ff7a414760315af8d", + "content": "847df3d0bbad2e8f77a7498b4654cd674bfd1d204b874b46381e97db3437" + }, + { + "address": "36177dc02b39dec25b9dffd52748d61d2559eb08e7ce62e12283e0413a9b052b", + "content": "d42d756ba54c33bc2a6824413830006bdc96f9845da37d50bfcf96540701" + }, + { + "address": "364ddc4e4eb3b9940b1bb8f9ab67e8162bca8840f4dec6d370abe23be0a2744d", + "content": "e4254d35aa93a1a931956fe8127e56efd70132639ee04412c95604724e67" + }, + { + "address": "366beb1a48fac10fa882fc433f84b39facd3e0bdf55560ef89a67de9787f498a", + "content": "d2a2bef83a26e5c90ed615fbf16762228a1c4793342b585ff9079e9c0baf" + }, + { + "address": "36dded1410fc032ce50df9d718e27961372d089da03ff74996fb53bed0bd7417", + "content": "1875e78c45b0cbad25ffbe8a67f02de8604111cf5812a245c8bb7a6c85f5" + }, + { + "address": "373719ff49359fc1371af011324a09ff4e6e3d7d3da6b7384a5e0f7268ca7b3f", + "content": "3ecc3c099c946007adc628facfd4bc1b536eded60484b087b6d6f504cebd" + }, + { + "address": "3738fc6ef8ffa932ad3c850a25245ea4cc5bca6c4b314971d629e0dad40d0f17", + "content": "3ac88a7e95210f68c855a96881d43d20fa3afc29752f88e68e64a86b9171" + }, + { + "address": "378608b1231d64937bf994af32e37e31ef0b8779dd41d4815a86056f8fcb83cb", + "content": "afd8ce535d15222122c2b7b6bce2cb4660a260219e71db3bddbd98853369" + }, + { + "address": "37a80d321fcd17c52e558825789d7f7a397414195940e0f2c32f49b2fdbeb3ec", + "content": "69ea7d4ae81768f1fe24fb97e804c06e60faa68839ceb0ea1b193166613e" + }, + { + "address": "37fcddb9d43653eeb7bb9e916077801902dc5ce505875fccc6c87f3d4557accb", + "content": "6dc0219774037e961d651a2e31465f7df08ff674a83766e320f9bcbaf55e" + }, + { + "address": "380cf4aaef14f1c01926d52de9ae6f337da6b7b5335f519857aa7e70bb31d0ce", + "content": "073b0b453642174fecdc0de564687862bfe2d3247e1894f63e9c365a57e1" + }, + { + "address": "381567cc53404681143746cb277b132dbe403424306dc2b9a06af4eaef0542a2", + "content": "aebf2f15b308266bd819448c0ea6b25dcf5c02121e93540c1044a9e0f34b" + }, + { + "address": "38651e9f7061b1dcaff7c74da2afdad2833456ba8fe4d93ceba7f9f3f828d154", + "content": "e4990282a97515fe0642dfaf57f9211a0b7d1420fbd7aa9640ac804459bf" + }, + { + "address": "38d39c575112673186aaba469356725dba1397de5c8ceda6719a6047580727a5", + "content": "40cabfd6acec3b2566073fe21011aac5c2482ef694fe5e8d8c27d779b208" + }, + { + "address": "38eeca802f10eedbb80945071dcb7004a73625a163817ff4b80db102c617dbf1", + "content": "d9dddafdc56e2afe58c16e02a4cabf5c37f46e546221ab31f34ce4e9181c" + }, + { + "address": "39671d93cdf0374f3300671b662e30d68cf8f4816d7470076dd95250447969cb", + "content": "6eb7d135858aeeb4d07863830d85d699e8b831497671b54b904730471b18" + }, + { + "address": "396d5abcd1ac10fb1ba8841b0c55fa5371d257703022c922270bc64124c6ce9d", + "content": "9cf4f9028a440987cc213fe7508c3d0396b776facc345d76993246c2509a" + }, + { + "address": "39ca9d75b35fce19aaff39a38a61a0d3423e051a7c60a05d869cbd44a9b2f427", + "content": "b94ac939e26e0a0b99dbede68f77c3e1b3621038af6fef5d97e62e56e266" + }, + { + "address": "3a012038c812e4c6105d2656c1e03e8a51040086a5d38b86ddda7fe865290446", + "content": "37519e781889a45e9469123f1219e30cac5b79189a0e8afabf443a121ce1" + }, + { + "address": "3a766eea0bab781b6b3082740a2a42bc15dcc1b47b5196382e1f1d936897329c", + "content": "c73cf0d842637edae4fcdd2f14cf8a64879791f432f0c462023b74a606ed" + }, + { + "address": "3ad06f637f6b4eb84bdac631aa31c3c859f1b4b5239dd705055555d316818c4e", + "content": "7b98f6ddef502a574a095de259cf2bf18ae458796257cde554ae592e0e17" + }, + { + "address": "3b0a8b728c0dc8a4f397e62030296d1d07f936829a1f2dc818983ddebc0d28f5", + "content": "02f5810bb6325f8ded8ce786b9f712f70eec4279379fe736e9899f2dd50b" + }, + { + "address": "3b821ccee8dc8941c4455e5b14af8139eaa0566152678670a0acfc4ed16deb8b", + "content": "525e9a044de3f0c2316901803f298b9d91c18dc292eebd64240995be5519" + }, + { + "address": "3b8dfb0f421c39bc82a6e7e55e33cf64c513864b43814e99f798482358675b70", + "content": "9eca4d267edde804683a058b1aab3fcea4f2ea08ca2cf4158fa9a2776d40" + }, + { + "address": "3be168ce1c0cbdc0ecc00bbe9ed765d6c0e16ef4fb3af61aa26c3eacc96e2ee2", + "content": "e219f24cd38decff75f156cd4e5357b7c17634e83b9b7881aec444b31f09" + }, + { + "address": "3c610c95dfac92d8e961a80046df1d24861b4e34f12e0b106a93d38d31774015", + "content": "428c7115f9b1cfaaa5e0a51cf6f2e6e0583929a1e98d6ce7a16caf8a5f02" + }, + { + "address": "3c87718b62c718cc960a76b7ba9bedc362a2a69e36b99ba6e969fff97ec4a742", + "content": "81e4ac8843ab3785d4bbfc01cbbe0d99820dcdfae602478ed2d6f630801c" + }, + { + "address": "3cad535ba18947a0f8ef18a137716e3fc8c8188e143ee77f71d49a4f9e73464f", + "content": "4e57ee8e6d42a3331e023d181dc601f734f80a4a8f6e96a0dc9a9d9b4525" + }, + { + "address": "3cb6bb10fbe58277a9e736249a0664f309b209ec4c60bfea7c8c812989dea5a3", + "content": "48e7f5d59e0c4c315f1951eb9d2bd13dbd6afdc13a9bab3cb23391d4435b" + }, + { + "address": "3cd7f7b3ab486e4e5b1e9d381a75d4034b9f5d024268b43dd29fe6341869237f", + "content": "5600a5ad2a064812095059d522e5fd91b3abe62503ebcbe58a76d00e247c" + }, + { + "address": "3d29be84605169ccb848c54225ae6434eb6dc5c80a820929d1d649648dcba099", + "content": "a276a35d85acc73513d14c5334fd32cd7cab5d3ed3fbfa42475028a11400" + }, + { + "address": "3d9c2809cb28b621824d19bb375c5cb8bd6d12e31fd4987b3766f1e06a3d5088", + "content": "7fefd4182cb30ff87c9eab1dcdf01c9b89c0511258addd1a86cb241dd0f9" + }, + { + "address": "3da54c9abab4de933da171f747d367cffdca3b06c27a970db58c25ec48fa006b", + "content": "2bbc749be87ec082d8643f88b82b91521410fe18d72fc3f6b2ca29c7a5db" + }, + { + "address": "3dac34fce1ccc779492094ba86fe2f3908489cb687d057f3bb63b51376115240", + "content": "ebf63bd61a0381e12a90d2c04adf8323bb7650a5c7bd0fcc5db397fc4122" + }, + { + "address": "3dbe0405f1b9f448dd1124288f0914cf1c9cbe192b3c8452c5a77125c8c7bb2f", + "content": "b4817db64d69cffe19f8d33d669b294207c0d284ffac729994e32318a7a9" + }, + { + "address": "3e61b716a9308675b713ea005697f9cb07673f7583a71e1570b61d64c3317aa7", + "content": "0c0293b88522e694ec69e3af768047c11147d9657fafcdcd345066036147" + }, + { + "address": "3ec1a48445cc3d39cf8587b61a06be003e0f88301b6a1ece30fde3bdc7471e4d", + "content": "1babeadf7a9b501df1b43f5ac4d32bf1273b0da5fc7a8a22afe936660987" + }, + { + "address": "3f2e3713861d17eb61f95858c3808479bb3e55299c0dff02c3e7e18f1eadf0c0", + "content": "36570d65023ec5071217fc03f609b2143973e3464d0d91808a37a58c72be" + }, + { + "address": "3f97ab28c8c0dce5a70a248413b6431b23fd36281001f9dc77dd4e4b37964b4d", + "content": "17e8621624b2769c7c266b1bd4e14e7fd938cbf8cb37b82f88284e6c21e5" + }, + { + "address": "3fc2294e5342cf3c32710aad0750e6d7f66ecc5065eccb0be72617f91fb9b29d", + "content": "5d099386bdb8dfab88778fce85a7226591dc4d621022cff2969ef6192020" + }, + { + "address": "3fe44070db140fb67540686a0f49417e53ab993994b4d433da58d14d1004fa0f", + "content": "dd93370637dcb8e1955362e3317e24963f0f5d7f327139bc3c57f7c28d0f" + }, + { + "address": "3fff495a5695b95076cba4730386f2806ebc6939a208b768413c8df528fad522", + "content": "331ffb22fb9228c23f1d4c03b9fdc1e36c1f26f1b4c4656ea324572e9784" + }, + { + "address": "4012d7a684ff924facecb5a367313cce617820680dd01f801b1495912def2835", + "content": "cf0f2b5ddba723f65c9f55574748d56537abcbc2e7a8113fec48f583ede2" + }, + { + "address": "40b8423e5f992156ce1f24d97377ac2d5e49e99d7954e1fbda63f719eb3a09eb", + "content": "1bac7547d5dd6f3ae3ecca93e84c65199e975798c236ad6c9cb2b5786dac" + }, + { + "address": "40d3ca251810dceaf82a49dae997766523cdd4cca08d93aadab84ac48344ebc2", + "content": "74dc8cffb1bbf59be7318d058ef27aced4fd925fe5a252c60dcd6a8cfe07" + }, + { + "address": "40df52aaee6c4f0ac2f95c42fc246e9819da50a6f03f006a644fdb5a3914e3ef", + "content": "d7ad028ecda0fecd6929f5394a9a26b89e724937766187137c70aca2373d" + }, + { + "address": "41dcb16a2c966fa7942a65a5365d761dbebe0d9c45f99883e51fef4882ba92b0", + "content": "5dac486132742371e92f24006649763665f478dd8625dddedce52107b050" + }, + { + "address": "421a40d30f387a3ec2bb7d6bb084464727f012076a425e8f3873eca0ed4fe5ec", + "content": "20c103b5fb35e288a2a04ad2d143ac7a277a02110d52b69b4469269ec28a" + }, + { + "address": "42c584249608591464ad77510b300a5cede262f6afaecf0d6b37cdf0913e512d", + "content": "965ed0777a9316409a954cb8eca1742da217f6d86c6bd1247e76f049a408" + }, + { + "address": "433f87b29a84eb6f76d6f2b34a98b1fc81c1b150d521980b997e04d11b42f573", + "content": "4e73b1588757e97c1c247beaa1b8580f70fef6e42279b3c9dbac6e466212" + }, + { + "address": "4373f4026c7598d656dde23150ce9b03e2a6b7875b43926347791cb72555d2b9", + "content": "00ec28486d17ce5fe2497bd545462be5573538edea26b7998cf003a3f8cb" + }, + { + "address": "43b290a794acbecc91e6e01e80a88de4286175ba4a548c07f1dcb26cae032b32", + "content": "502bbaf22ab9c125f6a6a58f3e8bb77b226f0a67c49690fd9d6e98be9f7e" + }, + { + "address": "43fae262115e5a57e7edf2adb14c5a062cbe77e22d3fa0c66a3884d95297e55e", + "content": "d774aba8e4f3faa9c9304a752f0d4c5045acc783b3d0ef458d1f78c0f270" + }, + { + "address": "442110136bea51caf01ecba5cd6fa744a87f3a9a68a18a2857bb22a239ab0277", + "content": "3bade9aeca23a00adc701a3046aba6e9092d99cc6659ec34fb305268a3f8" + }, + { + "address": "44aa703bf80b4f95a14a97903929184dd2d111a7babd06549669d87e8f014533", + "content": "2fb582b2fff29c0842c0a177580024af4fb0fb315e36758296c446755aa2" + }, + { + "address": "44efb60da68c98277c7f0b05e73e03f0231c1923925bc442fd58711ec026e60b", + "content": "e56b9ef0adb33e0e4cc0e638e4fc04e217f13d48b9269d69e222a38e6a35" + }, + { + "address": "45335f6b9fa73ad49f509131b61a20eefbd27412c164228d3224da16a9a8a554", + "content": "38f47c41e260ce22aaca4f0ed6f7df75f967aefe9d8cebf165741ee8374b" + }, + { + "address": "45410abaaf1dcf370b592c7ad6266439ca170cb84395ec242743ca986ebbab30", + "content": "9daba8ab4d4d58f9878a09ec7c3d76885b26e6c07adee54d634f63f6459a" + }, + { + "address": "456f693bff32b0bb61bb8bae4ce7edebbe2338aea93e483d4e62359582f64df1", + "content": "f2cdfa57c24b1daf05f7b7f4af4b373f148c5e61f265d4da95f807b64a7a" + }, + { + "address": "458ba6e61fe52f6732b635a37c3836ce77e863472cf21af989c6e4af02864997", + "content": "a1326435687b485ccb7fdb5df078082eb7eb76d2ef1289bcc52fa666184c" + }, + { + "address": "45907b1baa72f41972422658659f356ce0996e4a0550a09248bb66e6b77969b2", + "content": "2697f8547770fae224b59184c0aa268a00f99241ddce0bbc534e71547038" + }, + { + "address": "45a58a340166e3001dad4943e2e606617c362572f38bd1b8412a82eae5c7e7db", + "content": "ad3195b30c0b8d119f755d6ecd21fc006ad2bcf68e672be330da2939dd6f" + }, + { + "address": "45c3454dca902b5f5e18a7c71a1203feaa69c4ec73ae9bdb8d0ab3327c8ad0a8", + "content": "14471b0b68b3973a46dc520ba7805cb1a87d7f92591fdad52ec895e47f1f" + }, + { + "address": "46ed8937c2341dd018ef1f0fab9bfbbf07e1f34b8491ca060c56e3eff0fc6231", + "content": "a5a663b98dee63bef7735802f26b1cb723a9b71c521a979a27f6ebc7881e" + }, + { + "address": "4733762e3d1c0781ab1f27c637b0105f2f59dd917cf6fc0ba4fb8d93fb1b336a", + "content": "886b6e64ee56ed8d892f1941dbb547de8f21f989f6c08a84b6f81a2690ba" + }, + { + "address": "47f59b7df66ce9e8c52dc918069f0fb85c4ed462b397da89efd4cc50abcf88bd", + "content": "bbe557ae143898d3ebcfbf84a2731d33d53c11fb978f136c98f54fd11aa2" + }, + { + "address": "47f68ebe96d6ad344c80bd4167f702d95cf9a44246e57de9408efa2d98fb9742", + "content": "785de4b93dcf707e7627cfbfe47dcb03dd7d8dae56a859b7b7e38550ece9" + }, + { + "address": "485ba0bb7183d08dac2c0bbfb0e193ddea010f415b2705b8ce9822b5b4630725", + "content": "d540b19fa371303805e7232f9109811d0375648971ab401547b8eac337a9" + }, + { + "address": "48b60c9998a076164069acab8a7e075bd1e8231951ebd89bb4657c11d52e75b1", + "content": "060fe1c51379a4dc7b962d21a27c07a7a7f7d8f47f69f8b15260dddcb229" + }, + { + "address": "4929732fbca861d18a0a468ca61255e7be9653d11df77d9b1a1a2330ff34bd95", + "content": "a19a83e2e6a40eca5bc7013e624c2b0ac6599a8ced4038d9f27be00d0b51" + }, + { + "address": "492f89931470e61f042839dc0c0b6560f1e174a9aeeba4396ecef094614a1e67", + "content": "683e192a0c902601b3565751f7f2c6d311676739734fae41c0d13ff1408c" + }, + { + "address": "4946ec7460669035ff76a419cc4f0883a7e0d712584befe4981a635d522f7e61", + "content": "8eab33510db1753cc3105748335df301aff59ab0913334c873edec478698" + }, + { + "address": "49db633ed2018295576e96e9431a4714e30e50ba8af4864f5bca928a60a84b1a", + "content": "175166873182d76524b54cdc450de25b6d1247b9f325dbcc777ea4bd8d75" + }, + { + "address": "49fa50f7b164f7d6f18725d98d46735c5760eda8eb7d95ba623939be74c47603", + "content": "57c3a1aa25e8c1d60315534704ae78a70d897d9bfd266a1143c6b3bad6d5" + }, + { + "address": "4a36059b249b2594598ae1d064038fc890504f6e6070dad2e6e0cd25dc253e8f", + "content": "7b9839cb3e0a66ab7d2332a7b05c9209e2eed4135f2bf1f0fc0983200197" + }, + { + "address": "4b28e2d8fcad3da6a2c6f2c8ae9e7d2f29c60cd13ce0386bd2b8d46be0dc3eba", + "content": "7408fafb1162dbe4fa8860b593f0e599b68db7eec244c3e4651d3ca94d67" + }, + { + "address": "4b337c0aacc1c226e3131731e8ee328847f25f408ebdcd78af6e2e371bb20aec", + "content": "7e7df9bbe265754831493662437a8d66bc44ce96cc381bd5fc7d159a552e" + }, + { + "address": "4b3f34e7eea287c24f755bf22ae2000a37a56f3873c714b9e6bf1924d064394f", + "content": "a0b3d3663dc6bbc0c434ba7b9ce664367e1c915a8f105e994842a91487f4" + }, + { + "address": "4b81cc6ed30b714843b42fa03e91e4087a059bbc2d85475845baa73400371fb6", + "content": "cb1c8691de7ce6b14ac090d3a579e0e3b1237bf5bb762e5579e508838ba6" + }, + { + "address": "4b90d8f96f1a452387160e2411587ae9f5d38bd16146da9b4db39f58c2be3ccc", + "content": "e8a305646af97b818f9261b15b1cd1b840238278720f0119f64a31dc9cdd" + }, + { + "address": "4bb41bf31a7aaa20e347d9cd374d41d0966c5115d54c3d635f923739b193717c", + "content": "e0614dfb0466453147144ae302b93c92eae8a68b67b42c37a309f9e1b9d1" + }, + { + "address": "4bf9338039b8af9622b7252c19ce4268691855868392153f072460a0937d8374", + "content": "13787a2cb05190970e4a1863f03f852e14e4d88bbafd4c6bc948b82e8c2e" + }, + { + "address": "4d025d715f76494aa6696064a1c03bbda29decf10b9759419ade01649232f4ee", + "content": "0ae706d98eda17fcd2e9fdb2fba9f6dff0b421dd5765e67f70fa013c6bae" + }, + { + "address": "4d1c8a9377b57daabfe047cc9a1f8bf0cc48a0b7d8ecebdbcf6ca1a6c5c959e3", + "content": "e9bbb13ee485fb8f173ed06d50f49586327fac45007bd10ced43e39dbd3c" + }, + { + "address": "4d43c324828871d49437fc9c27f1ac815341f555ae7d87fb06f0aa28b7945960", + "content": "750d994f94219f0bb85c9209c1bb3c3ed317ea04a4fb9a125c8a061de114" + }, + { + "address": "4d4e2cb94597310c1a719256c79e471d083e51869a9a2168790d97631d716d75", + "content": "ff41f28349207b8e186ae7bca80e52407ead4f14f083658123d86550ff4f" + }, + { + "address": "4d5003b19206c0be8241018e12537e1febbb9b7069b02d2e597c8c0de5222754", + "content": "e0ebc9c9164e92758960bbfdc6f5c9adbe5e17ca8a96c5e26f4501774f84" + }, + { + "address": "4d7e52a17ffd295dc41126c3da7d68979b606c419f9ae8c735114442623a99d8", + "content": "d5129e189cc7ae5075bc98170ef3e7683eeda0a806b5cf96558527b785f2" + }, + { + "address": "4d954ac166843d27257399049c487b00f4b810a07243658c815fdcb5a2b90335", + "content": "519a423f89c66d82d2c84b3602d7ec3a1d0f8ed89d43038ec091fb5597c5" + }, + { + "address": "4da1e51f6d5c90172c3a77422d30b4eb6ca86eea4c936291462c8cb8e39d0aea", + "content": "411d5544be830f06649e52a71d9dca2949b4750348ab09e0f6d03e2726a1" + }, + { + "address": "4dd0cafed7cf8eb16a55add5774d53e124385e6eda5541cbbb8388bd19079b6a", + "content": "fbdc34c0237ed19c4bbce5043405fd9a3dc6dc0356c6b420fdbd0e3f4d8d" + }, + { + "address": "4e01e165e79cae676f302631c3795d79a49f24d2481190857ee48e881e62ec10", + "content": "05f9b42548e7d9ab46c941c2fd8e30d84fe68db12230dcb6434f9a7c6ec1" + }, + { + "address": "4e3991b823a0aa679ef705605208c37530d578e4aa5ab9c9ad0f39bd9c559252", + "content": "4ff9b44e94af5d9f2348d00ea9f0c5a83f900f5592726a10881da483000b" + }, + { + "address": "4e4bd0d2f52744b705a1bef1ba40b79096885fb641c4ffdf97dd0352eb443836", + "content": "8ca583ee3a3e13639b16c4d238f76a37587ad4baee828ad93a4e7527e2f3" + }, + { + "address": "4ea6f530051a873ed0a7063ae6537ba0523451b996a70b3eabb45c37ac94717e", + "content": "c85e2b30a2b482bf3b7c3f17ce593115f75c7c540f9b24a70be16429d3bc" + }, + { + "address": "4f3b116c99589586aae8790e173507d6fa7b39f8aa811d509548c75728beec1b", + "content": "8788e18f1fc9626b9d787ba8056c21c6311d8aca0e184f6e6dd2c2b47d50" + }, + { + "address": "4f5d864816c14f0fde0d68c472253a7c6da9426d7e0b974585d512fa28c3a868", + "content": "8d4c32117002896102e2534e6fd9e74ca55ae79a7c5310e1084d4cf0a3b3" + }, + { + "address": "4f79e5a7e7c381e23933c9300bdb789d7d1a6531e918599af74429400219b1a4", + "content": "3aa8442820b313cf81d1de1e8be5c6b9826c8bc443bb972a161c3a9ca8ea" + }, + { + "address": "4fa949c7595e89a46498e5bcfaac11daa4b643f26214c9b9f769184bff5aad7a", + "content": "6a085bc9edba70e58bedae8d476c8c2c0c7a50f6ea8236448a6efc89f34e" + }, + { + "address": "5018b5ada47eaaa80ef96688f6bdabd0c592a6eeaf3554e764fb9acc0066ac6e", + "content": "91911b0937527881486db6699cd4299f869e00400857f9ede7d8006f7898" + }, + { + "address": "5027453d06c129399456d6280c42d1d6ae7565e322d1408d678b5199015c4cad", + "content": "9425d1a461dbb0dc526902f02f09443ae1fd6f20a424d42520f84ddd2dcd" + }, + { + "address": "5052bdb0ca4e4ce4a73ac01fb0faac33ef981cc19f4bbeb78c62ea93f442beb5", + "content": "49cd568a5e6e1f94c67a0eca0cbe93a2ba3ab9b539f8b6a82f5be963282f" + }, + { + "address": "51b61bec43ff08cdd2dc955e5a339ad704512607f19e965e10718ccfd95db13d", + "content": "0853d095a24f6dc780fcfb6a55d064d5483d0c78358b073f68606947774a" + }, + { + "address": "51c89efc204edfdbca9596a2003660dbebe27bfb3122c63a757719bab66ddf60", + "content": "1bc01af747651bcb44f5c66677f21c965a08526a0cb513eb98d88f4cac3d" + }, + { + "address": "5223e35ae7a2a6dfade6f5640c2394a69020627b44d34e1bd28e304ffc8bb456", + "content": "9a7792a4def743de1712b0baccbd8368f731a48a0dab016009d6bceb524c" + }, + { + "address": "5280c20fee60c0cd7af39938cb7b743699397c0159abcb1596c6059289e914bb", + "content": "895019bbe138f20e4d2ce8a8ac43b0e5d1c237b029ba75d936b8c5cd8da5" + }, + { + "address": "52e100e51b6a418cd2e532182ee9043d5d8bb8d43e57adf023011a68c4f92c7c", + "content": "63236ab52558fdaee744234ef7c168e8e84603ef73ca9a202d06c42644f6" + }, + { + "address": "52e76de5118a081835c6bc2d3c31b30dd59922358fe52fbd77147875553c1479", + "content": "63e8f9c8d93991f9f3c8f01922a10bd8d1e6f91a4cbd9d2cbcae53ee314b" + }, + { + "address": "538c7c42b8c432f2032d654c18a7e03e989224158d103e3a119c1ac94e22b955", + "content": "fc76c52e1df9d9e6fec380fee83a587b9c1e9ba641572dcd48335eaad89a" + }, + { + "address": "53b95e5d7cfe5b6e5587e86bbd02440db13c66c380da6b43380e5f0abc3f3c41", + "content": "bbacc129ad89a177243b8d33604f2b31de038b055af0b065744caaf852f2" + }, + { + "address": "54087d4212ce763b2ad34e3b5582698270a361b4a943d0dbc1bc70ee4ff32233", + "content": "937fc74df92968720571190022cd9023dafd2154a344ead40ae492180e65" + }, + { + "address": "54611d5b77cee4934e708f6cecd18706e7c5c298e0f4d0bfb292d6b53019028c", + "content": "2b9b3ede093c40996e9808212b3f6b5bda52c2e556709e0d3358d5ce187d" + }, + { + "address": "552be8acf09473f6e00d1ca0a4bcbb6029d7280702d16874520277461de99d7b", + "content": "09620149688f96c117ea4a005ad38b87e02170eae0337ec7ed1e2a910b10" + }, + { + "address": "55591099948815ea3d13acc060e28b691fcc016575c19aa9910d981226e5e9b2", + "content": "673e1b4876fd33d4182afe9bed4075ad8995b623cd85e7b728550db0be64" + }, + { + "address": "5561b79221962bd81d14b849a34f964eb1a67f9c36ed415eb42896a053ba5a45", + "content": "d23ce76234318359cb41bd75ca26911d7491bc461f231cf507290ca8492e" + }, + { + "address": "55a4d4c45bb6a38bb9f0275e548115248602f58f185141d6681aa80704c16453", + "content": "cad4368a82b0b11b083f79c5e8775800e8779f1be9e8f12a18a75bc564de" + }, + { + "address": "56f83c7b1ec7e3041e19fd0ace067e62643d905286bea1fa30fd01799d3cf8e8", + "content": "f919596cca2f34fb3ee7b976708030db9944664d1e6cfc841c49868933b3" + }, + { + "address": "570cebb1d94c81c615392abe12aaada36929b41e3a6921cb51989b9c90374355", + "content": "ae13e215293d551382f65d37a828ed196f86a98500be5846abb242228b44" + }, + { + "address": "5748bd5946f89223e1593dcb20ca48c4923849a710b35c085120f4362d3a1a9c", + "content": "ed8ee37b22aae7e2faf8ff5a66a2a6b52faddc1f1ec15e03b4ec85ed516a" + }, + { + "address": "574d482d236f421eefbdb6e8ab5870df8971c98a3e8437b7cba5e52e87c4888d", + "content": "9d83adade2e7599921455a7e079098bfd43d4b2f0b9bf57fa859574ff19e" + }, + { + "address": "57857869b6eae6ad5173e1d3e19fd73e1f7575bebec5ea08d42596d6ed6561b1", + "content": "6225188b83a3bcf72e6ec03a39f66f3cd6ec6f5e2c583b1ba55f695bd476" + }, + { + "address": "5790ba1eb3a358698bdb3adc88a4f48a180acfec16155ec3710c0c47d3555ae4", + "content": "e17eaf628ad2573d69e447a2bcb0d84f91d844a52f44ff02d9101b208244" + }, + { + "address": "57d3d7dd01a395f25e94b6fc7f3facc843cecc730b46bc716d0df0520fb9ccfd", + "content": "d9225237ef9954d6712748cf79eacfab5d67d74a97ee1828206fbeb46100" + }, + { + "address": "57e71c9e1d2cb2a8417e138e07542e2f5f0535d1a7ad5d1e1808b230c32d3874", + "content": "270c6e43fb355ef1bee4d6e5d9e7242a74b4597b748a3a666eac797d7c62" + }, + { + "address": "589cb0b014677faa5e53da04fa47dd3abcf2ccab28d59ca832c5c5938b0157bc", + "content": "301bd688b3e0be6e9aed866a2a2fc5bbc71c6c2425cff9609231cb37eda9" + }, + { + "address": "58c6cddaa89104163e4641303a062855c6a1507c4e9b2ddec096d1d060a49462", + "content": "bf6f490690a1abf8177f738376a8689ae77e1cd8c98791d77b43c69e03bd" + }, + { + "address": "58cfad4e9d1ca12759b7e3a85f3a72aa25d7b0deb11db05787463458567bc1d5", + "content": "fa2feebcfba23583c725c49eff09de009a3524f6d3adf88d23117763f596" + }, + { + "address": "58cfd4832340e34acd472164283d4ab830136036b523c565285d6c9189e06bec", + "content": "16243dab200065ad0e5f1fea40cb269b7b7b658b36aefed1acbd58415a39" + }, + { + "address": "592e770d549574fb67200c4a133edd58ce20112eb0a0e312c93874124485f4b6", + "content": "93460b04679dfbc16a05ee701d5468c46c719b74aae75a45b3db92ff7c5d" + }, + { + "address": "5951daf0a54f06328ce27a0c56fe1beaeed127b55c4503f9add7fa314de7aa7b", + "content": "bd5ff2746b73feb9672e4c59f4fe3990e265d0e81674803b830a24e75ee3" + }, + { + "address": "59ddd37e9c737324a320687632f456d632e847118a914f1a921cdf504150f5d9", + "content": "d1475f70e283fb0ec4e7848614bf5be2530ba5bd4313ddb0928171acb73e" + }, + { + "address": "5a17b70d42b9a64cced0ec56d23191143a71a4772086cf7e735e67e4df8af200", + "content": "398de8b370fd6c85c901e54430e529823cb4d2c00d4abc09385a245f94d2" + }, + { + "address": "5a1e0d0ff16377a2e708c040f0198c1cab93c6bff366ccc098575b5a509dc588", + "content": "ecb48872235166533a795e0a9633a9787044678ba6f6a844695c8d1af447" + }, + { + "address": "5a97e29bfbd841cae9a8de4e26c6e2d35f03083195f63d44aa93d1595f1fc942", + "content": "bdc18d7bca5f9b41f91d7fbae35c8f935aea4ac1e9badd5369e649ed899f" + }, + { + "address": "5aae7b4b76635fc343a30e2d7dd849226db2ff71fc7e3f8d70b3211b82b78723", + "content": "acf77d0c18dfff040d7008e41c477c1ddbb3e41b15e9aa99e6041f674e08" + }, + { + "address": "5abba303b815a1160c4cb7eac7f437a33be67c8c91ff0e0f0436769cef1ee58a", + "content": "b6e4431632aabfc208d4dda125dc7399a2ba9f73ef7c134d5cbbb8fb01a5" + }, + { + "address": "5ad2d638b8bc1e44842f7a6d18e7cca88f5fc2899fe6026af8b0590b0ca8a0ff", + "content": "8532c1379a8811138017f344187375b606c9da1f8f8474213063868d1437" + }, + { + "address": "5af98422b5b3014e194898daf5d354e89e05386f49695cb21d9c46b48bb22e57", + "content": "0670b43587112002f658ff96ea8906b4741f6e326a38d0312173e8feb9e5" + }, + { + "address": "5afdbf42245bb9d42a2bcae78ee84399738ed023828430528e0765be76992191", + "content": "d106139d9442cbf00e232897abecd330c5e69b48d9091a9b0de16fe76534" + }, + { + "address": "5b21a4565dbbd1f9ae12e0083252b38b79d240d738922f53457eae9646c7c38c", + "content": "fc6e983a760292b8d0c2bd5d6f37a9c425b8db599cfc634bfc8736da1803" + }, + { + "address": "5b99a3ab99211a0906b78f8f3dd10f8bb148e915772a2d34cbcf50ee78ddaad7", + "content": "631241290d5d451b1ab07924c9fd020ff94db8f65ad15e4e0ad33b2efb4f" + }, + { + "address": "5bf0de38f0da9556a1128ac008867201ce537a6e9f7d7918777a4937257732a5", + "content": "5168fc746b706adaad2e41708defb51f49cda76013da889ab8799a5ee619" + }, + { + "address": "5c168851a29083b530b4acd163eb864762fcc0f49569dbef15ae98e6cef5f06b", + "content": "db702cd7ba14e7239b2de09e0a201a1b250d69e9a372f349b19df327eb99" + }, + { + "address": "5cca4cda9fce0f063c4e6cda9e3804b7660f7bf5e5b2cc2e603adf9fd82a90b2", + "content": "dfa0633b161afcd2bb97c3c1374555690c68781382d9c5fdb74e41ae8d4d" + }, + { + "address": "5ceed22a4804732d63934c8860f528155ef5842cc99e5e8c903ddda242c50f72", + "content": "def8b97e9b13f14f7c69d301eb4a852b39fe824ed2e9242b148b2047d52c" + }, + { + "address": "5d4afc3fafd6f2b3e26d514f36f9bb9b9f58d70081d153c07756f1549195a2e6", + "content": "3a1c41922e21fe221b1e9f80397c63e2c19f2807e3030eb01a12d883dfd8" + }, + { + "address": "5d5a21296c15c0d6b3ff1755ae0b112b35a5860398d35e14836f062f076bd3cc", + "content": "49bd490b1f9f6512de2fb6a913ac1b06e69b4ed5bfba775ad84c809fdb74" + }, + { + "address": "5ded77d1054a6d1846ce5e359db7c2e294adf432fb50414ba90e43a047bab05e", + "content": "ba78b6b4f9e96e68b9f3e9733f9daf4b59dfc1abe38f4600a258203fce4d" + }, + { + "address": "5df376d285e564a7597e2be000f1e7a637bcef9373afc40cef0761dfffcd3972", + "content": "f29e97b0e316117d83dfdbabe258252c8796090a2b06213b459216b250fe" + }, + { + "address": "5e33fa6a580cc41ed67d84aed4bb5e3cf881715bfa9f2a2a836f84a13c3cf8d7", + "content": "69177582423c908d1f6e8caa4a7309d92fad2cd5c0d31ab85ab51a033b8d" + }, + { + "address": "5e90f1b6d7029d6c6dbcb0c20bd0431e739848151e8b924ec20c8cf3ae039a5a", + "content": "514af13f5b78f09dc3da5249ee341365ad64fa34f4c05477c0be05e2823c" + }, + { + "address": "5eab3fc19c9976e5cb8263470c4fa0160a8b318d056eaac8c2c229444ed13fca", + "content": "aef5a00f770105ac4a97d2c369c059e6690de61b635b7d92dcc40ad40db9" + }, + { + "address": "5ed9c17f43a86c10166fd547fcf3404ae956a864f22ca12e83767bd03fc39532", + "content": "4ef0abb3fc92464b82c22fb1f9bd785916e646076cfe362b0ed6d5a720a1" + }, + { + "address": "5f0576e800f9712599af0a01b47ab72e3412ed0f2f24e17306a5d755c40f0697", + "content": "fcc0049c89056a0fc2ff33ab43f0183b82f4ffcae28e22ca29e9bc21f502" + }, + { + "address": "5f2e225723216132dd6f179abe141e11c4544e1111286555828717f50a62ad69", + "content": "b0244edc85a91b53b10b7fb4dd1a55c6d888763f56dfc8b221eae9e980c9" + }, + { + "address": "5f7141dc92ef0de09e88fd82c37d6aa9b4de198e838815e280080d69120ac9ab", + "content": "ed58b99035e7e12202c6cf01208b90fb10d153c957bb9041ec773abf96d6" + }, + { + "address": "5f736b7c2b47a5aed3fc20bd2c55114c9cf2764a22ae692204854d18d17b58bb", + "content": "ad2a7dc5bea2b8a6c6cd2775fa217f7cc2bce01b2c41d92c6ef0cd365c28" + }, + { + "address": "5f87276416adbedf22e5c1ab95048a6e854b68c71ef9989ec8334e5c8ee2108c", + "content": "cf88a4eeadd8858c5c456e927af05d76ef7dfbf855b138875972b857ae29" + }, + { + "address": "6030f519027d233a99c28d386e9440d58b00ee7779f004ca7aa475b66ec72b44", + "content": "aef4a945b7f9d97717e944de21f21251dfac92e93345a303a187e8edfd6c" + }, + { + "address": "608b8f09d9eb6dfa7e6caaf11a400f5a105cd1cf7324294f5579a4c98d0a1121", + "content": "b35775567e1b9d64402e38505f657330d65362791ddec664e0de81dd2662" + }, + { + "address": "60e0ca92e1a381f0e701c4ec2a032e630b41161a17f3a50f9249bad69d65d5a0", + "content": "5b9ce1c138e3be5615d87b190497c7b6e70df85c0f0b62f9fb01cd0c261c" + }, + { + "address": "61942cc45f98881cb660d6d89a24c45ae54d040bd494c511e303c684eb9817bc", + "content": "a2a8d523a5c3f3134730cd2b61f7f82fac3b4cdd1e43995ec9750873656b" + }, + { + "address": "61baee3a134c91c2e2b1cd2b43c02f545b6e148157d20d9651afb0d3f10eb866", + "content": "381e128e04eec2f32b02fe659f01d42db309ced0410092c485963901ab31" + }, + { + "address": "61ea98291eeddb3cf80df2ebb3264410fb880da2b9df9d5191bcc5d8e87ce341", + "content": "f757510065553a303a1662686e2d3e075bf61f8f64dc8cee2b691054e418" + }, + { + "address": "6215e8038d9aed9186597d056ce4b1c2c8b05b342dc14835ecc8fd2d1a115caf", + "content": "133f6ad5c3e289ba1bd45d0293349d626bb89305fc24cb65da9954988a6e" + }, + { + "address": "6228328f7d80a290c77df27bc823253a1875ea26d1443ba7afdbdf36f4d686c2", + "content": "ea6d979e592ea5d356178652a5bb19bd85ac4b0864238a008e3479faa9ce" + }, + { + "address": "62571bd801bfc6ba67c8d435197ed814f9109f9d2e50a81a9e042b52c542fcd9", + "content": "9823ba811cfb5c52001a02a244aa5fae6e3b446a02dcdf25d0d86301387d" + }, + { + "address": "62a011677c63656ae04178aa2fdfdd6196cc2eb468ba922afb9e57df898a9456", + "content": "094402abef90317dabec82dc2de1f24bf2e61b04f91af262fc52d2eb505d" + }, + { + "address": "62df062820e41df5f9a071cfced60f680dcd931f6ae83d5ddecf4bfb39b9417d", + "content": "97be25c9f67d52b11f990e55f477d53122b1aca8ad522afacaa8b5855cea" + }, + { + "address": "62e55d40e9f01d97dd428ac7cea2fe6decb59e261892d471953c07ced7831c89", + "content": "2d519fc6ecbffb6550e51a210972bfc446cff7bcabc7477bf0b5b453c1cd" + }, + { + "address": "62ef2d162d8181d31c848af945707e51733da412ae06e37509c398dfb0761f0a", + "content": "8e538c87f3cb59c882bb8dad10ed6f70fbde6ace0c595560fecd8e490380" + }, + { + "address": "6388cf0796280bad2f042ee83f2c7064382e58d8731629ceaef80af03a2194a3", + "content": "35d42555733617fca20a58d146055dff9dd8ec488e4e3ff5dc1386722601" + }, + { + "address": "642a17114538bd2b8d1e46bf183475c9b39207c250740a05e139ebed9ec061b0", + "content": "bcaa869acde00040016539e5e9f6708f778a01a32a3785d723ead2d81a64" + }, + { + "address": "64603714e35b882085b3d1e03244f3387a8c8a2fec29a4809035c8ea1c98d353", + "content": "a117203363b990fe9d8be6f8ccfef70572b2a90840756ce52dd5f36e7d86" + }, + { + "address": "646982e3e87714532ade5579aaf13c1806c614a9b8a53673aa6047eb68c117f6", + "content": "33551e750dc2e8870819400c865b15ba76ea1bf9fa10f45d620f9aaabd39" + }, + { + "address": "6483759aadba0655a06d75c942c5e477b3a6f6f5d65410399b98c5521aab3a30", + "content": "fd5393816343bf830c0fd4f67c2e4ce123842a03d69807ed1602e7143205" + }, + { + "address": "649eef7d5a99f107a27370464cb1f29622de9d9b314502c2b1ec0acb3ccb9d2d", + "content": "d35dcfa27831623d475e9016a0d0c101c89d714da97c64c493038fd9d6f1" + }, + { + "address": "64f00ddaeba5a13cfebda2048a87c865adb5fa6662a8412add5beaaaab7c41cf", + "content": "14a90b377af323bcc835f6f5f00309ccc251be9cf9054b6ce27c0e2f75d9" + }, + { + "address": "65f0d40e45e886592e9d34fc47d6a06e75250bfc7569ff7ab9d237a2b86fc553", + "content": "29986d745fe55722154ed82a3c638309aebe558a4681a456f84d3a8d1c90" + }, + { + "address": "66e5cecbe94b04a1ca7e553fe4756e7bc634a98fd7aef0b33cfc8f791f9e5a88", + "content": "e1be9df1c588df99bf7dd1654429a032041676990fdc6076def08cce7af0" + }, + { + "address": "66e99ae07554353f30e4dd9941e3ceae0e7a2deb90c88e6a11f8a1627f9a5000", + "content": "c7c903ff88514e1e16fb17c6f0900e152e6c43524184e3dda61e4831dca5" + }, + { + "address": "6709300019492d15fbe8673c6431f825a414da44921dcb215b3608c352bbec59", + "content": "54ee66df2c56525f9277265316499c0227581f52a20ab4d6fe78a96b87df" + }, + { + "address": "67103d59c7530e603d09a422f15797c432aa5713e3c75fa5cced27dbe087d704", + "content": "3359afbeab7de03125851262f5f986bc6d0a8f8b00191b10fd20f5c42034" + }, + { + "address": "6726da8526eab8412ef1a1b81ec900118940ba06238112796ce5796baca064ce", + "content": "5d9c66268a987d24dda19408b02c825d74ac4e4bce3de4dbf61af980f8ba" + }, + { + "address": "672f0d1d4d1c96f305343b3f2626ddced54b937d567487f5235f62765677e8da", + "content": "ad75e69a2138e52a3437ac58cf85ac61cc4b12782b50c801d947e6c7a614" + }, + { + "address": "683a5f3d5ed4df2e5958dcab1045b884515b33f5b852d963760b71ef97cec180", + "content": "ca5fef81fc70a30a046988641c77e89dd930b33901a0fe9c4d37b869339d" + }, + { + "address": "6843f97516eda5906e710cf1e67fa42d8b3726aa5173a96966a8a2deddd405dd", + "content": "a4a226d613c17196d7579cae08c3edd88dd463211e1a8577cbc1ea5677ea" + }, + { + "address": "687f77b861c62bf7a1eae9f50d8068236d670a25dc51c2309b76b4ad2e551ea3", + "content": "d58ff1cd4b47a0a1ded2521fa9123016d4545ef0f8ddeb086390aabb9e81" + }, + { + "address": "68ca3536952e9985ea60b4c45ed93ed8aa5fa9b1032607b8542f0fe3e5d49621", + "content": "a326a3b5383bbb33623032407f330c3e75a99df5874abe20e000f55f1964" + }, + { + "address": "68d0a7202ccc4983678abf6f6a0356a92815982a35fd4c2d77af905fd4979827", + "content": "4febd268faea14d73b657957600cfafe4deef4434a8fb2f60eaef997d685" + }, + { + "address": "6917eb79e0a63872b7297ac33623d4f58f9c475fcb0b3fe7ccefed7fa0f40cd4", + "content": "8219d363c8d641155380b5b3e8140b29fbc9c9b0e63d47fe644a7cd5bd65" + }, + { + "address": "69468f9a5540afb9529eaa1ed1a70239b8178b26ca53992aaa6b730e5cebaaa8", + "content": "baccc21dbe5b85e3b83ada3e2fe052d289ecc2ec05673b3703396dce30cf" + }, + { + "address": "69aa7d0960e2a162881173ece7d3845e27c08d77eb41cbf62224f3ab2170eb0d", + "content": "759e794b9dd47eca8b01f780cce2c1f40d94c21390ced7f35b954da151a2" + }, + { + "address": "69b11172b49f98251be7de892f37641bf11588d12c735fa1a4f27043f229b0a2", + "content": "2c95c0504f47811ece0b64cd1e1c5edf0122e494546c626fdcb1e6ca8842" + }, + { + "address": "69d7b9407e4e5a31fbf75af2553b2199311a0cfffe30d31071e5643ae63795be", + "content": "aee4ad55312ec646cf144ba091f414a1ab23a47435fd8bedad53343e43df" + }, + { + "address": "69d823ac8e01b83560808d3a7b9785a98dc3d563666a5cabc7e751e233251172", + "content": "3aa75a66f2176d882e30860a6596680a8b3b6a33af59a2edf7928e3c9f9b" + }, + { + "address": "6a78acc508b14b62c1f212d954a2ffde52500904c585dba2ddb385d310d1b658", + "content": "403ccec020aac5b7937658092e65563634d5f35cbc04e42928232428ffd1" + }, + { + "address": "6a7d7062897d40f33f6f5d83aad4bda583b60fe15ea111a37ea193bb112c1bf9", + "content": "8bfb0701e6995cf87d30e728ad3caaaed47620429859b01f72bd19af19d9" + }, + { + "address": "6ae53b7b17b56a859cc63915745fdd1e258e81235fdb53616d20e983ecf607cd", + "content": "637ffb8ed85bc73b4cf3bced0fe43606fced70d7fe57089682003af89ac9" + }, + { + "address": "6b070c88ac4da16699353c5284e9d8404db45e2738af4abb50dc4f213884c01a", + "content": "c82a132789e655d36df31fca99fe450faf59e8d72f753d351b0559f41b55" + }, + { + "address": "6b08b572a4f0b682edbb62193eab6f3832ace19157a0009453df4c795ce5eeeb", + "content": "e3754721a11667333644822131afbbc69e05f9cd142e2181215e79b1769b" + }, + { + "address": "6b0add71bf90302c2c623e8b8d4377b387d2148db940e24f17a2d1a708f11720", + "content": "00b0de859506797f85b8216f718099a7db3bb9889ea8f9408e8064891c" + }, + { + "address": "6b35c1398d0b51ffb27bcb85b4b9b16cffab3b27f2ade28c76e13d3eda5f7af3", + "content": "fa62e9668b8300a669f7661f7bd194ebe06d7e3089c09bc2844fc5b7e679" + }, + { + "address": "6bc58bcf08aa9c780c7265777366d6f19a7cff8bcb81878e95437931e1e9a951", + "content": "d5c71cfdd97e7e862d528ad140dcd6cf0d3ca2deda3967f68c26ae44d5ef" + }, + { + "address": "6bc65d87d3e8acfd32b323515d970b5e47158704dc39b1d5804d1030fbe84243", + "content": "c43832bbbf4e2eb23527a5b610b92e02e0427e2e1f997f6e5d81d9bca0b9" + }, + { + "address": "6be71d23275b6fc6d5c7892e7f2c8ce0da7ad47ff2af0d89a09435cb32394ac3", + "content": "65e6eff5506ac3b7038e59c797dbcacf262ec4ef54a0320e12fdb9b28533" + }, + { + "address": "6c03a2c9ff0e089b6e76aab7fc9ddf578a4d09b5d965351f9c61a2c06f437feb", + "content": "262bb43af43738f3c487810aa0383866b3738422fa99e3043d59d88e1580" + }, + { + "address": "6c2ab34f575c6b1a7871be6aa89a0d548767fea85420b0892ed3b50b8e1fa7af", + "content": "b1b1043aa26054f8e3ba2bbc969e490eeefb9767798c3f9dce022d64930d" + }, + { + "address": "6c7cc44a173d8b8679142d5f1a11873d7a4cf701fe45344ef476541ce861e8fe", + "content": "7e147d46355a7d5d6a3e6daa92020dd733ed3eef65a8c29532de307bb171" + }, + { + "address": "6c8a206f00fec21323c856347f8644e4b5c4839eeb3e60ac65b0216173c6e527", + "content": "0514b85c9b61daf12dd55ed565f6e2e2ba6a07bab2e12f7df8f8c197eee3" + }, + { + "address": "6ca1ca2db69d3569e2d0409d64d90304805ec0c0187b7e4dc192407ac4dbc8f9", + "content": "9978e5ba92b24fc8424d42820d11969fb6502ff161e55e04743f9a1b4255" + }, + { + "address": "6cbd9148eb179175078e3c516e5ddcd3c61beb81c038eeab338f6e3d5eff43ca", + "content": "97fd3cc1912ea9117f1487c340d1f3e1296b7745fdf8bf246a437d861642" + }, + { + "address": "6ccac522ffe71fa315b8979ecf663918dd9f5717b85866d4ce9f04843e4ab820", + "content": "45fa8c4c2b953c2878239e29497d86ed02ffd9ebb5923add829012a72070" + }, + { + "address": "6d131f879e403c91a1d5c4d30e4914d24213a7c0e95ffdaa8f5eb825b1301e8d", + "content": "b75b40488499c4bb7963ca6c0b2e2ad373959d7c70cfb29175d905a44f22" + }, + { + "address": "6d43a4fb3d280b28806511a42ce704b2c26e9363d07d660555dc0dc0bf613693", + "content": "bc003ff477ed528c444c59740f7fe28836e6365d5d5e6259050b0caad1dd" + }, + { + "address": "6d8cfda7735ec255c55f776ed6d347aab76b1e03a9eb04996281140a3eb04308", + "content": "2a3ff7c6aa01014dbb10630933a5e6080bf826520bd34b6934b881fda98e" + }, + { + "address": "6e0e11c968f8700716571fecc2c936b115fe255349262e453dc68dfc89d51455", + "content": "120c6ef3afdcbdf6e41a620092d8eaafd03d0f534cfe875ffa5fa4f4e24c" + }, + { + "address": "6ea9a094d65fe267247bf8a18d8486fb8f9ae060dfbdd41b9012f3b0bb3e2001", + "content": "19f060ae0b83ffd79c48d1e8c69ef5d8e9e2e2481a3534517364f710c5af" + }, + { + "address": "6ece745ad6e0949b276516024c81412f3d3c77d0a7a9f0aab8b522e766cca155", + "content": "9dad797dcfad1d0aa0b73283972241a84b3b0428d7ae36f8c7fdae028473" + }, + { + "address": "6eee1e3ad24e982c8f23f280b4186ef7f49614ffc8e8eb73e2b7f8d1fdc29906", + "content": "c545b5a1a4128a30787fc2b0c7c425afc4cbe7ab9da5aa49052f543d64e2" + }, + { + "address": "6fae0aeac48fae1fc8cf881f89497103347e4b4d3dba7179fc72890225bd42b5", + "content": "91c06d7a3248da246e71f861f6dc8170caf006f829797d71b802f8984c66" + }, + { + "address": "707f66130dd45f81b6c3c4b950814cb5830cb77b3000579779cd0843240e38b2", + "content": "a1e69602b1e75b1f7b895305b5f8d889804471d88fd554448f22d0232df2" + }, + { + "address": "70b81222a944088a8d780ea5baf7cbf3a571dfec7c22f434fd4e1966057b8824", + "content": "7ffca34cd0df480362694234f89e8812d506a5bcec045d73b0d1e6ae3597" + }, + { + "address": "71a814aa52628a8d9801bb7328815416a066f657091e88cdf3499c2288bc9aed", + "content": "ca85f41e458d3beb9ee45711ceddda07d261bfe580c43cdf912f8047f405" + }, + { + "address": "71d369a35e1dc8aaee3dd9169b5f43b388827fde933f81330b7d2be797de3c07", + "content": "196b0a16673248ea4a30572f854c5e362457813bee3ad269532dc99f5039" + }, + { + "address": "71d7a5e1d2d5e6b0120c35eaa992208ba0fc2d0c1986d9470f6103e2da6682ea", + "content": "6560c880df322b45fbee3877656aaee842bfca136933c03ce4258b4e1edb" + }, + { + "address": "7230654f429a8536d6be9c05a56b87447fc417837ed8602a5c0c09a0cac28451", + "content": "f8f56e75b876bdc93e5c092acb0852b3d57c6f2b5041ef24b9cc56fc91ec" + }, + { + "address": "727fab711635cd89ac69ef5e9af21f910e2a1c528b96e11b2f4cdc9722891e36", + "content": "927417250eb74d58f87e47eff6ba82b9bdb4248c14617074a0e160156914" + }, + { + "address": "72e5560cd1216afa4bb28be8a0804e5fc745ddc80754b8f48e8efb7bf86eaadd", + "content": "c41bf45c807ce7864e06ceb67c1cf5e1460045fd34b1afa4f0068017ef2e" + }, + { + "address": "738b9832d7fd8e13179c40b44b8b4ee531a0b6428897d0490dc5a210c4868625", + "content": "d9bee35178209bbff9cd52fbcca621d2e6caa4a1350b95fc121090619b4c" + }, + { + "address": "739330c11ff1beb4f09fe50e4d2f54ac0e30e7d61785408aa720e3504a0a824f", + "content": "9d9b10ac488ce69f651660594cad04c5e9b807060e83009b9d5d71316400" + }, + { + "address": "740cc8e58397faa8040d2af17c0f685feb38d9926f0e99f78f568f91ea4053c8", + "content": "6490f8759239f1bfa8c0e98583978a0e44e8cdb51fbdf7f28c6bb30a28bd" + }, + { + "address": "7448dd6ab667fbe50116e7b5c5fda5e4e0fed7da50bf189a92f7abcf1f156b9a", + "content": "6e8c1f39b26d83190452c182884961b911edceff5c238a7efc53fc689353" + }, + { + "address": "748b83faed1026fc0d491a7802e055705278c65bbaec1a7d467dd5d192934dd6", + "content": "ba6d7fd8d2f8e2bf09cd2f62e50a1b153cfefa27dd5cf2aafa2a9a57c72c" + }, + { + "address": "7494e8681a42fb67490d80a13c3b56876bd44fa8f3318c31aa026d3a5a2e4c42", + "content": "860e9a4f5164df302b0e29b3e8fb2beaa6922078f7fd02b34a08b5f208d4" + }, + { + "address": "7542f0ca47f26da87641f97007e9169da1004b334c1560355079f4a7d19fe822", + "content": "0472844a4522260c431735b6be44adf6e1351bf903b51be007443aac36f1" + }, + { + "address": "7588b28661db6578b78cdcef8765dc0ae58b8844356532ebb21ed82ae1a4e9bb", + "content": "ea231ffb6e6007669ff0765c8ccb730a82a27b6079b6bec0492568f9a16e" + }, + { + "address": "75d3e3bf08a19d84f5f8ed47a09b39b4b1b8e54e40a6d04a6307f970ef820c78", + "content": "90b39038d39a1ec863080b07129527b141ca15fc9c98f9d75be909c021ff" + }, + { + "address": "75d4084409136f213b7344998552ecfc0d5a43af88881b8a5b4383af92881916", + "content": "6be71722744ae75aeeccbac7f69f8fc783cccddf7cfe7ac287c1079e33c3" + }, + { + "address": "76062e7c849e562d919ed130cf090fb2084d565566176a34b04ef555d1e07e4a", + "content": "aa29197981bc44a8ae41d840a4ea298c2b6fbcb79764e1bae53dc2b557d3" + }, + { + "address": "766dcbd448b803e8f263facadbd873366a4eacf26750f2b6ad90caea551ba8f4", + "content": "bea7222efa6deceb767486a1e24af2491d48d181f78bdafafe21f4b68bf2" + }, + { + "address": "76a39696cb58147dc6547a06a91bd2ca209c63cf1f58abbea30ea4b3761b5de3", + "content": "c875d566d21e65c1d8c7f8d7fa383f7c85f2b86e5ed218493a30954c187c" + }, + { + "address": "76ab1d7ff685e1a587e9c728c76595d85d278413d32f83583b3a3317265056b3", + "content": "8206234651d66af13387aee693bf33d313a035c3dd7979089981b89ddf98" + }, + { + "address": "76fd01b9d5975a2a83068e2a46b2d2bf659a5caf867a9dadedc5a10f87924e10", + "content": "9a995f4a3f06114689d6677e5e9aa088055e15e2b0aac072206cf5387e06" + }, + { + "address": "7775388ae9318908633cc53518ca477be1d991347e71a3eb3304ac59aa53f766", + "content": "f9cec8ab78392e78228805e0d850390dfdef85b1b98339a4f0c00da95e3b" + }, + { + "address": "7782b68d00455874bc2781bc817428d579cd9d8822b6deba9b9e94e516c61842", + "content": "0b8dc2b6345d189d15ccd9bcbaef25cf9917daabc3790963d64ae10a60ff" + }, + { + "address": "778edf71062bbfcd45b1b394923ca48dcd44cb47449764d7a644ab94db1d313a", + "content": "381fdae38b6ed7e1ff01365da42cf8b4bb2894cf0ec59a9460377b034c88" + }, + { + "address": "77c1083a7e21f45ad7893971a0e785bfa1a17a6688b364ec2c41a26d9f28e8cc", + "content": "480a9c4eb27ee823cbc7992369bc9442a199ccc826ee4fb895d017aca5e4" + }, + { + "address": "77d35676eb71f106f2cf1e61ba0c23eef5503a32b164ac792905786665ce88f9", + "content": "7cf4113fca8badcb9bd07e9742924c78b6584b614743b64af5a1863096d0" + }, + { + "address": "7844e2d1cceb255002b7220f97fb039656ec45060146098eb1389860b83556e4", + "content": "ca7a38838796ad6868f33ac3f5356f6c9f1287e629cf33b6c41477d62ad3" + }, + { + "address": "78d9f54331d73f637f3778b7429d9d4b04cb2e694a9d52d31e0540a03920f0b7", + "content": "d5c17c4dff0731fc0777d6652b814acb11907151a1652b16d86cb5c86d39" + }, + { + "address": "79388c2450946a7bf4bead6aba4a8d96192d6a69577f85956e3ae86193030d2c", + "content": "d0845015a68e6865f682545e645e1e35ff3d4baa0c40ceea6bf9436e03ce" + }, + { + "address": "79c66e14221a5b8000e3f4bb52f76e33c7040b80d88ee57e9a7dd120f09a4437", + "content": "47f1baed9e3a3d363bba1da857a4ad1112b1f9722901a0615187b7da5a89" + }, + { + "address": "7aefbd93f50e8031117542f6100784ab993376b026c431f944b678c7403e37f6", + "content": "cb5d7ca31cacf52327fb864dc702cc76199b3df1f15ccdf22942c811e5ae" + }, + { + "address": "7b1b54f0d3210535637c61d3dd62ae8119ae7ce600a5f94db6a2b1baa6437d39", + "content": "65c94051cc3acf82cd81d51a26ae11a33fc69fd29fb9f1b326b141f567d9" + }, + { + "address": "7b4775d5fd1029a8cc16914bfddb1e2e1d35cfd92db8359f0ae65618de55823f", + "content": "06a978a80856de6c54f196f996eb3f4446162e17db7d6771b845e3cb82cd" + }, + { + "address": "7b4facb643c28c67b632a5cf51bfcb54ca451b618ac100a04aeb018cf30bd781", + "content": "af672dcd4766d13c0d60a2ad9af845aeed4b20364f92675cc0d5242d1d5b" + }, + { + "address": "7bbb7145b4c225532cddbd2a1527597caa30933dde2f185244bb6d68aba5f1ed", + "content": "3336103e94ea7836ca615fe87ccf7a21b58df417049e13dd799a116faa1a" + }, + { + "address": "7bd1e180bb2d8c36081240595619b96d160d98a9da4898dfb6e36f4be063f2b5", + "content": "fd8c29bc180555574ead3fcc6f312dc5142734d9d21f7e66abc17c20ebb1" + }, + { + "address": "7be113006ba5bd462c8aa418fa4035f7b3fcfc5812b47d38ff1a22e674bfaaa0", + "content": "aee472d328ce54339f2bb045c038d8a45d76259d3a73b5397bf93873044c" + }, + { + "address": "7c20b9143cfa26ea28b20718007236f4d223029e0131c0b41bcdd99eb7c7fc4a", + "content": "2fdf7dc520458855f4d9fd0f7a1666754f952dd0d868802b18259c6fc245" + }, + { + "address": "7c29d3dcd7e0ae178d32f36f9ffba386d11a8e33b7fa19874b5a33b5e955d898", + "content": "072814734cd1e89772af4d5ba3331723f2b133a24e85fe5aa4e036e04d24" + }, + { + "address": "7c523f03a6aabdea3f77a3e29dccd5c1928185f274bb3724e61118264e06beca", + "content": "d0ecc101fca3c6651b4bfd340d3416264bd872d2e9c33707200966452dec" + }, + { + "address": "7cc0e55f0f5c99e8e04a40d8becab35d23c31f7bac00593fe6cca572a84d0076", + "content": "41684ee6d416988001b9b59ef47ac05bb324ccb7f328aecdc415e197381c" + }, + { + "address": "7d8b8a3e687de47d3cc5b1f9a4072ce0cb6a95063b1924808c08c98dff101f35", + "content": "2970eed1b0b8b20f6bb5248f0bec0a4bd219dcdd16b200041b03fbb3bca4" + }, + { + "address": "7dfb35158c71796d8fe96ff6f2c7a73999de11b89ff673671552e256f55b07be", + "content": "f1f7ace32c9a4286842dae4715f72f4eb86eae1af03d53a82328bcf08d0d" + }, + { + "address": "7e56954cf2e81c1d568380161f0770b4971006db7cee06bbd65abad72ecea03b", + "content": "7a08c8c5eea51b3edeef179a949244351e112abc6a2d5df3daee7259b508" + }, + { + "address": "7eb0aacf51da3d5da03989bc31412ba0fe28aee31f88b61bbbd2566567ffa538", + "content": "e67dc0e663ad82a45f7ef77f049a0a3b88430f37cb72343bbbb3d2867b8e" + }, + { + "address": "7ebab8408feb9d1c7432cfb851ec993a739db277f113a0e2df659dfaaee0579a", + "content": "92838ebec1d892551bad7e9b9b3faf3a3ba44b2fcb5c42d361b76f214a77" + }, + { + "address": "7f0d223aa875a92a861cc28d1938efefa0af00dcdaabaf883eeff83fe2d6a0ad", + "content": "091bdb2aece5ccb5a2ea7b89a2c584197e38bf3391e32a657dfb45e1cd7e" + }, + { + "address": "7f17860bed98f44604c4c5605c86b874e3544e23590ffa86030a76b7a025479c", + "content": "e2fdac7456e8f559dc9508f349b454decc7efdf720e7aa9e32c2606d984a" + }, + { + "address": "7f1deeed3e115379142191314e376bc98a9b9cd95d45838b2e7fe1d263efbd14", + "content": "b44509ee43dad6e5ee3ed376fb23a803190932e1e7c274e99a2f39142d7d" + }, + { + "address": "7f3d760e33eca306998d486846bc821e5d32611e773729efa286edb8a3134834", + "content": "24837aa0adc97420c4b5a7c4170d918dad264219319d3941a98a7dada494" + }, + { + "address": "7f536eb03907a1b66d1417d7d5567971cd862bc5f08cefbdd1e72557d257dbc2", + "content": "37a37c705cc54ee6bcac28be9fdea0ae4be28725b93b013c9a6f41856387" + }, + { + "address": "7f9b7833f7aa773ef45564c09f895ff1892117156ee18207798c458e60e96409", + "content": "36733f50a3d5bfdf55a33153cfb985937b815f27ac9540283c3a811246ad" + }, + { + "address": "7ff5dac290c229c77439292a78f68355d7401a5241f6fb63fa6ab4c88c77c8d4", + "content": "530ccf5bb90ecefc29207007f2c17e4afa192ebfffb0990bfd36006158c4" + }, + { + "address": "7ff75ffb8e4e8f4a5d7299b6624345cc3da3dba0c031813bb03aee4aca94dec7", + "content": "4a8dd9c18ed3895e3d096bf3f63fec14b8807d806218e8ac7e4a18768439" + }, + { + "address": "80043ccefbca8c780a70cc08df7ad4c07397670c1a6a3645cb2ca3b4da44840c", + "content": "719bf6295dcb82787407084cae53085378e3c39078378870116cb5b2fa66" + }, + { + "address": "80439f0ecfb54c498644a5d2305f5f1c28b71437d2ef0e1018bdae2332b2bf68", + "content": "4a4c45ad93f0a4b648df05e3d4779144205b4736f80c2de8c8fa71e47f1a" + }, + { + "address": "806d7b2a1bbbe42bc79d5989ab5bcf3a46bf19e2b6fac98adaeaf2ba4a7f933c", + "content": "25002744ea4a5622ee243e72dba5d2f9ddcaa2a47b2e7dd067e6a95dd609" + }, + { + "address": "80b9c0e269522a39fabc311106b6514fd69298ec1e74383da96ea7fc47ce6bbc", + "content": "fd0948fc55c394a3b4164e5db3481da6d894ffa646106f197e5aea40b29c" + }, + { + "address": "80e1d2a28af04ced2b8b137eb250271c6dadb7153b2cfd610cf1ff361ee12890", + "content": "dedf3e3482cedc04d7a6465790bf6d71d3fd1d5c406ee2b1810e4c21f2d1" + }, + { + "address": "80e884fc0cef96b4c80ebe27d5643b46e24e25a075efd39311489cb1e0d6101b", + "content": "0dcacc584d2a605b7cb8fd9c46f04e5f75a554ed6735b1fa00a212f95b18" + }, + { + "address": "80ef44cb40344ace4d640d51a92b845338c8281c7acc9d5cf415b4683e69cd0d", + "content": "45d6cd83f1a2be0ab456efc3d6ab54fa937bfa2d53129be86dd3a7a58654" + }, + { + "address": "811f97b958422b23d980fa82f7c2a15a88a0702fe512f94f45ff6ca2db451dd7", + "content": "204eccbc36d1c41005cc70d51363c1cdbefa1d5a09efa83e35738c40bab0" + }, + { + "address": "81e2e86c71b010bd41c3e6c1e2c69188eda404fde5f15a741f5853029d189c7a", + "content": "8451cc9694904794d093bc4223b0d018597f1a1c4cf4f3c25878876c186d" + }, + { + "address": "82c1d2f7332361109deb3258a39ce54bc10c53f5ce803efda589bc590353e4c8", + "content": "43cbb164caf8cbcb15ee343924e3334ad3ed96806628563e32e283921047" + }, + { + "address": "839c2abc6df2c68186abdfdbec3a72200642902306878cc513057dbf650cd155", + "content": "1ddcfd118c21bca75dccd7d808b0aec94500aa3613306e17d700e5f91205" + }, + { + "address": "843d7f3eebf9213e7a21661cc592f26151db7b8da27a750a8684d2430e639394", + "content": "03dd830e0d95276ba6ba82b46c042945d88dd7c3e3f353a1c23500d58e04" + }, + { + "address": "84655980e14e61dcecfe42e07462963d4b5391c46b642350e51dcf922154f772", + "content": "b1652d91660d23dbb9e2bcd0b62d2b8090cc9cc9af6cbf94b81691deb98a" + }, + { + "address": "858395411889c7ad68d1ab33d4b65a0f933fb02a7d2294d6a1e7685d9705d309", + "content": "1a46bd77c7949f46eb502595a12735143fdb85449f53b83b89b837b910f9" + }, + { + "address": "858ceb3d4e162711197f829d172e6e3f4c5890cb52a8bb009507d16e2ab3144b", + "content": "9eca7c18aadb07dba3dc4befaa6aa3380ef954bf9db2549b97ea9ceda8c2" + }, + { + "address": "8613dc68b661c2486d6913eccfcbf2c0675f11d2da30cfb684b0da494e89855e", + "content": "685e8d800f2f8a4e2a6ef8a7054170ee37e2954fbe2756eb7ddf01b4417c" + }, + { + "address": "86154efb7df14e95818404c0239f2a41059f7a0c8e5e60b5f46d1f48f64567b9", + "content": "126dc3083acefcf79c8ed76afc3e4f4ffb5d83b337dbd6ca36f9e23b7b3e" + }, + { + "address": "865697b9de9c177d3eedc7438e89aa2f34b038d754ef7354d51c2128f8bfd81c", + "content": "96aa7b472cdb24163bb5f550c664687c54829f44a8db02b6eb3e4954a2cb" + }, + { + "address": "865d7a6638cd37d3422040019378abe7e88c950b30d7df5e2b917d4de18650fd", + "content": "e4ebf6d10c30f44290040311f6e55fcbcb404e8503c8cf9bc00673311cc0" + }, + { + "address": "866933e848c1db3970e855e7acfa1fcafdccc3a70a91f0b80f8e95fa13c83f3c", + "content": "488b6581d9530f3985677cef619340c12826cced0ed349eac8476e8e4586" + }, + { + "address": "86b379771e6229181afb3aa59ac7b527b00016dbb50ebdbcf9e85f843208c26b", + "content": "02a0272803b06babdf24f92b8a881ea39f3375f2a9fddb34c2e14046989a" + }, + { + "address": "86bc2cf0cad420b26bbf3b194081f56127e2c5b64708dbf5859376ee567d2144", + "content": "a5e4324ead9d7e21119045c43ee2ce6e72aa368c16f02c5f0747b2fe6d3f" + }, + { + "address": "87b9d3ad66b081d157740901c6af7f985d6836b0f96c5b01605a2a261c6a78ec", + "content": "099528392ed207ba71e68faa95c416de02c22eb7e6127fcabc8c30c314d2" + }, + { + "address": "87da85c4716cd65ef2cfec4fc7c770302e4a413ee41a87c7ea902e981ee3f6d9", + "content": "e5792c6b967e2f25e64cbc85a2bc9a1376d7028c35beb59efa21d297be85" + }, + { + "address": "882f0c4a1433528542a5d302d2d93fe214d5cf7802830bcc1c687de4e151b26b", + "content": "c4d28b4e43447824813d98ad0e898284471f8a94dadd714d6e6740416add" + }, + { + "address": "884792afe996334fedf20b63a641f00914605b3d0af23970de3112f85184002f", + "content": "cdd4e9e2526c3662155f5b59d162e54697e29d88c9691982ed509176aba9" + }, + { + "address": "886fffdd39bcdee196081da7f750768120c4e22077001cfe15e7e1b90b2f41d1", + "content": "4aa3b122e55911c7ebb268740c7771cc004e4be6da2f2467f3516b88c4ef" + }, + { + "address": "88b0377355083e824fdf8545c89190c6f86e0cbc960f53256425d1013e634046", + "content": "53e2c97e8d91fd1527ef1a7de17bae731ffd13e751f50b5f453d0427bfba" + }, + { + "address": "88c8498d55c60b9bb5a6a13102c59e8aace62cbf54ef65cadae3199c7e7133d3", + "content": "75e2866a12bf20be6e1e8bb0f2701031a114f02a987f88bcbc42c7c112d4" + }, + { + "address": "88d476c084884fb2e31611e4fbd456e795a4e222f5ab234cddf58976f7ef64cb", + "content": "df824b7e8a27eacf347ee1004e2355f8fe4c6e5a5fd363f9fd9d616dd033" + }, + { + "address": "88db59cdd20ac84d365a5108bd2924ba6d9504f7a0d0f569772c4a74b94c3d84", + "content": "798e56b40b60f4e16bee5431913b9fc299911e36b775a307995cdce6b9ad" + }, + { + "address": "88f2dc4a93c7dc63895b68dda58f254e2597550a4dfe46fcc5b841e0b41ac22e", + "content": "e0ec63af5950d5ec039b108720fb358daf4152f9a4bfb6e519136aaef323" + }, + { + "address": "890adf000c75c2408f1e3cc07bde8c709a3440fec3c8a267ea9b43aaff0eba82", + "content": "13a50e7bf09799717f988d819eac87adfb264ad8fa889829dd205f9d5fc4" + }, + { + "address": "896922188aab113a6d916683f02d7e4377c4802f5c9a0b9872564014f0a9d521", + "content": "2d7ac3cc697a168bc014316396abb2fac9cc68c927a700e316f080f07d50" + }, + { + "address": "8971ce20a65901867190a7be49e49075e83de5a15d86ee4c8cc96cb7a6937af6", + "content": "2bd37b63c14acad6632ebbca18ca290b50ae6aa20f4b65f5318bc444c610" + }, + { + "address": "89e250fe2f5e707b3672b0cb0cbe972ef7cf5dc553168c301faa8e287cfff88f", + "content": "6b86ab990b467f0997dd5f26cdfb4f029d984969d71de29db93744a1de08" + }, + { + "address": "89e4a0d12bd70fd92aa7f07362d8bf8822fe768ac3604c3c1ac7cfe169abd2a5", + "content": "91b5835adf2060f3ed416b4801ad32fe7c63c0c23b714d514ce95f532759" + }, + { + "address": "8a17fd5c9a55ce777baa489b2ccd62c8926e72fcfed5b78ea58cdb32bb229e03", + "content": "5072c5fe1c612adecd65e0528cb38a826d81dafe4900a8bf72321c341314" + }, + { + "address": "8a379a0bbf3e6a0a062a013159bb95b50936a16e4b8ef5db1f99777603fcdedf", + "content": "539b8588400769c50d31b36214fb49f70d6e2baff79d81091da35f180218" + }, + { + "address": "8a59c278bd869b747a427bb2ac8d9a39bb73706ece9628df3fa10cb0d80ca2c3", + "content": "ef8f4f6bfcad6cbf2aa1caf03f5fd248b517cccac1c8e9405180f9c836a4" + }, + { + "address": "8ac06741fd4a9fe1f3c885c0a387e142d7b14c629dca7bcd6e4e9b2ca3729126", + "content": "37f0d270f3d8b9c95e6c0520ed9c713a5b417e4cfc02e4c465aba31c3e73" + }, + { + "address": "8b0299009474ec49463078019d59df057625ef55ec05772d58c55f9628d61de6", + "content": "233c009b9467be53073ef86c4c7d7047f676bd9a6cacefab1ec418bbe1cf" + }, + { + "address": "8b0487789332810a243f6c27a6a46eafcb15e7bd83eed2db35d7d631382276e2", + "content": "38cc9f96179edf3a6742b10540648718b41be509e2ba39d094f59c67d94d" + }, + { + "address": "8bbba83ab051c3eba57f31e6945c97fc893774944a11d4e1802357ad3f7f1857", + "content": "e19b44e297ccaa89a8ff34607f1dfc031e9b141956b07b66f968e2fa4849" + }, + { + "address": "8bca35ba511afc0d807a7f5b3d054a5e16316ed40cc676fcf5c61d7283de3a90", + "content": "dc148b1b078473c5518809b8cb312e6c30cf6211a040d39204813a8f2c4f" + }, + { + "address": "8bf64fbbe948d0a8997b587bb0af8b419cd74e5cea32983f3cc544c2348e6a0d", + "content": "163d7b1b2cdc4d2e400a31b38233ec3a1a4185d0aa4cedce26788a45ac7f" + }, + { + "address": "8bf6675451036ead99a9e540fd64939c03d393d877ba93ed4618009501f004b4", + "content": "f354448fab04376ffb3cec14b11e54a4e834b43d7193bcc115b209feff06" + }, + { + "address": "8c489e0737a1a19e5f0e3f829d48521566a75eccd652ebd35d0b4193aa9919dc", + "content": "b817bc2f8829028aa46f8a28e1cb65f1ddf2b9c3fe07ff9cea30fc961b01" + }, + { + "address": "8c56c759b20542daea57ac21a9e1cdc8ab2ab998f061ddac081ded921c2a0d54", + "content": "ed25b62f05c844ae4f587e0d04985bb07e05fc7428166e57e5fc924f9d78" + }, + { + "address": "8c8fb134cab0d402ba8da2ec7a74b6bba4c220581c2fe99fc2de1dc77e9373ac", + "content": "ee4ed99578ffb7fe4726f1db58f26efdd0325d6b5639e8913af62b2788b8" + }, + { + "address": "8ca35708635fec0a6328866f3d93992f2979bc505eefc80221c57ad3556a5199", + "content": "e12f2ccbbc3fc14b4f9631ed1ad5afcdaf46c2a3ad5e9214c468dc0a4f3d" + }, + { + "address": "8ca81b3781e341b5828c277817d4f9081cf043937a3188153a64af9212ff89c3", + "content": "50e830df87d9ca43a63b15ed851be5af31367756e6c0d391edc8a6394510" + }, + { + "address": "8cc021e5ade0bff06ca1cebabe9c98737e2f0547fdbd3a6da67884f397404e6b", + "content": "8b2b336634d972dbb65991fc27e87655587f0012e93fff5aff17d3a6cc8b" + }, + { + "address": "8d964b2a16678eaba662071dd0a1ce476a1b3350abfe0c624fdde5ce75a08cea", + "content": "5e3c4f3188202313c4de9569c1734a8a55ba00be13dcd019fa7b1824bb74" + }, + { + "address": "8e583a4564822d651be0c3eb3ff3d0bcdc20eeb671d3dd30073d5ad2fbcd3c3b", + "content": "b2aba459d35ee56aec19a38f4379f1be1cd987757070711313daf650136f" + }, + { + "address": "8e9b3d689c5f917e08b6cfbe733c2436a5eb18f2f332ae0ee84f2110baa748e9", + "content": "c80f6b979cc6fc3fd1f924b1b81facef20c17bc2083b2356dfd5174e590e" + }, + { + "address": "8eeb3d3860c51499c494458a32445802e6556ce72b9074ebaeb56d608dca19c4", + "content": "d91940d10fa641957ca0fe3bee50f5705258a75183f1dba52e957c9b0fde" + }, + { + "address": "8eec16b5680eca196d1b34a7851aa73b840534d5245739099adf81c0c313447e", + "content": "e03012fc62005cc4b515968120f491a9bba1ff6cc4067b492708d9b9f82f" + }, + { + "address": "8f5dcead7f6e00f7f90af837483ecc395d2ca84b2fb530ceb2c9fcb2cf3913b2", + "content": "4b714e77b567991dc79ad8a5a1bc50bef2db58221984141906fc39fc8254" + }, + { + "address": "8f71a29dfc31af3a4f2ed63bcbb522e894929104044619babbf0fa50f2cbaa98", + "content": "78ad9ba91ef1bf8cd3d2750c251ca8f6bc5f245115c23add0a4f2c13db52" + }, + { + "address": "8fa28cdceb7307d796e510ea5c28b749694353d245f6cbc618198dc2f673eab5", + "content": "409033c22824b1e0f99f408eec2d257a938aa009a0a755c3feb3fdebbaa8" + }, + { + "address": "8fc8521e24138209165097dfd7c91b36383d23b756628d81cfffe6de3696ef83", + "content": "c0c41ae6c09bf6aaa2fbf1f1da1aa45568b56d68df6b5fc8adca6bf74000" + }, + { + "address": "903acd1569ff3ad5d852b04bdec8923c7febaabf1a7fab312c8ff4c14072ac53", + "content": "c90a5dd79b6b3a5c207d0c7f8e5145c72cf3a466f173f157bad105a2cea6" + }, + { + "address": "9040dc4648b7cfa138349f4647840735d473072930a7c7bc8a9609b91771f0b8", + "content": "a46967d435ade7af2d6458679bf04ab153d4a208ef5e9e3a0bba7f3edf0b" + }, + { + "address": "906af32c9965170a6c6b665c393f1e78148297ecd5b58451ea709b4b2830ac30", + "content": "f8569ee3c361fc540ec9dadfb5e0290faa55c91360a6c6791a922442ab55" + }, + { + "address": "90763ef9aaee7ce68407c148bfea98302da13a3b2b56eab3d0ce34193213665a", + "content": "fe831b8c927bdaf580b809b6e65bb1e497fb7348be3b582707d3ea20e801" + }, + { + "address": "907db29715da609af0fdccbf857dc0378968af2693ce654f070d786b12fcad6b", + "content": "f010e81850204eb973a3816df34a18880c46c97ce4979291ecec69ca8d05" + }, + { + "address": "90942ab61442e01c1561f1de052971e427e451d23866a1306b535bfa673fff42", + "content": "df6f1209c887bb5a7eca31ef3f9bfa69cdc195d80c3483feb4b258fd5a91" + }, + { + "address": "90ae06a8da86558741cd3ce63f6e864f1b01dc4d5e06f0f3c7585bb021abe2d7", + "content": "4f4f5164d1872bf58d93ec0e3332fd4dc44b60378f75e66f2bae05a761c5" + }, + { + "address": "90beb30f57bb2bd3f8dc8633e4e9e3ac6d4cce3353b7b251b80fbe4595fc8cc1", + "content": "631442dfc3f96223ccf8d2394630d7efdab9020715c19ba0771399c638f1" + }, + { + "address": "90d0f4c275580d00f589d77e5e46d6b6da4b5b50964e2a642c6a1d860e24a5c0", + "content": "98575a90b596ef8553381c33dad3a194bbf24a9574d8091fb1af75673bfb" + }, + { + "address": "90f8ea80cd43a464dbbb6175159b17dc1dc275c53f883a816c797450b214a893", + "content": "25c04b3104716f20a19d9040a4897638f84d6734f8331a57bc10060e329b" + }, + { + "address": "91337e1c5feeb0a5d87f9a1661063fbf4bd37ea6fe0345a0b1f3a2f24c06b887", + "content": "fd429736ecdbd8aec77ad1dfd6156bc6a83a4564e0abbff6be0bade5ebc0" + }, + { + "address": "9141811ba7353a0b09a047373ec0dd207e299d1d778b5ee7d4b301f718c6a1d8", + "content": "722655129738607e6a67ae7416822c727dbf476d8f14fbeac1f943e878ad" + }, + { + "address": "917f5b384739ce12fd3b3751b4ea5577592bca3457e043e65dc11702f3d65a92", + "content": "338db6575f66fd202c3b2fa2761944223883683b86abd4dc9b70564bad4c" + }, + { + "address": "91dcc63a08b48ed7c65370090457269ac728cd64ab1c80053584e322876eee65", + "content": "c070ff26ff88e44fcccc7942f3c48d09e888dec1abb0243b7440fcaec773" + }, + { + "address": "925cb56b68b8f59ed3988f510be6223d6cfe3b8a50ecdb3c7695af608b2d0a8d", + "content": "dce1761440c2b2bce161427517d2504ae4f2ff187b651623316a4575cff9" + }, + { + "address": "9287c0d9d508be3221e204d39414701fea29601083bb9af5ee65ada5d952dd04", + "content": "c7c4812b42be5417f2e743d9807d30c3c3a2ce8ae4f39e902a2d26d605fb" + }, + { + "address": "92a1432d2cd675892db4321399e145178f400035c273fe3df499c994a48d2963", + "content": "709385bb107e19c08b0cb5c6837cbb42cdac7c84072a5ff1de674bcb03bd" + }, + { + "address": "92e819b29ae9ef192ea90fbf557da2a25f1673edd3eda866394ebd76b4f7e10b", + "content": "6a750c94659195a1d57ea7aa17d34d353d21235fd3195288031668e7a5bc" + }, + { + "address": "92f16a4b622932ab0dec44ae49f569d8a3bef84e5c9e39a646234f05683d3ac9", + "content": "41df7b5d742042aaa78822d2f635da6d4bd7a6b9ec8d82c5148fa215e5b3" + }, + { + "address": "92fbc90b6e431cc3b6ecf358b7f0f201a258c2becf31bf48a1db9bc6ec73a272", + "content": "3a80fac618c19d0cedd1b9a61d7c1542be046c250ee85adc2b4e5b7cb619" + }, + { + "address": "930a7416865eeabdea12d91cc9aa98a48511c15eeb038bb87d2195e718fb0506", + "content": "ef916b75b3ba996b5698dd09025b04e2d6d2626a2c79ae748bc178d3c85f" + }, + { + "address": "9335277b76a3f8796500eba636da5d3b8c134b38de9cc957b83e7d11d29c8b6c", + "content": "75b172b3a96bfd297cad7998032133dd1d0c2ccec5394eefc89432b6f4d5" + }, + { + "address": "93c00a2293cf039d3284c4cd9c314fbaeeee2d23998f94e0cd19288919ed047e", + "content": "6268e08586db068294883e6d169453ca6bba0b25fb318af0355e8e0b18d8" + }, + { + "address": "9564bf6da1a2f762ffd2c028db58b8d80e1266a720f9f5706f02a2b66467804c", + "content": "ba471387d5a0b78fac0e134d17d02596cbe9aa95342fe7120b9ce7e9a400" + }, + { + "address": "9580f83b969bfb54329da6caaae95f10415af6c36ddbb24429d250b187dec8aa", + "content": "4c49d7777c9fc22ae80dd1d5bcca5c12a9d4c780388592a8614f8aa0240e" + }, + { + "address": "95d0735c8eaf0e7db317e500e6dbd74168224d684f6a40e0a0a109d2996ad956", + "content": "4d57adbe59507595bdfbbb184c4a1891f1b057eade49c4f43960e1de9c57" + }, + { + "address": "96627e650f1d92be92a973e43f0ee9bbb3424e5a09b567f45a4b753fff950057", + "content": "3088adbb2b89116f5c67c29ab35e5b2f8ce8611e851a4ef64cd29bb4fd26" + }, + { + "address": "96961d2c1f448402d6b9acadec5950692ed6d286dbd2f12392d9553fbfec5c0a", + "content": "fe38a4f8453f8a6bf8b03f2eac8fba937d4da12f84e659703c5df0b1be1a" + }, + { + "address": "96f3a2e7e96d32e3c30255dbb1a6eca2f52fe7faa92620358ace4602298ab7bc", + "content": "d4f81af24ffa380961e7cab0c0786652e740c912eea4164768ea8b35e3e8" + }, + { + "address": "96f40d47412ebec97c461eac8a13bf754977b2928d798b8762e0052160db7b71", + "content": "34f93dd137cdc5767ed4970943cf6712f7d98aa87ce2e81a3e6d368b0b3f" + }, + { + "address": "971a65cfdcd3638385d2f41d35133536773daa12adfbdcaaba1e272af93cd34b", + "content": "b3dddc59c0810e7c1efd8ae0bdb9b854119dd26d1781cbed06a66b2a0a05" + }, + { + "address": "97471f60f88eeaa3f25b81e9034a0c72c7e391c6f5a44fcfa9c84001ce00bf42", + "content": "45996d8c562febc79a6b5f1b58ce814a1bec60ba69fc2ca5eaa19762f920" + }, + { + "address": "975cfcdb6578a7955b0737b784404851c98a6ee8a2a31dc8a0d76d2c0a358b89", + "content": "d5e0e35061f39c75655b39f2b89ea635241c43f9d7ad36a37e02d517fdd5" + }, + { + "address": "979314bf4a4dc6b7cf21c2b9f9b3e471382f50a1ee0fb5b28fc1c83c63280869", + "content": "9ede2a4a4fdc2b8ab4117450a89cca17251083bddc59d7a9b2a2a816520b" + }, + { + "address": "97a573f8823993ddb9b95e5b0e22572729473aceecaccd156c126d6f67c1103b", + "content": "9d8589b33e32abecbe80ad5f90218fe3f31cc9198db2ae7d7212444ba591" + }, + { + "address": "97aa9ac492817aad3ae4d472e6e592f49325fe9ce0c9f83f74b98c5677ebb475", + "content": "8b085e664a6b5d4de42cfafbe8bc9b1b77a1811a19307084692195267b5a" + }, + { + "address": "982d3ed327343274760bc5dbdcb23985347c9cb061500e9295c2b450bff7eae2", + "content": "d379266c673131cf328562bd54b518f904444496770ec8b9b66c4cae9e09" + }, + { + "address": "983168f6195a41d935f8c2273f815656da1444375f6f7866f2e1ad4f23055516", + "content": "6e291f936b458f321c52a140bf703e45f1607193c7e6e8d90dce69d97e84" + }, + { + "address": "98e23a319c76ceffbd7682baf154be1ac69962d105db60ffb286ac6849392fe1", + "content": "b41ee7eb0e6ed5d7f35da8874d7db0d297e81a8df37159b149390daa15d1" + }, + { + "address": "99100d420ae55456e0dca61f7093c07fd36f8a192c41a86c6d8f82fd5aadffb9", + "content": "acebb25ef4d5cbb0549e1d3aae927a91b75898f24e842b284e133899788d" + }, + { + "address": "994c2cf792786c8e4c6f1562f4ac05a08914f61ad3ddef0c2a057fc62ff5a9c7", + "content": "89a16b63fb551645d62941c67392faa7cdad6e701f5b0639bf0fd9cf548c" + }, + { + "address": "99ef8395e68c737d2c937e126d27d21d87cffe16d83b3153a50086359ff276f1", + "content": "7c639aae84366a4cbaff9b272da7e487807344af6a2763a5919f4e0425c4" + }, + { + "address": "9a336c1bfd19471cbcdabe5cfd8410965da8d24806f20f0ae2c8aa1789bf16d2", + "content": "5f0821487771f5ccdc1fb14ac508b0b44933b1a747c71e23b726cdb5e06f" + }, + { + "address": "9a4ea573f0e27a453c0437cd720714db1ba1aa818501920c38eb997e5913d058", + "content": "243b046b05a1ec16f4e41c17d57a104fb394890baa5cfe59f308dea17bf4" + }, + { + "address": "9a7473ba854a79203786915d8599618498f0718c575c031790abbf00e625d94c", + "content": "875aa459a7718dc35a1b7f4d3e14a2214313c63f81c0131dd05834c0e698" + }, + { + "address": "9a965e78693aba4ab57d8f3aa676812064c4343ce92247968c317237ad1f947c", + "content": "abe39888e07cb8c1bcd5e9ca4ec12c262bac3b0ae3b619c5c328258fc811" + }, + { + "address": "9b2b9a04424c99c06b3b3a6492e659aa35a35e43f94dd0ac7138791a3b9cf044", + "content": "8556c2b2d22c66672742b38a54812d6d6c1ff573b6373cbf294383456731" + }, + { + "address": "9b949f127966997cf9411ee9c54749dfb7c4a33e7a180bf16254453cffba9fdc", + "content": "d7b2fdc69e683b4fe8151252a8f4e0779c94bd50321d8a3d99c24b200c17" + }, + { + "address": "9b94befa4c255a5dd5a67fc5842d86db001778296d41807f0e663a8c03818d8f", + "content": "e03b691985376910106a5ee7b6f6d243b672179840712afb37c671b557ea" + }, + { + "address": "9bc61ade1df74632a8abc439a3579aa1407bd4b4637d439c05ce5b0c0fe4c4e1", + "content": "65f8828d61cb5de98baa767e6845c564a2e5252fe2b440e5abc2d3a42c4c" + }, + { + "address": "9bd9bf236a219c8d938ee7fe7463473c8a5ecfb93860baf698ae9bfd78df4bf5", + "content": "f494e0d8e90cb4817095be3ff4d589bf48724d20accac57e263f870924a6" + }, + { + "address": "9be217248e6d1acde716bdc26e41d9b2a6c43800614d067cf3f111e3ef904430", + "content": "66c04747f7ea72fab726bf7ca709ec89a450b7824b2017c1e9e61a0fb96e" + }, + { + "address": "9bef98f2de07156fcb3bb5f873f8d50dc60c8270c830dfe04ae69c27628b0f4b", + "content": "bb399d21e7a58397607820a1c4305734317b86ac4729c6d1f1b10eff8c57" + }, + { + "address": "9c29e115bca65705f071caa313e6936d2da6cef6a48b542178bf2d93ce260097", + "content": "d3c3f157381d8a96d9630f1072c1d7cb0d3eaf2df953bfa37474c5afb46c" + }, + { + "address": "9c4fcf5f7725671309005c31543e64dff95ece39478a47b2b2375c6b1c22da89", + "content": "1fed75d7d36579479d052a095ed85aaa8af9836dda32ccd5da56fe382ca0" + }, + { + "address": "9c6bf7ffe7ae877cb71700ae0b627e22aeec712d127b725f6e931bf9e7b04148", + "content": "745175a3d8d24152a8547de4672fa2c61d28ab32ea74608671533df7c7aa" + }, + { + "address": "9c9b805ef65a4b2815c18d9fb53c248fb7c2638283d82f6d4c35a86e82f61785", + "content": "25ded02c7232204d6245e8b65ed5650eebdf432e840707dc0440f9038828" + }, + { + "address": "9cce0ccda14ca96e33192153a295e75cc2a8ffef296dc668c2fa5faf15b7b364", + "content": "ed458ee681427734269896e6351eb2897bacbe165c63c38f758c828820c8" + }, + { + "address": "9cf8eeb54a2f9e033240e926ce909e5154ce0e8212b9be6142e2e56dc0dadadc", + "content": "7c834f2ead2c9135821fb502e5b7e256883596b9c38bc9e59bf5d999ebaf" + }, + { + "address": "9d50445b66c5bbeeec8f394f465997e77d08c05f1807c38e95073823cbcda59f", + "content": "d9d4229f2f678ddc73305758284691603705cf15fff22eb61fa27391854a" + }, + { + "address": "9dbdb1f88aa347b09eb68679032e5d0b80b365b20b4951d6540ed2d0358b7cef", + "content": "3df646a2747e77d72f58d8cb267e680d93cee43f62ad5f08df01f0850970" + }, + { + "address": "9ddb94c9c16c9a12cec155405c311b80fba876d966e52e9d21892921abd73018", + "content": "b112cb1ffd5b72e1b1d180f06e0f0b5e46815697fb1714f7965df2b39667" + }, + { + "address": "9de1abde5729c9381e6573bcc91c88364d181a40bc916cd708452eab52fc63c8", + "content": "df63f000d7624442960e341ba2199bf6fa6df9de66502d621d8e3a23d0dc" + }, + { + "address": "9e4c9ab0b892c23fbedc495033e5495979a4ab66e0df29fd5886616dbc9d2810", + "content": "eea36efb9529f5b4c428b27c6a5d572fe0d26e598a4dda67d9ed8fc3a82f" + }, + { + "address": "9e95fe3530f2b59742eb8e52863d13180654db90f2f6a265228c3e062340e076", + "content": "5a546d140a5716c90243f1f9faf8b89bbd71af6fb9ebb490e2625b44ee0e" + }, + { + "address": "9ebba37f898bee8aa96719b878b4063b40213c53dae5df88fcddf06daac28090", + "content": "dfc1644858622223291f1c584b586d0249e812ea6819a1e4ab2fd51445b2" + }, + { + "address": "9ec04517e6f48ce3620a1a73390d2c80e81ae919d6a69bf45c147d2e6a8fcde9", + "content": "d69c22fbba69f41fe6fc4424dc0ae6d4ce77981bd7cb8b241b35ecbaf1b7" + }, + { + "address": "9ef2ba24a170655454b108b4f0363d931bc8d149fc756183eaf58da35ce5faac", + "content": "8844d5ec62b8963e67153709b96cd65483f002b625e6888112318b2b2159" + }, + { + "address": "9f2be1f78e1a717afecd4f9c1df32a41dff331bdfc7f23bde4f2a0713b769f8f", + "content": "1fce4d126691f9d323afaa459989300ce2c3a4ef576dc7d8d822de247e13" + }, + { + "address": "9f9cb27b884a800e9c7186b71af4a8a3812e3412dd1b5dac725b1ffd9496704b", + "content": "7faa23679e085d8bd6e04d84594d0c2c0e59ff2ed5f21be53907da10ae6d" + }, + { + "address": "9ffc015b5fd0eee286fcb296fa80433614aa64fbcda07e6ee0b900ffdaffd0d4", + "content": "2e8a096e7512e76ae648b9294d5af19fb4ce6349815012694f9333dedd79" + }, + { + "address": "a10e4a32f93a95e35c5df14457ba7d3aeb6d3acf66091cc7edc9ee29d8098b5d", + "content": "8c65c1142f905334efb8e71495389c4edb29e71876c1c8d9acd0abf91f51" + }, + { + "address": "a1ba57162fa414531578a796b91e84e437a549f289c99ebc1e8490985a781f32", + "content": "cbd670f12593ec7da9e50fbfe12bfe75e87a7822ba02ff16c1e95cf5350e" + }, + { + "address": "a1d57a505ad0ae897eafd2101e033f500045e4905a5e3e463c4aa8646bddecdd", + "content": "9ef8b2b7fa143fc88bea021144c9eae066f55934b916ba9c51007e246c4e" + }, + { + "address": "a1ee387c3747f003928642b6dcef37fcb5c89871de7fdd84ebfc6f6dd88dce11", + "content": "f1532f2e43fbc0a37ac04ce7976416cf32c177b3bdcac0b38a969b4d7826ead31f3c9afbd1bd45151e1d069559a5fdbd79a677106a8cb6132a6c07786f8ed83b54a1cabc103a012957898daa69f6d3e42167f1f9337cb3edf78444c61a258d24a3dbba9c6bc5839f471cb1fe1784f0b5c1b57d212e15192d2052c09a922fb25b58d63a5a7a854204c801f3c9b3f45bdbe9230e86fa94ffbab005bd03e2980960d0220fe2fcc15df6ba72fdfc4eb221bcdfea9a955be6487551db050a90ac74806cfee1255728dd8c90089e4523a0c7d150f0aa946503ca865f83da50196a75fa304d676ed0520d391958b0e3355f93215e3a74bb74f1613458e20167cf04f2bef4f9a8415c0a3973f02bda49fcf76cbb28d837efee9c71544018dee40aa07418e4aae9a248d7eead264aab85968fc28ff512ad5387eb35316b8bdb99c8d2248777ad8b7838576a9d27df4820591d2c7b34b0bf2beb1de3f004b55bc58de68ed41fa60deca24e5e7bc5eb3bb0da3684f2960dfcefaf1cc139742c66c1fb3a4857f4f46faf00432154e09637bd0deaaba735f0b59339d1497e631f1c1cdcc0fa8fb6d9bcaf877cf4d98466304ecef746019f41f7929b9b498d6a16d51823f58528bf7e2aab522cafd45203290c0308ecf20253e81e9dd2060640470ceb302d4c9686738545ef9772a00572e73d29cb8807b3a66a67a23334895e84c52fb44121e8934b1bd1763087370090679261267fed72de1249c09fe43bf83c2f4edf675df5e12f029404c7d6a598161fdaf98f687c282e60077cd0d02c9df9f1e9bd2ba0c88e9f8937cdfe40198f4c948c23a6f653ddbd805aa6bccf9e590d2c6ecd1a05b250cad0afb3532325f90157b6d9420c956e5b80150dce2ced701e5792993520cdd26e76352711c914ad0f234ee9c8e9eb0d3a2b3e3266b5de795cafccffbecdf0c3bc020fe171c5c691c2915997e564fffae771a9765a43d32e73b7a173295c17c28eaef413871f5b0e3a2c32368d84cf5a8020851c6d1119b8b727443dac2df0453364f9abafcd39f55fd3f2e64baff8b3bc38868b89bdf3198106730a6121fcc4c95494b855d025bb970d60abe2a87a5d22561a6a1ca6845fc34ee8d871d537d81cc63a27871d76d127994f4fc2cfba7a3a37b095fa9b68d8e4b612d844564163c29af3a30a2d3aea0f258ae6295df979b7ea43daec7149d075f69e121c2446dc7a80677d7d3c573500e3d5394bb1051916d6a56ee6ca653784c0764c20a2d02a76db3855a012a50a64b0e5b6fc8d371a2b76d2efdc7932faa088a173cc8b166442aa480f16033e1c3f1523911a0ac513d95b6045914aa942d3adbc1ea01f761f8b92d0531a68c512a3cf96317cc7ae7eaaf2bcea1d77b8154f26d874519c33bc7075ecefc6a45c420618ea6833c9a26562dabd6044cffd3d7ae3667ae7f979cdb6cdfdfd6c3abf01e1ad67179b1877e75addf326ca4e7d549a459f3dde2a60a75ec63aef8aa51c4f8494d0ec5ff4fa17f0d38c989fc7f7837dc5a9adaca3623807496b0affa8816510c63ce3d54fd650267d9e361faaeed6ae8c8c1b8f3e3d0b2852e34ab1fc16e5ade3202c61c54cf0ed5a321de1dd6492dc0349c45b9782742f4bd19059344bd1c1460092141c367380d4532e00047dfb79e764e4fd7abe8cfead0e4fcbff4bfac06692674b90cc9e724a2e480344418441603d8f0e7771af3fa925504a02677de4b1a1cdf0a1beed3124a316b8ec290dc18563d558e0b3e1ce5d910c5ea65d62f29801183e2321f7776d9e92564a7ce4400a62d2b7f3b9e463ab303cafcaf1538e5a88a3f5894b4b2b31093235c2af1de5087feb7c40a31ccc64ab4065c86d624a93c58c0f0be5a5fd5c8adb0d3ce28b27dc6ddd5e4cd48287787c89cb9d5fa45f64e5434deb9dce37e62dc288c14cc206a7feb6b83d17de6e789bbb9a15189d3878bae3872c4198e4e37545d10e1814443ffb9c1b47402f2f29968bc9ecee129071f9fffed612069efeb69667906c4e95d303b9d5fc2a9b7cd3b0e04cb6105ad2e9bfa68bd3c28f2d180f55465e782af48a53b51f69c3fd7fb365b0c83e17725ceedfbeb85601480865849f9dad82743ec9aa502f35757b8961405c52549e993f0e136e9335d2bec75e9be38e7cfe64e21ec8766d3bf987763a78a806e5ac26aa1a5f43b7e3a8a230353ad6d1590e79198a43d3c663c3ff783c63e4a1bf4e25d15b36e1956b860199a248e6248cd3883e3945514a73590be5049269b0d6fd96963f324a13ea4d4aef871cdd8538ee999ef86fcba9c6050058880916d555762470cec67ca3ebd38e30688ef62de8738c46178520813804b3bf8eadba03fc54ec3a740780e3e6f7aa4be6b8de7788bec533dd6b47aeafe981b24e13972596482958c9e7e34d56423f1ea0d1fea103cac0faab925babcd63e24f9eba2ee076f4807d3662901e3de57109967b9944d8da0a1e5948f96cf2d5d4f3ae618725c949cadc30cc95718198968f3c50cb0b9bae7902b9b258b8032fe19e42d509e84bba04f0d47cc8ee76cebcda5e00925b3caab10063c7c8ea312439e3b7544e6853f14d000fd345a1892c2f957e5ad104bc32a0857340fd996504d0b59c87f091fc933c295ced8afc6cd66ffb15edc8788390b758590db815e1d389122944056d2bd9733e4265ec0ff4f80383def90c53ee9151f138e3ce4748cd62b54be85d2f5b4076f12fb1a873eef7ae4aa373efae4ba7facd37d5d44e4dd80f075d1f780db79d6e28a8c7937b47437c50776783da37aa1a5f7466acd0a47906955cf4121e0e0a9badffff461e6c3c235674e01d36dec3980fa03b9b218304f00fa8c428a0bbf6e0b4bf2a7d7cebd79115be939a52f3926b79111e22432099eeb91fbe123c964d4181ec019230ac40e2b8efea14350908ae3c3e350ee1784671721f06722d1f75fccedfa24707bfa1851632ec05bfa09e23a414e473e27abec5dfd1d5f85fd679f9032db3b627894fd6a0472a4de7971904e50c620a14c821a7de97fb7334bda316f915112ff90ca8b90933cd78a51a72d148d93aff64a3a008c07e5dc02168bc6a8d296924a9a6ac0ada0a05d11a582edeedb05353321dac24674c2e76e2209978e14d5e19a5dfba0e98a3956a3838ce52e277e0fca5da79dda9976b3ddbfd32a92d4faa947f2c5d437c64e38fba4957d0a6f728f51611b2840a887934bdaebcd45432623fa185fda1280ffc019989fffd79304dcbb564d83af709282ae72114567029df82adf247b030e8279e3763b8e52654201b52d98110fcdf57a9f55059a0618d8964aecb32026b02a43f91d2d192cb43912db8803caa4027ecfa3c85016651cbee88a4548eba16c82254d832ddccc5a9d310cf3d9abfb0eced832c1fcd56f9dd1e5ffa55901b7effc504c644c4232cec118fb328fc0b07705639dce2d6d4c591db8493a6262936203aa05e35451a0292c173e46fc607cdeab6626a144c6fafac94b8afd85a522111fc36451c3932b47446628686889fae522779a4c861209b4faa4b61a4fd1185f6e082b0042555140b472ad5e3c73d32d6e9373f3c80f828a43f8ab93879b5a5229c85a1b1385d0a0ca8a0734f4103eea2e1cf439da77560e944431a3a2c991b059e8c080b56555913565e59332cf74135e7fe53678cae0f47b2b1ff093f97a243b0b71b34e5a37848df3ae121a5ebbe3195346fcba75966a460736c8dd95b1d57e491dd2593aeba9b3fce698d7352ab4801280db1c4a26a98ddc24e2ccf0f65c4d1e43df055df32f305b1561b9106d44ad36541bfcdaaeca9efb52cd95070cf132b041d39523eddfb9bd3d09ec4a9853a467d33cd05d99b79c5e035c847a3298cd41d6d76a6a4ff3b02ba211fc5e6b26b060da3569a088cd991121fcf334a75a58374e7f26afc5f4bc4b7905f123e4d2aa366fec3eb5b5b496f8d6bc19fbc54b7708e2dad8d9b2d96e2c1c3cce75194b9f211659b2be89cc1590c18de526116385b217ee78e315c1106f11a270f101943780588a28cee75023da0ab6efca0668ba136865e9529793fe05a379b723511a7ea90c4a653a6a7fe0b028ffc074ff57e3bd6c628e55f193a4fd7a864c5aa7d209cfdefa8dff5e9f8e045cba1a9d61d73fd8e3d078d3546be4ae26cdd04ac7602d3e11d8272e28a3ff9f0f6da9c79743b2bc4ba3abeb2fbc997ea6e32b654b270fcb85040093856fedf5855b3bee560ba76a987b6950fae0cd2d4cb1a84a994c85fda9e67e4b80c6c9d42055a648b2407e9f517765e8ed63fe7929628d517ae85b6210e2505d2bfa39d7b5a2ceaa61a736f51918a3b9ca28c0136fa5f244bb83feb8a94914d3fe7a420f905307dbeb4d2624eac963435e60523fa568977305d32efeaa99616b2e8933cefb8aaa00ba1f02a130e73c72140e560e073fd9fc3e066d0a7aee184e95ff43812f01656671b070014ff4a8d5cf3ef7b3157bbf0984c0853bcd64ee47169d5c6357642dacf25931d4b4d95eceaaf7663402a8e3872681eca7e6754a061e8559318eaf45fb5e5833ea4564eafebbeb9441f028dbc0a3eae253c8fad739533f06224326fa10723e578cd709b48430cb13859a09108f49ab463cd7f132a24f5e6dfd110ed92bd750c889b5aa31edf17e2b0228a737d99f27b8af8e6b559e892d625330df619e948ae83e8de82a3b54da1e43a56ba6bc7cc816c6c901f3d90699ad7ac025be9d7277ed04bf2d012d9ce0ee2e198b6065dc442e68a407ec9a60957d2b408af83924940667454db5e52c7d16608486e0c885828ed042ecf9712f2afce65e9cfbaddea49fe5ac71c83424814eec8557295b2fd715f259ad91ef5e5cdf4dc64d445f3dec36e2b4b85814079b653edb8c5fad0126f4315eb8b75912234e369e865e544cb42e81911409cfb07d70294732fa2058f388345b6fe6cebff5481605c7abc302969338a2dea17f7f2c2d4934a656007c570ccb92189dd567d328bc71374f50ff12a8e67b2e3a520184e94bd8634166458e7cabbe0766601e6701bd5e0f02901e487193b0868523e0966490f3bda2f6ebf5d0aaac5a761e36e6f14853b42ba785dbeabcf72b86ee384334787821fb9dff9419a26dd4e305013d4d16f26118019001fda816ea190e775ae25ef2794bbcf9461df536a1ea175d37b7058178071c45f71a48ed9055d3112f7067ca0a4dd621595a46a68fb9d75fa10abe6a79117d1ed6b685684d04f2456a09b014f79d7a47bd7ea5acec13cce4f0c14bb9a3929b4a5bf5f6b827ef91a52fea0e08a481b1c40791bae0ef939f953663ad099e0927925ea57cc63664248b66109c9248c4dd27c56cd15b77d234f04c130c613ee53e5a88c63ad16526172ceaa608a280365430216589dbddb5a38448ff630768bcb96f2be0034d6aa60919befea58214f935437c24a9c92a08b9ba72160f2eedd3cb6576786dff32f5f4a10e82092a093163daf627db0b1b3bb632d66346bae37f56319db106ba7d6969318310869df63db4efedb417360872f483fde41c043df5a8851dd879913656eb2e3a395a71c6fcd74cf546f482a089a78fb9378ec61d0c2848948d2b11d3d7eb24a7c23ca205f81a7c01a219364f6c5fa8ae8877f387e7fd707bd380135fe6953054e1bd5d849d2d36dabd7d40ca0038681f2a05078181444a627fdc9dd4c444d4a05496837e714f89fc16ef77df7736a346d7a61608909dac5306060a5e01623470f3dae785c6a0839b78d38128ac893bd4d7d8dc82080abd0b681e226595ebc75215dbb7a90d9766dbc686c39cf9fd08aee4c95796d63f51052731dd159ea859c10819d923a1d286705f726e8d7a22c91ed39aa26e7baa490c07d6864496676cc4f7093782dd99b4c770920b8dc77770c34b045832d3549e703d2a393755fc8b8c818e36797d14149f3e51fe778ac392152fedf1f42e350e323c5f1ac936a95a93212ef9e60f14361ac6dfc4653288ce5d2449c9e8227a3c70e37f11de0f3dcbd4ab27b39b2059e0622971bea407c545fcc1afd96498e7821f863a1130d733bc66d177dfcf8555c84986577e0ba0b1058db8f7011d72e9491a50b298f6914eaa00afe04d8114a0a4a6985a9a2072bf1669187c3e076257911396465ae7955612fbd47e6ace65d575af04cc6eeb33707a9d4d71685025ab97198621e7dfcfd63b45f113dda02f86d1610da066e2f96ef29f4109ebf532b84545c513ed6b4c068c1c673f8eaa966c85b5fadb440fc8f7c06363961cd3566a221f54d7100fcb71f5033933e573fdec7033b8424a5927b82d13a4cf0824598a8f5dbeeda36f607a10e80788f7154499d3c32c26f612bb2356cd5dcdb0841ff5ef94ba4d5d287ba60f458633ba9cbabbb2f012e9ef789b616fcd8866108b85853cde04899b3a49a3f49d33c5343973c02012f5919843aebddc28d912ea8c77cd497947ed9684ebe98a7c7f7b9f34504a132c3105ca2799c83af6e97ce345f3717934e76616e30044f56f4a4ff76f9d9e12573e6d15d377bf954fbcb2e978295eec0d1ccdc6aba47b9f4e702ddb9cae6a19715f23b20592e8db1966138547ebcb10fa45b655a7d9b65ab5a9a049ea912b5efd44a398fc72e14e5b67a72aa446970fd37aa4f139ba5bc71e68cb523aaf6b2e800890315a354e1397e759e6d9d72bec7c6c7541eca3ae129405a64d63704b634672c1c244eae91723b48c6d554a0923cd86bbcb5eba9389d0b7c247b68226e802d43a231f356ccb3d141fab0a9949a127b3c60ce2f4eb12fa67ff93dd245381182caee02588b65927d4d9fea6f239ee489c61cc0b596b93717c90beef577044d45bd97586084b113d0bf9df3915bd3150db3d34a754b2e03cf7bbe6e6938dee6785d3f0e94d8f483bf2654c1672791574b69b8fe952bd2a6ac51c9abb4920b238d2a7f23c0ecfa4bbc42f77c6c6f48c0f6cb5a783f48599e66a3620c7fd7fc10bd7962a9ec177c515bf8b4dba9344108040527fe742fc9d8ab47731bcb4fc976faa6ea445e8821db90a0a45f1d02d1a167f8ceb25b2a47f2e405913d6bfb9cb868d6b8acdc1a903d729c5440bc742237291f0eddf979ca78dae1686fe17494a9445d89013cf909ae041057a428868a461d6776af799cc1fa94f5243b7e58ce155f5d0a5e041e2d28b4ea0c233b03e1ba3bfafefc923f01d8cbfc4fc6302f90b0f41f15401af902ad611cf7774a49357ad700232355f3425b3ce5768694d9feaa79693a5b30453e9391a8a38d32174e62a1bca5646a205d8fee33168cca52f04a5ec7c1ba17ab7b169c7b5bc33439c8640ababd836618a22dd2fc9bc3d77126bd80ae48ea7d304f216b98d21bce50144bff8cdd475f5da677d45a32f191d2491e0ca7b9b4d09ffd1458c16622094c96e298d29597c4ca57cbffa8a68f437a9503635b09b65d4100fe73f07a418dfffe289a090102ceedc022275847573f26364230ed7a8a9a8d80aa40eafc7331232c231b71d6a24726cca184eaa5f20f491398167b78f2e75da046bf77d23e172fdcebc85e4efd35cf3f816905dda95b0ea681ac3a93ee053ee23fc3607fd1e2f8b3b9eb5432d4f05a9115c1f15c5e016c63d1201441861ac95063eded3166be8589ccac636ecabaf767c132b4bc7ecf93698421e8b0613094f7de28adfaaf3886db77c395131bc1cd3f6e1914e7d3f2b8415cf4e2e69fea433eee681b891c00b50380d3ab85ab7b5cd2c2723adb875a91718ccce45910d9b504e26d875f9966e8d670110793c7869d521e1bc4c6543ba119c28d0a5ed43e2fe4957dde8467b816c2cbc06ac0d37d2f2558f132c1d680a2109d1e090d73aff4e38ab2af95fe6d655344d0dfcc889399d34b15dc619476e8f7418831c3b043874cace7a04299dc5e17f824c6ea43928d4eaf7a1dcf36447470084cf062263801266d50b128e501b1e356458ee1881f66874b76d13e43b982fd7029929132c94cea262303aeb07f3c3b88e918841a4c113cd7c6d6e09a2039b2ff59bd1042a35e00eb18aecb5892474794a3b0b4d2f10a42e9d889fb6d527bbefd768b91cd846430825765898c3ea7461793f66cb0343aa3c9f1c5b5496d7f84a447bdd4501e7b1005c9daaf18e493a1a875e862f6c64c539ee7591157be9a35e83169646cb343cbc7a7e7231d8c708c458ec16b62806dc942a3793e6bb65066a3aadd8c5b208e73212e6074bc5ae7a72dfd3e859c44d89aba9e843fda193cc14b7d9c6ea81567e9da1bec422e7d268440aa2bbab6bf20acd15c0f19b3d455241b1e46014634e71dd6f658cc51ac3472a5dfd0683029c82c5600b45abc57e0d2a4f36177acbb551b69169095b11efaaa2b9b3ed8cbc49758891be29a63a844681f4b78b0890c3a8e4a4c0d6464e532fa17d34ca7093367e9023a33005cf168594829bd962c3056ce0a6127b556dd546010e7ab30c227993578a58400aead1a0814382387230a2dd356e73b08f359aadb3da049443cf78f9c04ef2c43a4648b8383db3a4a2c48faf3413f37ed47feba90b226b46a20b1e088119232b8b7b85599a067883641f2c2280895ee4fee4c0416ee1e463370690079ddda269db7ffc62e69e4dd423636c203368d8abe60e931d6927bfdbf1710aee9d27336f156676fd41a0d6fec9667e15d11ea2605a264d79a98ef4009e9502c14ec94dadc9e6d80fc2274bb42057de64c044939828276bf2951645074fe899ccc95ea3a3f2fdf19bae1e3ffb9ab8c893c421728dc1a5cd92f234c65be6929a44032f5e8085b29bf19b85f45d212f37a6881b14e33c084daaf3b057d542a58f8ece343d5734b550bca7bb213f7327fa710b9320c62de41ea99a74abb8b6ee5eac478e669c55409a984945347867a564ddc9c1aff9aff9338800e878deb575ccc1e696996d69aa3ccad973c1976ff80706b0f229f3aaef2d13b143d0e58f68f2994a86fd1cba6c773c15b3b6104beda65861bd5143c0b38ba412a147d20fcd228bca716c35ebb11f97a8403654000cb981f5e22806ac986bbf3afecea2c7ffda9b8616abf631fbe930aca3bcc8b808f383417f5ada230469bc67563a8d0263e8ee076122cdf61f646e24c88f8416ba4612f7ec04f044b1d4860c42d42b3996822311c170a4ba57177bf82d01f84688fc25e5725676481c2392fcd24a9968e9eebf2423c6fa18857fb72bdec868abe28b8d5338083cb50fc6d610997bb5d849a9ca2c6fa3943eb997ca1cd3fc1cfee1d9831c81776a452dd37299880a5453afd2eb2228cdb4ed330d935f650b763f17ca687d754e09070597d9e3cb296688176fcf6177c4ed47bb59e0d750b0fd2e0247a70e7d7032aebea36865ac0103ed9a52868a9e86c2707ce5bf481ea0aa2f3e1b85db017e8c7f9ee491711f61ec74ddc399c4143d43b54581fe4d1936023503bd8b847aebe208ad4b64f0d885deb0487a0e2cecd243ac90edeebc0dad54f069f09b0c4f38a07ecdce290e32de9d8ad3d6740a315f11535e1c69ee91cb0aa53a7b7848f705e0aa313c63d5673b5f6c59c26893fddc25e846fb874b8532f99cd1eb1d25ac3fd0eaabdcddf934984883d68c9e193bed8011ae1910800db2742ba9930052524030c8e399110dbab68c3422d7e9d7cc2fec030cf0b0cb3921b4ec157abab341bbe2e6a0d7c45237f601bffd0ad3cbc445c2a65b1f5841062cb85262c72891c20bddb101dd78a9ab885907f1774c4866696ae1e7a7cabdae68897234f45405f750479df55d779ee8d7c1acab609c78858ecf612421b67bdba1181af5e349ac1c132677e43372e1049892106ddadc9b512df914e53404858f59e7cd1d3e91ee8037784e189f9fe9dc7ec1fca4382c2b3b4c35fb2a530df6ef32ed5da57e4fdd4a08e2a02feff6f608542cad367a1ac5a80343ad53407958d43c814dd9ca8501df8a196a51f8095c2da442f350bf71d3d8dc422a13b6e9f9ce5dc1b80bff9ae94d315ae00b56efea227929e17b930131417937ed1da001af94f51ac614b50db47787d7d7a24f7252b1269d172313fe4a9b2c00204088a0360bff876cd5aa50b0d8f6d8ca01ec703b0e53a947ad03d1f438f91f47f7bf074ba3d2d2edf340d7dcf20bdd4639a37f01b0cc92d02ca42b5f3ad8a97c603f292094e59176880cce2a30f861efe83b2d45d47f628236bdae1ea65d251e646cca6c950f0e95f50d2e336b2e55dfd07ba69e1d7007375da7a1e4d984a9d8f012a75cea3856830a4d36b8e268767c8a862cd95b59d3006d6f6955bdf05a4b5dd7c80fc6e578345024b0ee221222ad261aff45f69d7b8dcce002dd46ce6fc06c139a675ce19270440a515ffe3a3ed25a0713552d4911d82eb37dbd5a5d0acbcbd5c66285626de34e44d71fd32a1df60614bf9bd0732485f6e61b920a4a27908e492be1f90e7e68ef07862789feff678ed4b9844a40000bbb3531fa8b52aad4ad978c811c545958644408fe6faeade7ecfe086fef6157ed0cfd53ca56fe5b7906e63331b742e18ea8f7a9542e1ae3fea550ab959b511aefb91bedb51e84677973d9487028f959689f794f1d861b88ec7bd57ff50b6e437b4d2e5b9a6f077e5703179742ff330b07897ebe96231bae906609daf7730e6ad803dc38f7cf7f4ea32259c10f137d343e6d4bac59611ebb8528c5bbde8f3dc8c4fa4125b4a1d22d9794bb5d95882fd795dbe4f9118217887cb8d0a5de7704997454f7ede9d68f6ffbd8f91d5f05a5d03a6aadf1e437ee1c4ae49673535327b2a986823db1f7b245618dc4919780a1d586d19fe51d6880727f9a9c68dd73532dd9b71b663a3f2d9f0de8b9e6716d85cd2e81429cd7685c18fe548754918d360e79ed6f6c1db062ff94e9a9e99c7e4ad60a93e1393175eb25c87f073170c2f05c1c65f6683d7c1f17fd906e8ed43e957c80d994c4814c4c502ae8ceae4f9ab8e955d2339dffc106fb67c1a42c680def6d6a56ad1ca1226158c413e4afcb111cecb75cece1c9f6f55b01d740e5dc2b15ae18f4aa78adef73d10bbb69b070dd5eba8dac9a987a3b7be1574f1aeedc0db605980b7dd19e1007b77503c7a8290b2cd3b1f65f824025e1cd7634cf69bf1c8cfd7a979f095cfd94b45fd8a015a9afac815869f9e6f8c02d5487204a1e34db69d67af3174b70c20661f86b3f8724abec0a03b1224645a333a900623ccf447d30a85e45a74dcc73b525e31f1e117f4f42d5f48689fe0dae20fabf0ecda572299c99ccc833cbea1c38c30a1518dc9127805a601b62f9a0fda0f4afef770712412da3f590db951b8a845d6a5f82a0428c8717351dd4e09a3e32c53a18f9340969da9b92973a440827b9302cde5904225a4db47fd3d0cb5aba740181f5437ccf8c8bce79344e448b2dbcf15f7f74d170a49f7802a106bc7827050f00f12c0476a48e27acc6082bd94d221ce4f94b6cda17817ff90257299b77d56d5bdd64d5accb80758f43e3ebe8ddbaf0e223571a7bacb34d98be07031608cceb18dc749ce3f5a4805c8ab7e42658f0886c1511bc55ea5654bbdbf829cef41e87242931f83e4bd403b2f170af47fee1454d446f7a356b88c0091518e0deb39740bb0b1853b78aa3b5fa62818aeb05b7e29387c6a37437cace113fa8ecc8443b0df58a4a3af49a1194b05e5730f4365f312788acc778b530ace41e87a3b52ba1a9e66d05ecf0852cbfde64ee0ec40c86da2a4d9d0ba5d89797bec9f98a2c27156010d7407f732005f4dbe0163851f5ecefb4546f7fbece117977f0b4f31a5fbad70368b254c3a386991bc7fded5a8cae5da1bcb7d96b14f8f6cee0787356144999e83935c2890b369d2648a5d3d34913827ffca0550a964590a8971782fa70f665d5e90745b959c304be0465fcaf6dbdc096d2ec801245429651bee8253691c43fc751ce3692661f691ddca70e3ca5093f35fbc325033722941206f0eed507dfba25fe2c74f2c376930c90005864d17859c114bc0f5e7c2e5130b5913d89c4781e543d4fed55b6d17e8f5c5ae7de547e4185a5c20b35ed12f76c2476e6236764dfb1680f7b1c1126877a6d6a7930cf4ec5f554a5ed37d1559e3ba30f3fa5b6b25eb4641b61a809285a2672d6830bc8b6d55fb062c83df2635db0111ccf58ae5f9c661f9e51efb87f596fdb1a7f93b55f04d408aa3621445e8f5e4cbb28f0d1f1ffa50487f41a721e867df5007912bb50056e8d9b5d44d955b98a68dab1a0ec4822f5902e4ba77d8e23820f3b74dad05270ccc3550e564ee265960b2cb0bf771fcb86949fcec803eaf4038eb48a2ecefc1177ee13eaad3987d89fc28a09753395c988b6d49e6a672c9724edc2c076e2facc5ab62e845d323053c4dd1fbb8cb5afa029cfd73cec986641fa5b3107f533bb6810af16ffb9066ea727da805f7c39343e6d4e91266c767392232591a4c193c239ed1ca0b0b2f30f2718e74496b7f55908343f77db7e856d3b9900edbcf59cdd156ed1f1f90384de2aed7e35cb6fd4c8c432947bc7c5427ed30ee7a5bba5f77ddc7169ac16cf653f75211cf8f8335e8c610536e8f1daef1fc7d4feb0decd39ee3644e226c9052e568df49966680067bbbb7ac87ef52b73c2a152c642a539b16de048cc24de6b3072f173770e1cff90cd0b11accc7e24b8bd81ee4c46a1c7077b03ee0aa6418182c13d4fbd9d667f80f315f2b92ba28c866d15032b63a1e69bf1a4694f78ea3bde93e6a80ea00785fde318fd122dc6398909ead4ff9ac4530781513eebc108d409c588d01cb0502bdcdb2e3f64ad0898e8ff57e57b2129f20ba936852cd55bdce6b57e20a83312e24e31e6f42c67b19b681cc294a73e032f8a3d8bdc5b17dc6effb36d7055d1d9afe833438447092b575ce22aa0773a530511b8141200d5081d4f4d297421b19fa27e6339945b70918af1806dcd39100697067ae4460e4a89acece935bdfbdf6d795ced48c707075f8d00a58eb629e2a1efe886809ec1c39d6cae35e524eeafb631b30b404a433aa02f190264903345e14ef85c82eda65ceff71ee311f2657f315b1e729803c150d18bbca3bc9e0f5a3528889baea4a3650091f62e2432843908166c9acc493b121e6c692488a30bb140f9fa549d85104922aeab1611b729f006b50b3e355180cf5851c33833ca8613fdce5046647a4303b112b50f6954f67976bbd26de0af45f3ab207d3c333a9abdf8616db3cd4920362b40b90543dd211f84800bfe5128bf5678785f2b4d880bc460d8caa8e8ea4d66358ea32ae8847400e29637143d2f6218cc1ee4e26c65df7ba4a911da04af3caaab6075b9e5b2d901fbfb6b1e9f4e07588ef135907a5a9d8775173774f92f358d62bfb3ef191c524b1e69757e2340dba79eadb0c2bce79d54a02676b5cf16bbb6719a59e1678dee7c5d06fe621cf12a93a8a7d09ede273e1bd7127b53dbd611b62a33c8fbdf2623cbcc4a45d2fb8917b222e800f7506540daa611a4ed4bacbb55394771de796b9b7a32d10df369ddc51b736de7fcd456728f353c7d49f5a8764168c027841fc46c120837ece7d31156b3df611f9b184393c26beee85724d8cbc91968dbd37adac0d1ae432557736e040017b3d798aaaf861341589a5895808fb3321b48a08492dc7e7ce2dafa010763bceed3ca294c80eba718be4468f4fb45c614f0357159a1ad0ddda84e04d030f5b25f81d76a349a0aa173539f48f83aa7120fff08a4650491d445c3dddc187b28676e9b1924de7df11878d22c5a3971d9e285d0d90c158341d159f7c8ad6984d3f99893b1164167f8e2d5f5a0c571e55ef1c10abded8bca5fed191ca6e711343b0317218b39b8eadd4f11c48026d80cbabc9bd056f3bfd59a7d649b0355b67ed4f942c9b5af7d2226ae81b9180d227d7e0ff39dfd00bef9a7de6052ea0c1a3f65a4a72ae07d42510e605d1b103ef81aa15b193ccec325601d9bb697639047d4040aea6c9266d2af1ff64567a65f142d3e28224c0a6a8fe54418d6bf9903ae29aab92f407bf8e357edc065cd2cf08c60da021a0ad29c9a32873da926579be5dd8c4bfa28d2867f22aaafc0ca7a82e887105c07a0f7f935e4dd5cb7bb185b54a31632ae8cc7f7d2414e4e138e108140925cba51a3ef1606833c3587840203c6a7428f2574351b96873e1e84cab490bd886af019a1ea6724a73169ca500a2630d7b588ae5f08913be4286c65859d97fd883c7db22d95371c512c25d6a5ac9113fe1aeca2dd427744099d97227b25e95dd1b719bd4a01b71b5fe4a019148d26b6b5eb6dd701d0057998c17cb6a9fdd299f37ab0f4fd753e336635fc1877e157495a275e072747bc5a3ade13ea18875b44eec1c74128b459ece6f9257916653157291a04667ab24958a2bfacbe78be45ac41bd348111ea7b172814e2c579d190253794f66a44c2026435f3223620f5adc33c9f0c4e1f13b2db9a739e79cb74dad53e25585914f32f2b160f7d8e320d085e20b952898ca58ebb9495d06750c1e391bfded83963ca16a3adcdaa67e994e26d71d6281a991c230f347315d53be0dff3e5f59069d573cf621afb0ac75c6769b6b5863c091fc1ace7b362645465327b410b5eece3f5e94db4cdb7c3c50fe1db105ba3a06142825f66049c80d0c2d97d50dbb4c5f053fafcd2efbff611149c8566f518940bf120d37b25daac29fab43045fa9a952b9a02d8631ee8e03d7152c47f19987e76e6f22b9f5abdd9e7f00da617cf15db52780b9342eec46ed458670781da3792a3c8561b9abaca36c28be625994a152cadb7a703cbef6b38cf0b45b9062434c61685e477e4b46058623e1a2378f0df57ffe8a57f7a5d6e91cbb8ba7bf487b308d20ceec60a01d8505e8a6a6c8347642ab7ae5e7783c7400c9c92b9e18f9aa845196f272305c1596a6aa06de60a020f714f2a964971bed3a46312d4dbf3ea6c6d0eaa94f368b7b52a5f208cf474671ae9863f560f735b6a9c28226e2ebd5479f65c83cb0c4fa219faf66a09b6fac8d2ec6f7c3c6e4ab0a56902a0e0464a71870f74b5949211455121772816a8a26cccf5a73726d9313bcce3a2a91c9450d3cc384bbbbae1a2bb43fc6e33b186d5adf6653734a6a9edaec0904335cabece977b0effa5b6c4e98eb60dad72be6e9eb3b4673835dd3faf6eb0ed93165e2211783485f784328cda99bcd14701d1bb147c04064c9d50f5383047c56c026bd32d952fb8a9d5946dc1e8e8b228c3416b232e19d21ad0a02b7aab8eec8cfe4d994fecd051cea977f3c16efe93ca374d6d1aa66101d6ce9edafef8e47cf0e2500a49ae2bbd168ee41cf2711aa19ea6e77a29bb7f0abfaa8a2c063b46510a97963e96a098bee8c85cba2e04e1d73c5a3700a669b7f57290f4fff3a992b39d4d36b07cad2ac96047e52883ead6dbd00e00c90f5c8fa72dda311b96ae8327d95029806dbeaf3527e53fc725ac1257c24189a95ba81f3cc604c8ea205852141ca7612ae50555913ed4c72d4aa57dec08e8ef0083b95730550668c6fff9981e95aa727726576eaa90fbbe6c177e5e909f9bd351aa1c835bc622b60a0897c7ec8a3f1c9a552418093612f8518138d02234c606e1ba5fe2730ea8db45591e663ea3862315785a74a6eb3706624798ac8a89bd5055fb4cc1413a92a15b55ed106345d30d1c4d9d0fddf0edbe3b7c797265dee591e05eca992cc9ca9fa5521897e0e4942db4abbdec5c9498b663d45240ef4085e763e3d7b1b2b6a5ac4d9c0c55a5665ef8634e10ff4e6a033762fa764768c9c35ae65c07d15be6aa037d515596cbb311175ce2dba840a85060840032bcbd6971847198f11738fff91e04bbbfa4ee7a9c0bbb46dac1df2a38654731b937cd1552082c907e2376ec7e9a43be10f90a621c98515693293b37e4542e365ec52b592fe06e4234e44e072b46933de279365280eee05c73df17abfb3e631749fdb1035bc0f20a39b5da5a2cd53a82a7d11f5b521b985482f90b88abbdea8826caf2d3c985539debf76dc88291028860e83bbf7748b9d25de2884a49bc6771484a47389699aabbbcd6d5621d804ba4e1abd7a76ab2b351f063211caacf24026c17fc065a2912e98bd1467de1d0bb7b15443f6932ffc104634c6365fce4a79adf437a3e5bd5bf7ec8bf9a511b1ae3481d586e0173d751e97c8b926b9b7711d26113de0f88858bd04b667c9a8a6da5e3d9748b55f06b09969b4599db6dde288543d3202211b659e7fd2bb8e9e2544f55b0a89900fb38f7036a27a4fe4c1896ed7914030b766cd7bc3c477b25dcb3baa870517c6fd755e755abfb017fb25772215b37560e7679e69b032e6f2833aa246b3122f16a37421c80aee26e57dcce797be3e471519913bd15444b6bdc7e0eb80ad9367cfa997cb4e8cadb4a01b0b91259e69900f12f09f461cb6a314b669ba37c938b85262f55c61e1472fab6726a27a6f3eef61fb3fef97c921c332b32af4c6e4cefe6bfc631b385d72438cfa31e4580feaacafe9bec55d3f7001881fac4503e6c58b54b7988a4591058444f8f42e56e69e1fe874afb5699a5fcbfc0f9bf6f8ecbcedd8e1031e7ed5c2ae454c7fa5983e3c142afe44f79af8703128d00912ee7964ab1381818c9b8c3c34699871f640349b16a01ec67ac6640400935893edec5602cf2b9ccc965a185a15567e63f9ac6db73b24775a4ba7edcb13c581d58db7e133b9faf2f2fb78428302516adcf02fb322aba4936346e6eeb39f7366517aa1ee9b6433a3ce0f9bdea0e6d88a369ed49e1e7da8a76ca96adcb0609fad512cde90495c11db5fd40f10fb2087c614f5dc4921a22da19a16d23210482876854f301f679c87c8e319e897720b92f3ec2c3b0e790c6e086e82a8b1c0ea8e289652" + }, + { + "address": "a1fb5292afb33b7d8d0153491de9c55aa6236c2b2adb9f085c54f2996a236bad", + "content": "93846578fa652078b811d1c6560616c2999e286360b7fdd151384df85f42" + }, + { + "address": "a277fabda416986599552ddbd4467e18993c7a1e2e4e321817f62dd32c185302", + "content": "58bccb54fc2a00badf7cd88d34dfd9190d7c5f4502b1b8ecedccfd2f56d0" + }, + { + "address": "a28068f502aa41738121c0f7116fc5c7e0a2f258c291ef60b8325ceed04c07bc", + "content": "da61bd5e2dc2677d864312aa1f7f5e044dd2589616c07151d4f99338a378" + }, + { + "address": "a2e24ba9d6878bb9e6e63fe7eb53be1d07860edeb40753bbf5f1b364002d1a34", + "content": "dc61c4ecc0750129205dd78de4188b24e2eb5bdf8fc5dda0f6b8129c1ba2" + }, + { + "address": "a33bf85249bdf0d4e3ac6c9bcd9a1db0cd0a91878381b26313d35d6e5f137e04", + "content": "76854c1331c58a01c6ec5490ea2cedf01294f26f477d8270bbdebb8ecd77" + }, + { + "address": "a37a346a80e7bb28fdd351e26a1ef6b8869049bd0b7333757b897a7dc26c5ece", + "content": "880c9bb9ecd96daf3bf27e8705a92299e3465c2cf3c5de597b5d69155bbb" + }, + { + "address": "a3a3b4455c9361e7bd1ae91ab3bf5b15017978f0e1c84f68269fa856117ade69", + "content": "5299fc0360a3a8bfa9d5134b0efd794fdda4698611a1c7f65c31ae605f49" + }, + { + "address": "a4bb679a13bf3a51a69dd8434b6ab804410b11f68692fe1b55d40f08c060186c", + "content": "80c6847955de1a9c95840dad71c1b7a2ef6beea87541982f04b39ba39723" + }, + { + "address": "a4c3b1182332dfadb07c461aa4226fbec806fad16585af2e804ed4250155acd1", + "content": "049b64101930a5a8281be83967d5750bb2f17754f9dff0148c3bcd55eabc" + }, + { + "address": "a5032fa20b581e7a0487739cc3bbccfd96b7714857b03a94256169bcb0a7660e", + "content": "94e246421060607f25f495cb0e2b1a79105bcc735f170cce75a016cedc55" + }, + { + "address": "a536590a16d17715015c103bc2c9ef5695f30a025416126f3f7b7025a1445bd2", + "content": "4d307178051dd9c87faf50cd213439732c68b83b314232321559bef52bec" + }, + { + "address": "a56a1984d2b57a7e75958c1492da681bb3b5260ab4b7a03274b6d2aed1021ea8", + "content": "cc54e3c7fbc9fbc04e6c5641410c2bbf87ea26ce36523033e8b9ecfac3fe" + }, + { + "address": "a5b6cd3e235eaefe80c6c4f78f5bd73d45ea135e55402368cf4073cebc8647e5", + "content": "46fc22b34454f71ea2ceb8179fd291648ee82b790d6cb57692b7d075cb3a" + }, + { + "address": "a5fa5d883da0df6a4b0d433dec6b995e2c08543c250fcb91de7ecd68316e34c8", + "content": "423f905ec4d1e652be453456c613b3135ba88c05ea288064b7af396af79e" + }, + { + "address": "a638215ee2b9acdd201f8d6ef7fdd949a84c2a9d72417fe397ad53881ed8f53a", + "content": "c5a4417ff05b7c1768a5439f903f173ee3188660056e8a1d3c524516418e" + }, + { + "address": "a64ef406814d91e58f286cc7158eefee2c08db94d887bbc6061c9b1bbbd3d92e", + "content": "559f67de1de698d22484d0622e217025c34a92d877f9ded6ef0d569fbeaf" + }, + { + "address": "a65df49631c890c729419490125f55f85f72964453799cac5ca09a8441190f5e", + "content": "024358ffe0c58a1cdf4a9d65342e4911dc0c5e6406319607b3be89bd2289" + }, + { + "address": "a70156785b474a98865d270836ff97f0aea7708ac8761ecaefda2f9c9109ee56", + "content": "14718f614f9f86c78ceb3853bbf9b49ae62e29867a550dd1f52b90248800" + }, + { + "address": "a703b04a50f3743a08a9799b1e0e08a0cc7724289b3b2be795e7a9b0ed77c4e3", + "content": "4e38ed64763f3f07f34270b1e66a47cb687e7a011a5f5387961a809a80eb" + }, + { + "address": "a71fa6a4a927ac6a06b935689465ecb3dd9d37b8510ee66efb5fbc574043a6e5", + "content": "79ba05446800ef5317c1cda57028d91f9df9c4454193811b0b5379d6f58d" + }, + { + "address": "a7924316f33b416dbb2486069facc0187fcf4f1dc8384e55a30a903d6c4c332d", + "content": "7880f96e0dc70aaebdb5f8dc040bdbcd65af2390ad76a0f630414f9516d6" + }, + { + "address": "a7970b8786e76ca3811aa79cdc562903b38454b3bffdb9633bed8eac1025934a", + "content": "c7eb1b55f36f1ce4d55bf48650e0243fe9fabd46ed7abab02086ea4119d4" + }, + { + "address": "a7e0434658dfb7a699c7ceb138a8dae4aa9d9d73ba5f03e5d5cc3b190cbc71bc", + "content": "30afab580448ce95d8220030bf47ea34cee473c357d2a71725175d98c6ea" + }, + { + "address": "a7fba900c1ebb85c8a1227ad4d3b32b1bedd2362c5fab3c1947a42ecaea9fbbb", + "content": "a692054f8b53d5bf93be691353da079502b2eefb68b78a5c27bb92979e68" + }, + { + "address": "a88cee7c38c77432ca063eb560458f095ed656161a14e87f72da8081f8519f5a", + "content": "4763e475e075aee47ee70739ea41c7a63081101a5d7101f31aa9ff9420bd" + }, + { + "address": "a8c26344fe864b578d88257e4451500bbc2b10a273b95c0c1a5c32af05b91ec3", + "content": "bdf803110fabdf6f422e78ee88602b21359ccf95d53e64c66b490e9d9b73" + }, + { + "address": "a8d5016285e27bbbf7d46bf6c30891eacd62a24935d5b0c90edfd86f3d35d44a", + "content": "1df3dffadb563485c1e16516e0853dd6519821e7ac1fc0a59343b94804e9" + }, + { + "address": "a8dd9deda0f27ebb784f435688bbf39a05a1c9d44fe5251100fe9428edd383fb", + "content": "94a32e912b234897db81c29e5f8566a18ded9805958d8020481e5077e1d6" + }, + { + "address": "a923a86638e0acb61d6058065a85a971f7ffcdd0b3469eb4f181c2be21a42a52", + "content": "f3617b1160f3c594e9fd45b8c74774403530cc06ad925ff5bf8d6cf5e85f" + }, + { + "address": "a92675bcae363e8222707932da4b31631c42ef320c81f8b25210ebf8b5c0497f", + "content": "a0f04f111426f7862696ccaaba24f73b4b62be419e4f8cea78b827c1db5b" + }, + { + "address": "a944a734bf6bfe972d58fa343313a6fec03dd52d6b258b2c2ecfc3db87fb3614", + "content": "7baa08debd3410efcc15bf129d1ee06a2d596f7192a3f85d5dcdeccd8774" + }, + { + "address": "a9fe624c52402742e554807e1e7e3c68b0d4998cefb32c56ea81660f6f321b43", + "content": "d1a5cd2a43b7b4d218526a4dc275f6e237854f37a8e1bf9e041f963242d4" + }, + { + "address": "aa1f5b95d3914c5fc04bc5f47517e130aa63226ebab64a4b0ec4d77ba0294985", + "content": "ccee715e0154ede08548083590995646f46c148a6fc1827ae39275dc3ede" + }, + { + "address": "aa8b38c2307ea87b6f9315820a48b26683f80b85618a32c0fb97288aeba61327", + "content": "d5b97da3510ec0c2289b11c4f0dbe561a0d3e5c2b99a5ff9fabb3c20905b" + }, + { + "address": "ab30af4a8cc9a8548e9d85e0d09b993d221aea5a75fd82035c28521099d83359", + "content": "54b7410333d94386bcd107bc4f9d56dd2271d48f49ee58bede994ead0734" + }, + { + "address": "ab4d2c84b937840cdf8fbc9b4473da061b9a64bb782ca9f31684028eb586ce89", + "content": "b04d8cdeaeecd80297ed1ea9c34368ba7414db9b252fe67abb57cfc798d9" + }, + { + "address": "abf56cb790ff0d0a57dc3891ed359539e0b65538d726f4311624215c26138295", + "content": "ac861a5ac2054c01c9feb669434290e566f0d192d5ca39485f9802b4137b" + }, + { + "address": "ac138a8c3ecf019c99c7e5426b0d2c20fe08aff1be25fce3cb1b5409f4c7d3b6", + "content": "6b4bbc36b83e07bfcbf28c395c5e36212a1894bda207c93794a29932ab9b" + }, + { + "address": "acf8e8c89ddded3922b2d77903a8149413a6cd21d0b64a839a5e4434554734c3", + "content": "54a37645b65e43bb7199780e4f59376280a92c9dd59922137854f7505479" + }, + { + "address": "ad487b8a6acdb940e5aeec90c0b166cd7534bdc6a47655b0f54e1c67048491bd", + "content": "02bec083b740ff5e41b452f69f48f5790b237db6614d242a634275beb7a9" + }, + { + "address": "ad85a9c96c11785817187752cf3b126fa3f1f08135c7aa5abc88bd93031d7553", + "content": "ff934f2ca9f7278478c547cb7a407548cca655f5a15313cd7e5be9a8ccd7" + }, + { + "address": "ad8cd427a2ab15b606eaecb4f2ea40e676315f48bb46729d81420e4380a67a4b", + "content": "bea14f09601549c30e98328586962d5ca7fa4d342225053459eec87d323e" + }, + { + "address": "add58bb35c2fe6376ecd03fb71aa6c7237498b03036ca6df2ffb24c85e3bc16d", + "content": "9c82fff6b8a44f6d490de18d50a01212299daec4bee3d9bbdc9f1013470c" + }, + { + "address": "adf46ad37f6d09faa2071f994d065bcaabd3e0d1607583761c81bff248df9ab9", + "content": "7da9a806803c1d9d6a6f5d19cb2ab79b3943011476d47566f6f456923532" + }, + { + "address": "ae3fa2d697590827448fbfbbea4e88a140bb21b38016db8b2e611dc277ab6f49", + "content": "029e79b96a32859c71dbfd76371da838b48e24619fef7df0a3c8efb0e2a6" + }, + { + "address": "ae4f1ff859266e08df5f982bf785f1d77e676bfbe39a5487c7630959ed6fe6fd", + "content": "0404035e34db477ac1bab4c9281d98a69d6088e56fe187108cb68e019f6f" + }, + { + "address": "af3350a3ebf08dbf77614262362ffb6883c8c6471585802df158278d1eb6ff0f", + "content": "a81ec117bf91ea23de02491ee0b68caa0fa393ec803fb77ea5cc1b0bac89" + }, + { + "address": "af536c6e6f964a2e5c67158477dfeb2e3bbf1cacce4efdbf33d3f18c67aa26f4", + "content": "aeccbb4fe3ba1c3f003e617a0c242264068bbe06971129540bf0868a6741" + }, + { + "address": "afd0daf901ef82edd5f759d067ab2437700d67d8594d07fca60d0aac7320b16b", + "content": "4122e254857152a7aa293fd27c004d12761d73f60e50b70c1b68d9ef6a62" + }, + { + "address": "afdc54f0d72630b464318916f87bb5db3a6eaafc398dfa6fffaaadc89c6c6567", + "content": "fa82dfbc6572b91f2aa50fef498e00f9c564e14391bfaac5cb8c26a7dc08" + }, + { + "address": "aff3934c56dbeec53b70492ded80797ae0b96f860bcd0a7f8bb28beff287f0b6", + "content": "5c946cf5b171cee61e7e96187487d62fb22b3cfdb71fe4c69f6582bcc1a0" + }, + { + "address": "affa548b1b199c8e176438aae10b4cd3dec8740051b2b01d2a90d44d6e92a9e5", + "content": "0346f3ba5cfe7c9aaf73f264410e8e34f8600c262c34ab4e266d2232ddaf" + }, + { + "address": "b05b1910b7e929903539d527eb1119c981abe8aa8888ed5d7e3b82f43437fb33", + "content": "f562b783c057bf7ef436b8caedbd0f979f199c0d469a464b87164864da9e" + }, + { + "address": "b0e8a56585fda0260159184a2eecc969f6f583b7fd1f128bd4b7229c2dbba3f0", + "content": "3f8bf69bc394c067c39752f714079a10a43bd1e59ea3ffe1460ee6002eda" + }, + { + "address": "b147cafa1d20c300346b5dcbf7fc852f1924bd4b83568083a84c6c64003442e9", + "content": "484352cd141ca15e1d15ef3501f40c5b910ca737971654b72e1c51043715" + }, + { + "address": "b1570c2d7b825e4e881643930fa356b50e9fe460ae5436cb4f270ddf330b5e98", + "content": "c4071e3f06ee80ffb0c01145178cf7dd4a940c6a39160aa1649f18ac3f1b" + }, + { + "address": "b195d8cf713f5f2df4ddca9d460104e7ca784bdcdcc7906d59361450620b7be9", + "content": "255d8d0d7cfa0a2606c9eb6ae1ebfae4e6422cd519bebb405ef149d1d3b7" + }, + { + "address": "b1cddf5ba5c76474a099552e0ec1c25ec55429faf6dfdc6274a9f42dcc4f0e2e", + "content": "2d128f1c2107b853bc69c8d7ead62ef89a6c629b8404c99a291e77739b0b" + }, + { + "address": "b1d8b2225031d7252833633e9f2673ae4c088475825631c0415b3e7cfa4db461", + "content": "d43b9ea50b70e460dfc27ef645fff641e840c7d82dc1c33d9efd6ff75b76" + }, + { + "address": "b1d900b35e96bc1ca2b5ee62d72d7a352d61be814b104ae551390307ad4136f2", + "content": "f6729abb0a3540bfa8dc26b6a8968d8d9307470f1904a8c1b42bd9089802" + }, + { + "address": "b1fc1c42260eaaf3a730d21106bcb6e1f5b66b808fe777bf79cab971b3ed08b0", + "content": "b737386f866b6a41d421937c6d871c5901e8810106194a85b20a5a356e85" + }, + { + "address": "b27bb528251b6aca5790a53a8ab82ae0dd4e816a2b2df2b699e22e140efa844d", + "content": "11a67d4379d01be20eb60cefb1f327e064fb62158d801b0df99c87e75bbe" + }, + { + "address": "b347de8995248eaad2d5552c22c644227f2a11b4f9924eb5ab578a39c3945ee2", + "content": "faf0f88b59e8e7a438e4a8310f4075a2257b5138fa0b5f07e53f05dc6e43" + }, + { + "address": "b3be8e9daa9492032cc2a2c6835968f1b12a5d6a7e6e2b2ddadfbb3b4bfd0aa8", + "content": "a17c26256278a8f3ca25b5f5b90a5874e196590faffaa69f7315ab832eb4" + }, + { + "address": "b3e82d4a26174e8eb523437b90e62acc2b70aabcaa5e7cfdff7eaebe388e8f55", + "content": "eb538bf6297232a22ae09c1aeab1908d1ac9ce60d618bdaa51dc415122cc" + }, + { + "address": "b3fd41b20fdc61276627f1de436c03e13e94542c424bef49d900e8e54a90f873", + "content": "8ed203ea6bfe0b1d9e146cd6cef2b056e56979303535a795a1379d49f5e2" + }, + { + "address": "b4121392b213bd2af7cafdd478737ac3d7c36ee74235c07a9ffd0d7889041f72", + "content": "a88a8e258fc0e7b1012048d0ee90961568510c1a7242d6d2ab1573660273" + }, + { + "address": "b42341db7912d7e631fcb8508f9f52e3ec1b39e152554bc9690326d9a2ff937f", + "content": "115582fc5e1610fc01db14f8ab8bec5889427fd12ad48bbf84ea40f27d60" + }, + { + "address": "b437ed9f15cc711c82b9461e83cd28022c97694b67116f5b8d4df7d1b4153420", + "content": "41cdd4825925287690d8b1880747a56e6ac228288f703c860f654025eb04" + }, + { + "address": "b44b41226dc6b825a4e51c5f2aec0e9c6eb1d2cad6d0db4342306dda91d3817d", + "content": "09d46d47833576ea1d3bd2f86e46731c0773a5bc16e6b1d0b3568274592d" + }, + { + "address": "b4d997c8d59cb05a2f47754eb63d15daa642d3f49b6f092cb0bf1d1754142c3a", + "content": "1e757bd39eb3a33f151144b4b12524750cb0889186658b6f8a7cdfc1c878" + }, + { + "address": "b4ddd4a5080e3571bdc93af18fe8b4569bcb44883f258ff00da343666a6f3819", + "content": "54b8a59d7feffddf99ec6875de6041f379efe5b804bbb942d824f2f8e889" + }, + { + "address": "b4f490407cd3cebe88a49c04bd3c9c137d29750ac65e3ae4a6d5ea680f1e979c", + "content": "293c9db0f3a043fc5569c0a94805ded04215d7d0b5bd6d92d0c495451fbe" + }, + { + "address": "b67b9d3fd12e16626db483228cfc49a55838ea14d68f7ede8bcfd2fa9a79f424", + "content": "34dbe19034199df1d6cf396f23e923b6d58ab50adac5bf20f3501503cf83" + }, + { + "address": "b67d9dd7b4e3305ecc5287aa51264017534e52c26662f2834d046903c67e4889", + "content": "b40fabbb656cfef0df25d3c2df6e3e8566be503e87195e94a398dcd505d2" + }, + { + "address": "b6bb2c2274e5be86419bcaaa7fb8b33ddf44066bf3a2c5c426492862a47c1ae9", + "content": "dd6cc279b6274a5547f7e61d12576bb6d651eced0ee987244c21b2805376" + }, + { + "address": "b7416be1223d4326c3674894ee962fb4ac0c406541b8f0cb84907ba1a1653697", + "content": "428388ce2d012c02e13ede6095b5aa3aef17bb67b22a7d0cdb2b1bf5a37f" + }, + { + "address": "b794051f78e48fa9be17258a77a5ccc2f64e0a849c653eed1bb58cab80517726", + "content": "bd2c51e3aef950d4663c0618a4dc3caefffc7cb9d09241a2aa637ca72a57" + }, + { + "address": "b817c64882a7989b51d1d79672b73e5f2fefbe7be1d96232cecbaa99f55bba4e", + "content": "7c10ac2459b71a5ca33b9acaf28cd8d475906d284b34e5fb2b393304a410" + }, + { + "address": "b86db4e69a9521f8fb8763d09ba309e75c472248b7e509cfcbaa66f1d4cf7d81", + "content": "d09497ef57c7d7d6f8d7e34bc1a998f6eddbf738e1cc0ce73678ae6e06c2" + }, + { + "address": "b8886dda0327366cd09a2db4b1e8c579d87a7371c359b0f62363b7a4264657c7", + "content": "d663783d95cc7bf5fb40a063dbc922dbde936382f63cc996c6beabdf5345" + }, + { + "address": "b899502566506b61105179823b01c5c6be8c2cd9d393f019548da15b2981388e", + "content": "09ff204dfd59b587588a390a70b1e26b10bac77d0111056363d8148ded3c" + }, + { + "address": "b8cc3ae8eaf4ef783ec0cbdefd626ac92108615014c2ef68f5c5d62d8244044f", + "content": "26bd56f302c14fc3b7dd4ac1008897b8b4cbc6464a142c4c61acabfad74b" + }, + { + "address": "b8e02b31732c2932897abecbc8933d32e88ccc56fa4c0983ed772c10b6de9407", + "content": "d11379dd6dc668099328d482889061970d62899f4178713c6a908ba4fd58" + }, + { + "address": "b928dcfb015e88ddea58f81eb047e43da324773b759f3d812a1244e03cfdcc1b", + "content": "25920f48fb23498415f413c5e4b37735623a96a43f425e5009ab6d941b11" + }, + { + "address": "b94710fdbb5cd19e2eb8c369130756e053224733b6d2388a9df25c0740c4b321", + "content": "7b1bacbda4ed45db7411be08aee074580ba68cb7a467ade7c4411a795ece" + }, + { + "address": "b960f580bce132ebe48ff2114e427cb6ddb20dc410ef2ea998a67d564827c52e", + "content": "452c890fffa283aa3033f2bda4ae5f50dce4f56387eacf7be791455618a3" + }, + { + "address": "b967c315fd428941dd0781370e3fdd47786e075f4741f77cf19967f390db952b", + "content": "0070d6f724aa67419bea82c1890c5139fbe4561813f5a86510c7b7a7ac7e" + }, + { + "address": "b9c1acdb369047f8daa4180c469a2e242b89ac36fe8cdb5bc2869721ecdc6236", + "content": "e1e7dbc9262d6ff958b8483cfa973abae166b1ad538454d28e03668539a4" + }, + { + "address": "b9c567a15090b0547f7ac5fdf55687db95c80209c9ad26e2a5688d563e4afd0f", + "content": "7227162d78ceabe46f216f0d2c16a6194a5ad66bf99996bf60f5ed5e4d9e" + }, + { + "address": "ba15b2615424551de3a6a6162cd5f159c92ff66f61cf02bbfcf6c74029404c45", + "content": "bdbe1fd82fd138433cff0b4d3583693f18960c18ec2b09d65a2d453286ee" + }, + { + "address": "ba2ed321f712aa70789204bdfd197f491b59f9b5b232b03e0dabfbecf941cf3f", + "content": "55b13085a23f7c1b9165ca03dd7721b95351c71b814129ea5524edb9d710" + }, + { + "address": "baa3d7891cb569010ee8ea9ac34d2abd896e3d4e466cc2d3f3398c005ffd28d8", + "content": "2f52aa9532a287a229ca3ec50b2609f4c871130ba0a6733cf32ca9534251" + }, + { + "address": "baada8ca61a116f4ac8a12ddccd04552df75bc78fc502a78995ffaa7e73bac9b", + "content": "7426360d127bb1425c624315fe99403443e71ac88c55a8f6f63edb2ac921" + }, + { + "address": "bab4c7267f012136d05b1e033a2fa89eee6826f3fac8f6832bc2fb35265dc099", + "content": "936717944a61dd70f1e045ca3e3058fecd4d844f0212bfa9416bb9e1c059" + }, + { + "address": "bb1a8ba22650fb1c57785b1309abb8fb168ccb941b6371fc6c0b086a5a3e5b1a", + "content": "6cb49b2f426cbec0986175b300e58bcef9adf01cb6dee819df9ce86bd894" + }, + { + "address": "bb1b74a3ce2c17a714774f369fd33bd48c05dbadb0740b8bbe2fce4e4c77b97a", + "content": "f5bb7cc7d943a278bbab91a2660ffc42be37c1871ed9295339e27cb69e72" + }, + { + "address": "bb7a76a8219e5c535857ba9517f0baca467085efd92989abf804a53272f3a74e", + "content": "0c4548c44aca95217fe91d5ffbc011e6f480c60d4a69f8cba4ad6452cbb7" + }, + { + "address": "bbdcbb13f92e3afa2fdd82fb1dccab47666cbc18596b49e28c913d8d1793f079", + "content": "c396f4d798d4d359a41e1e223598a75c103deddcb3f09fece2e203f2f6fd" + }, + { + "address": "bc05785ba1954b3ffba88410c677a654f8713f1a2b43a74fd8c13699f40f70e2", + "content": "2e0d54598cc669588f00688fa7b9e6429e2b1c8fb4b8a7c598872346d954" + }, + { + "address": "bc21663bbcd74b2ed86f2e4f2792634e8dba45e41853b9dfb671ecf1a35bea82", + "content": "511da475af0ff4288e70974fc49742cd25e4b71c43dab49a97a85e77202c" + }, + { + "address": "bc3058df745058d84039f52ec141e35b77cebe7dde672c55c1402248c6e526a9", + "content": "0dad07e5986cb9a9aa67456b868992518d42707feb90c366dccc7c24ba80" + }, + { + "address": "bc4efe0fda1c706ab4a4f2f8f9e509e2c6a77bd26302983fa496ede3b56a8274", + "content": "d96dd4883a70ad7c4824d2e4474fa32402f942079ab9b5c437485ce40129" + }, + { + "address": "bc9fb7ac57d754e80d310021e826b24e4d535858fc8968a39db3ecccfac0a00d", + "content": "554c91709a98a54d7eb7b61889622e4d47e788aeb3ce56144ee10b0e11fa" + }, + { + "address": "bcca412953178ccded690c6b7ffcfc0ecd6e846a199159c9a0e8497f9bda5e6f", + "content": "78f4dcdd9df0aa7716be8ee988c57c4a7e7dedc9441916f9daaa7960683b" + }, + { + "address": "bda5bb45a8e46cd0a9f091a524ddb09f0237a621d436545667acd4538c6fc486", + "content": "a12549bbf94f9a1326c3ddbd3bb9ed66b760dd7fe0b5ccb50e28ce6a2c04" + }, + { + "address": "bdbc089d095dd79f82ecd6c0e8aada21375a70e01cd56bc1f2b66f8154437e3e", + "content": "4e2b74401f2b878828416161b5c5f35f28061e22f3d8d3e45a24bedc7f35" + }, + { + "address": "bde54bd75229aced521e46c852669848e93e21759c864f4e3772d8131ad82586", + "content": "3e11d3a543643568d2474060b688a4a14dd8fe60f36abd5d25dbb49c4194" + }, + { + "address": "bdf831b9fd46588ecd85bd7563732d44430ccf6fce27e81514a00057881bca37", + "content": "9301939400dc0020ccd5cc86cceb2451cce2ccdf52ccb4cca1ccc950cc88701b69cc92370cccc642ccfccca24e01cc9dccac427d7422ccd2dc0020cc8e78ccddcca5ccf2cc8175cca259420bccd0ccf2cccecc87cce7cce97accf8ccd341cc900cccd359ccddcce57accf2ccf047cc8fcd6b189401dc0020ccf3ccd3cca8ccb6ccd361ccf90e1f0dccaeccdb683e41cce3707dccbf22cc855fcc8854470fccd6ccdbccceccb8cce80ddc0020162128cce9cca611ccd11163cccb2c1c0bccc475cca2ccf1ccf23dccbe7ccce5cc87cce5ccb1682c4b2739617ecd6b189402dc0020cca1ccee387c3747ccf003cc92cc8642ccb6ccdcccef37ccfcccb5ccc8cc9871ccde7fccddcc84ccebccfc6f6dccd8cc8dccce11dc0020ccf378ccc1546921440acce4ccfaccbe391027ccdbcce6cc9bcc93ccf5612b082ccce1cca635cccdccb2ccd83f2466cd6b1a01" + }, + { + "address": "be228dfd5376ec8bcc5ed8fdd47da40a32c1ba1762c8f1eafb3a66d26a84ba61", + "content": "967ea1d354b54db148d72de67cd32a0b87fee051f2cda8ef46f94b502939" + }, + { + "address": "be51affdc30d8a3bd09f997a3f5edf7dc203b745791a62289a58a769ddb6563b", + "content": "0736e5adb1339be4c360b5cfc229ec4b3b907541383a51caab31c394960e" + }, + { + "address": "bf332ef76919ded549ee933a798959f3e66de2e0747869ae1a5e5899950fa888", + "content": "1dccde557e59ec863e974c91fbdfe93e0cbb1d67169a3d919e3979427898" + }, + { + "address": "bf76d9a31fb8d35c69af9429d6e238820e223f0a0af93211defe3585c4d2af91", + "content": "e5f1904df496bedf8fd0587a3a26cf35ec9a4f27fe67a2c73799e8cac640" + }, + { + "address": "bf9e726d1f93bad43a12726006c9c79b3af286bebe9c3f6410ef025bf81db80f", + "content": "9f1448b9d3ee69ba42e82e8d0ea7c19b89473ddee621de82653b3c831df4" + }, + { + "address": "bfa941efe07f4f10d34770ee9191a82f40c2bbd3f89c2ee76714c844c0001b90", + "content": "6f5d814c1f353d3b4a18d692354de0084dd24ce4791ef631b72bfcf4741d" + }, + { + "address": "bfa98ad76f8b2f59189aa8bd17fd95f2b82a77011f00012ea1be5a11954e16fd", + "content": "908f2e17a07d894c1df1f4fcdcbb2b18a5488111f8b5ec39a565403c4f54" + }, + { + "address": "c00c332f4e1f1476d97c22e4a7a0c18691cd12a6f8d706ed444ef68898339f9c", + "content": "1ad766d11bd16e02c792f75d8f41c405beb365fb32be859a784c82583ec5" + }, + { + "address": "c018d2e77ed7a86d825ac42296759a9e8779a0d9c1f60bfa373c7853a1abbf3b", + "content": "9bd841361e4604e6b91048a2e03818437f0031bbf5369a51b1beffd5fe01" + }, + { + "address": "c02ce5632c31963505264c222639a318e6e31984375530c7775ca78a845e0cd2", + "content": "ed4b8ae3eef958cd025400bf9a7681ae08233b360b98571cb11dc9ee866f" + }, + { + "address": "c0796199cc2d700c94a231647478683db987771a90b9a355a5be13295a3b316b", + "content": "296892fe331d28f26024f6030e9a27e266cae63aa3ab44733f703c304421" + }, + { + "address": "c09ebb8a2d6bcc7b7e99286584856df5882d5a73341b6f0da20f80577b4a01d1", + "content": "bf7ccb49fd909855dab180ccb10c17d75cc433465c6f8bf8d6323965ebea" + }, + { + "address": "c0b9f7bcf938fcdfc799da4ceb8cfa2992c10f8900524acb05d1b8e60546bf06", + "content": "4173fdb56fbaf9833f8e65216cf5cca55fb0fedf42478a6c6db5a92823fb" + }, + { + "address": "c0e5ff29bb445438e1c749d31457412a89bfdfaf1cddb1da36c24cbd7933f583", + "content": "4a7673452b851ba3e71a6476042d4af0bcdd72e7e8863d2b906b9fb3ae90" + }, + { + "address": "c11eb3b0a31f487a73169153e39a4712f7a377ec8895d3f44c9196b9c632af51", + "content": "ad3bd9a41f713921acf0e603ac81aedb2f7a3be98859310966f325b6f6d1" + }, + { + "address": "c13e7b0d3d9dc44f961c1a0f4ccf213d02884b0f68aa5e7718af096ab9f42081", + "content": "722d54a0dc6189d9a603be0929641a9ed91f316c21870fb460aea4fa7b35" + }, + { + "address": "c160f4e946fb5d08c40165b7666b72032e330d9efec65cdb05ce0c0311ff2bc4", + "content": "77a8fc528df31071a75a9b8ba494debfcfe3714a8baa7e02075e4d9536c5" + }, + { + "address": "c1a281f418284f90a7e622b1e5752fdfaeccb88859fb289c56b6802a7a0751e0", + "content": "c1123a32cd383ba9bee86baa5dbcd88720560a0a9ffc5f8e138751e7c5b0" + }, + { + "address": "c262d4ee8683c4c2b47942302102141af702e1a9ff664e48c15a52f6914636ac", + "content": "303ecd9a387a0a00a8cbb40707fdd524bb21eb10feda5e930fb5277b7a10" + }, + { + "address": "c27ebfd25d213286f48df2b27f5200ff45eb0c601eb52258e9621faeb7206a48", + "content": "ce605b30297662c3e3c3475ddb44bf1b224847aa446191fce7a0e147f307" + }, + { + "address": "c29031f8a5667646b84f1bf062494f601eed7c976d9e51652bee62682930578e", + "content": "3dac5ecefc95f8c5f5d5a12b19e37d6032214d1f3e8b4e52f425dbf7825e" + }, + { + "address": "c299693bd12dd568512a2cb570dc32fdadb09c2ee64eee9888f6be8c536b97e2", + "content": "15eab03775f4d8932cc4937c843e568526f32cc05dde3d76fe7605785ca8" + }, + { + "address": "c2da58fcef50624fb31fab57bc63cd233329d77f207d72a4abb3864d7cca884c", + "content": "a5aa2401bbfddd6db07f41bd1347e99216d899f7919268550f1fa39099b4" + }, + { + "address": "c2f6b48d39027d5af916701347d5efc7325699ae6b149c2aa1a3aef1e36f3280", + "content": "7a747328cbf0665e7645eec7487c593a95efae41b1ba104f592f6587f73c" + }, + { + "address": "c420095d15be127c3b929bf3f4e86543f47c5498093d86c9ba350d9e1059c28b", + "content": "f13578bf40ac9cd404a0a7739690288ee59b71548904043200a4b50ffc42" + }, + { + "address": "c489465a499976aa9443017e147d3a3d5bfea59e529e93dc7d1d605e77ad1ee2", + "content": "ac8a4e9ab07f08f4f96d9505940090c0a5c2a4cd5fae04f73e06545fdbbf" + }, + { + "address": "c50d442aeafeacc9d287f2b40dfb021ef2f506eb335f1133376921abfe6bf509", + "content": "fa63502332230464b4cdb2db85c93e006a4e8fbea7ebb0b9df0843978a5f" + }, + { + "address": "c5182d697a493d739cfd902626ee9c4ead7bb738e2adfffc40efc6633d8befde", + "content": "981427de24fe49c5650d2877607f9b3442a9aa15a38f72fa91d75a204be7" + }, + { + "address": "c5406a14745dd499e57a7b217ecfa9940eb6057a66637594cc3522312dc22048", + "content": "40eea8058b51c4895496a2a72d8f9324592f1d8da1f89bf3c6de88a5fa53" + }, + { + "address": "c569a37c538ed4382efcefa4272ddc9017e4f7f88e25540b4fe12b40d81324da", + "content": "a2e3a446ced8b150db80b0f79fe0b6484dcfb8da8280067590cb23f901fe" + }, + { + "address": "c575b753fdafb6b7632bc62b6d7071ce2bea84f1282fa2f7b3e7cbc4e6fbda09", + "content": "8de98d9af9954fdd2fa11755ce884c79e15eb01d99e7f2f7326e56b4a277" + }, + { + "address": "c607aab3ab6a671fb0ddc20ba9484feac1b559236363d898ecf1ef608aa26b0b", + "content": "a29f59de756748c7c738a21c27cda34a0a745432e451a50b7d9a1efcf127" + }, + { + "address": "c6479ed7b5c71adc221dd112d772f60c80af13ceb371e739ff4b4cc58ea29c03", + "content": "e897ccc42761e4131a9008cf8345ac81775ad506cbd106c7058457b12cb6" + }, + { + "address": "c6509542a75a40a04a407e4341a8df3202afd32a7cb26e5ad83db6d3854dce8e", + "content": "0789f7473725a0be333766bf272eba859455bff968436540dc045aee99f0" + }, + { + "address": "c6e8792936a4c4d9445355c8ee375ad4fff0aab2fa382e3dbefc5dbf7fe6e42a", + "content": "cefb5b938c025b2db2b7843ba7db1130d83323cced1a16dc61518aa30bc9" + }, + { + "address": "c704d9cbb3350c5fa49823a6499e2c7a9b72adf6970c82e85d72523719a2f223", + "content": "578fbd374675067cb52b76356d9b5d35a7c1efbea8969f351afe202fe93b" + }, + { + "address": "c722e83bdec50bef8238632824b3f9e8c709ec1dd758ca65194e3a43499c3cfe", + "content": "f3ad251e1061bc2711b507addf27c5f2abe859c41f8a954c214be01cf117" + }, + { + "address": "c72b0bf0012edf8d66afa026ac4897621484477f1cf00586d16959b67f81d238", + "content": "0da104fcbb1cb15d7623372ed30de8a77184a80f30adfe5cffa664ca584c" + }, + { + "address": "c7635f875070ee76d782d068c0076483d42696f69bbe05472db106da610165ea", + "content": "648dbf5346b580cef3c0b334f99559d9a282e990edc592251b12ac24bffc" + }, + { + "address": "c7e29ca11dad4d7fb021b79d96792ae75af7e0992b0dcbae09334b9a8d0f455d", + "content": "cf2e87e6c66f2e785b909abd8b13c61f07722285a148c08c724f56b503c7" + }, + { + "address": "c829ac51c9f5c1c31b985ee2f72ed8b8c0dd6a067c742aee2341b4a1b06d7d11", + "content": "0c2e9f00cd0b09ee3a321989726acdb14fa892e1937fb409539e53e000f0" + }, + { + "address": "c8abb0d20a9b3ce074139d6b0ce0fe77506eb81d1919b1fd37debbcdcc6fe91c", + "content": "8a07a8cc383f214b27cef31b5a8b135eccc1d21a9a11791aa44176b865d1" + }, + { + "address": "c915f4ff4c90d36bcde5c02bac2c1f4fbe800b8ff7b9a5f38500d839ac0bd0ef", + "content": "069b50c464be645cb23260b42cb10d4167e93119b27a90c35691bb101f4a" + }, + { + "address": "c948837751003b68c97e8da73c032b263f110f79e6fa822eecb7a6c68cec90a6", + "content": "63bd1f4366cdce81bb4d1479fb93fbfbf3840f79e00a530c45602b192b5d" + }, + { + "address": "ca405c22dfd32323f3bfb82bf0c3fd381a98905c10a343884cc14a87d11b9d66", + "content": "7defb2fc8904599657367c272abe072223790eb3f16819e75f8df1ac2aa3" + }, + { + "address": "ca927e0172991e726998f7e43a33c157d6c1bb0059d1a7d3b081947055a9d782", + "content": "12603041fb1760b1ab4f87b2bfae31e4b056776beaae6547773f5f08cdda" + }, + { + "address": "cab3b1162d89c7f16b6ffbd2a73a82ad4c2b0121f6dbc2c83a1f06383a020fc0", + "content": "cf5c5bbac78b473dfb7676fcc986ddfff3fb68770ca49906c77799ce25f0" + }, + { + "address": "caec95d53b8b8fd671ec89253d04e9ad0c2eebf43ed5825dc7fc7d30bd6d956a", + "content": "e41dc62a88a19520051d2ec9f64d2644a1ee0f031c1add50fc7ddd9765df" + }, + { + "address": "cb25e1043e7e00d523b2cf9bc8f27e65a242c8ac613b59a35dfd0ae0d28a9329", + "content": "ea604c3648e19fa1ebbe10415833ff4406e70a4887e9091458e004c4b21d" + }, + { + "address": "cb27dc7c08c917081a947a11080b9ee597c0067a347a068e27d9b931ee49e221", + "content": "a2943167fffabaf10cf37a328e19fde49f73366e4b0d406c139e87c8bf22" + }, + { + "address": "cb37694e1e6c7141b314a1e24be72fb5118f08aefdd8c68d141aafcc1297eaaa", + "content": "46b454a77fc2262f2e6fde927a1e0b7a609dd66723bfb63b4a40ed50971e" + }, + { + "address": "cb52240e61d45a73560ec64470b31d59ccea0b58e74031cc75ff0d0b1fda36e0", + "content": "71cad34c1b89fd9cca24181f42e726348acf71e5563f0cf5c731725988c9" + }, + { + "address": "cba274610007f25f312e59b10111b4a5a2911029931913dffae92dd786d3a087", + "content": "f57877be945810a173bdb85eb4102e28dc0fc8bcd16039b6871ce8d202b3" + }, + { + "address": "cc10b81027c9636b4e27c0617293d0f110097b08b64763a66b063e39f19386b3", + "content": "eb445c1de99f83cef72832a402d0c84d58351861b0343e624a9027904e08" + }, + { + "address": "cc2f6f7d042100b1e94c59383c5c5aa02752f7c45f29d03a2cdf526002b5c48a", + "content": "a50d2a85376a5ed4ebaee13f0c957fac98fea2a9d3008d4d9358c765e086" + }, + { + "address": "cc3c57b94f043c14a2f2efa9e685ea6a3441a3bd3374a4cc2c3bda42a069d9a3", + "content": "e24b36acfebc4ddd4829f836ec548f7024b2f91414542ee9475f669bdb15" + }, + { + "address": "cc76ba88c522f85a4c0827071e51ba14ced883bf57cf8f9e548a48da611a2772", + "content": "895c8e4fa102660ff2d23c6d4a9d842c65e2af366309a24f16d1d19b81cc" + }, + { + "address": "cc82c783144993c260e7ed1d8f982b35b9c245a684115873d4c74977463444c1", + "content": "ca3fe6b10438143c4764f760a76809127078f6681126cf95e23342d986ec" + }, + { + "address": "ccb2b4863d01dfe1558eca9f988a24228be5ba895e401462b3e5cb780657d900", + "content": "6baf055641ceb61aa4e705c88db41f8d2cd01a6eb4c0478a921c9c3a4437" + }, + { + "address": "ccdee40518a75f0165c490aa01b97a99e24fdb5554d206422ef43178ca47b85a", + "content": "66d595098604c15ba5099234bb7d3c2fa197781d4c58213e2ad4f5b720e1" + }, + { + "address": "cd35d555472b37cc61dec9e8c9625d2ebe2818c035cfd0eb9bc06717bb19bdaa", + "content": "25b05339320f30097232d5f60e74ea4665f155012cb5463c6a6d485b942c" + }, + { + "address": "cd4e71ccd2811511844ceceaede7a93dd3e21779ac8b30884be8e401a2658ff5", + "content": "d1cec43b17920ddf27e3cd69bf39e383da9c3f0d637ceab08df7feacefb6" + }, + { + "address": "cdb705da817f4d9b2a9840ff8999a0d319dd946cc030989ee722170684cee4d1", + "content": "6be4c84ea3caa75b72e03b5472215cdfe0c7f5e33e29ace61e44d6ce8ee9" + }, + { + "address": "ce0ac8edfae37da7b97e186beb713ef33583abbedb6b32af180c0d7292b27923", + "content": "e92c451ed36fdcbcaf782a5addfe338575b1b9c17313daf2cc1ce9f7f976" + }, + { + "address": "ce51633ecd24fce75318d295a8b543e07c0a501bd6ac38d39c1176acf4072cce", + "content": "0e80387cd54ba8e72ccd350fd1e46ed2c13ba745bf932c50e3b49a6ee2ed" + }, + { + "address": "ce62ac345e4c5289ba4b55924b395e6c6cca906a1ecfa8b9958147f1c2431dfe", + "content": "fb1dcc844f7efdf4d7879a8bc440a71a5033a2025b13069d890e71e900ff" + }, + { + "address": "ceba63c3c0891f3853fae95abb48796e73c77340a02eb3d0895092cd263750ae", + "content": "3fc875e26792dd1e1808f7e1078ed3aa960f3a562117f842841b9d61006d" + }, + { + "address": "cedf19d9bbdb5d5dd9c9917d92c7fd63f2b8c932f5355a7cf42f0f5d62a0b48f", + "content": "a7cb39e3f5b2faceb4b87e6584678132f09ddf4ccb73b7dd21cc2cbb073c" + }, + { + "address": "cef735ac67a6058eba562245c714846b0e474907627f65ae49293332e351a4b9", + "content": "b6477f528720a68026f3c97b23a76493d840d3c943d2a6888f1030e416b0" + }, + { + "address": "cefc1b1d690ed4f3224a91434fd5d08d9d0015f4f40574e86bcdea843631cf72", + "content": "6ca30c9cf163d9046ca6b4ce8b1fd8586caa22d00741cb96ee851ce1b02f" + }, + { + "address": "cf7143900adbb2820a9b1eecd9678fa19d826384ed09c16552b57ea35b32ec71", + "content": "ba532ded19e7604471d96aa78f1bfa500adad922d753d7c9d1d017f85ddd" + }, + { + "address": "cf7596dd4dd04917c122ed28c4eada52777c040e7e95e575fbe80b17070f0145", + "content": "59ea45bb2a436ae1723bb25ff63161617f9d6778692569a7b8bca4c7b9fc" + }, + { + "address": "cfa48f2453ba43bde137a9136390aa02ba2bf766c37032cd1e542b10f0a339ff", + "content": "eb7ceb0c2a18a94445e752450806175181d865fab8e6604f0dd9a83d0089" + }, + { + "address": "cfc6d95e724b82a1309a3a30574057bc6889e0672a7cf76ecbbade0fc59411db", + "content": "46a16ee6c5727debe55f4e0882d18531bd9c9a5179c30110d79c5b07c9e9" + }, + { + "address": "d05e1f4a0526402a7ae80450a411a5a9dbb1bc041a4aabde814e07010c831929", + "content": "473799c8ea3250155cb72fba433fdfacd133532f081f7fbb6bdba5009678" + }, + { + "address": "d061cd0f707fbda84f9d59ab23cb00bad85b10a1305effd793bf2376867c6338", + "content": "cd06516cf709b920cb731fb2876a6d6428f7d2db7ebc3e4cfefc0891c75c" + }, + { + "address": "d0eab940f5a37792b5218d02cbad2ddb95056b670abac5496fa85cb279ccb5e0", + "content": "cc743f1860a86de0a38a97216543fc3da4c81f25d69a324b9b0055ff93c4" + }, + { + "address": "d0f6dbc378cfbbacf2963fb48db31f1ef63754652a8fe956b6d784a3645e5160", + "content": "3c5a85144a784024eb6bb2efdfa11258a9f951e06fae80612c0cde2bba66" + }, + { + "address": "d1b50159e68e76283826289117f4e37ac839fdd152e0a7d7774334d620f3516c", + "content": "f7e03cab017da32da69d1cd53703f805c42f9adba7c95e606cbb38465956" + }, + { + "address": "d23367b44d517c9494c6785c711500b89a84eb93eab80f4c8d57e263d4fbadeb", + "content": "dfd1a7065baf2608792a46f5bbe4bb7d8a4f7051b8ac7696686ac62b4c86" + }, + { + "address": "d239656a694963863d306dd9ccb78810882292da00967784da130e75961a5d11", + "content": "260121fd39a1b4caa887e2156e841263c1e8e7510fe8b74807f083c0be19" + }, + { + "address": "d24fc1a782a8e60b6ecd2fee82b80b620ad6fcccfd83c95c0c397b4351db957b", + "content": "0ab14e348bd7935cb0705ec315151030f9d2897a1a9a3f8ec2a77d8219e8" + }, + { + "address": "d253bc310ef221bc5a296adf0a01593f25291b6b075f77d5e6e95a87cc2261d2", + "content": "e77c427e1e7af4ad3088cb91bc4926dd9b4e80409354bfa52d9c8806afd5" + }, + { + "address": "d2c9fba43aa5256bcc9c991c3a7e5af3ab13eb98295d3c3f95963871437f2eff", + "content": "26b0a46cc0ad4f7cf92df3970f87aa212b894ea235fb8826e92c490bef6c" + }, + { + "address": "d30b2f40bda5d7cc6917d247ade7f3ecf8d7d9cbde18bcaaf9929955a21ee547", + "content": "3331510b95dc11bd4b9323b18e1bb435346a172f0a203d8158e04cdaa7c9" + }, + { + "address": "d3d544abc0715febb5b2c7aa1acd86d471077b7143a39f9c0cc4551ad17db033", + "content": "821cd243bf13e82ac0fb4adfa34a59f6f3939e2e3fb733656749cd79662e" + }, + { + "address": "d4b729eb189546eaf8c514f0f8b44f356687a229c1642224f87c7ee26708654b", + "content": "03a5af87ea0643dd2e326a44aae65c0d68267e2463dd5d57e2149f1beaba" + }, + { + "address": "d51735dfd29c91591da8474a3f660fca59815c40a342acee6bae4782e09d61da", + "content": "0c3fee444e9ba9142811e80a41cb7e383275c44c166c941999351ffaa89a" + }, + { + "address": "d51ab174d7203a32c3ad719a934fdd08a85c8569471d21b45705474c538c0a01", + "content": "819c9b4b412d4832782634819b8cae1c48bd4cc308635d6d116253b31843" + }, + { + "address": "d586eb2451e2df52b4a1c95088701b6992370cc642fca24e019dac427d7422d2", + "content": "08f3a6cc98c3e9c6538f2a621bb63f8ad44f2ab7e8a8035b7d58bd5de6bff438287a54936f842e1b0a350f56376ed678199c8b8b542e86d8f3f5f17b514b95970edc0676fca333ceb06a3e5a61b8d3f1a0015daf532821118944a76beee5ae31a1852444559e446b44c4807de8143a9aa09548728b5dd1a181f7fbeccd7f084141f661be8004b31d98d4ae9e3bfae2f8142a5b99a4218c3165d337a40681b60e01f88a7847234e1ff7c77053036e84619956d9e1ec38fd9c8f5416cfea63678abb7d54c1daa2ad8a5dcba49e633184d09c8c4a48b4c1cbc02bfb8086eedac87fad86c8b283216aafe7117933b62aa85e7faf470056cfce45b33525a9b90c9c969d0c1838a70622bae4e0499e1ce3fc31c5f1aa8633d90cacee1b1854165a818cad5523c49008c80e7061fff4accf6501d02f990618f43c44d91db0fd6150e44dcd6cf5daf5447e00a340dd3b749f3200c666a550cfbd2f5ab71f6f9ab703e5a42055b697f382a920546a7ccc602dcb3ba3b68d34e47a513ec9bb9a10ddf683b8272931d7ff3008bd792f284a1e1ecf8d7a5f8aa6d78b6578f6241ee2b8f135f89c89bdbb2713403a9cd73c14c4bff0eb28634dd38f14fb6507ef80bf115879850e04cf43027aab0473fd3aab20bf02f9f533d22644a01c62847447e58a2de52b6451ee2a2bdd34c46050ece0f01f1da40d50276f58c02fcee78beca32dcb4d4db0e0b76147cbe7a21f9aea46820ffdae1d97c06c92c3c7d4b8d180efa2351fe5cf5619dd867655b50f8324b43e4108d3e8d2c79cbf653d68137c0261c2fb28171dad262006b3f9ece8923013339ec56cb7099048b3470f9a497767161b58bd0e7a17f4987680e1e436262ca05d66224c2b83ecf02a9399a4c4597f6d9b4232044aacffa2f72823653f6780301444a52678e4a40237e91fae08bea26854345d632f6eaecb26e9f837ddeb94ed3ebb3bbfb8c5162a86521c63dc861bd0d664682cd5578cf26e1b10b34623aa27d20371d1db492d596dad9f7c0e88be9f9ce347d7776da45afbb28eefc817eeffdf780902ac293c19b1cc2c89d54b59351b223ef75bbe9912b91e91c2869eb9e8e6527926597ac7e9281c925f5ebb58c43193e333961ff8627e409b8197fde45abb130c41d4bd1f1c4c33bae2708077654facd76bed7ff4c3b7a0b0453ddacf685280a0eebbb28de0a4bc16fc1e74f03d8c0f0068bb55be9e87cb59d578812c074bf8440d1ddba1daeb4a54e0201de88ca08f3eb38b255d8df4e96c59ea938af7aee82a162fd6491c12f87077355a22c2ee11789049261e26b4dc2ad4f70996d6982971bf95c6290fb6c36bf12000fa75324e9cec08a3c11511d137042afc88acdcd083d9773f237390e163e96279297d4261f27283f1fd0dc882f18a5ceb1d8c649461b610ca6521bb6d1241aa38d50f8c370941832214e64882bcda7c3ab48e75acc5952fa27d05a0a6c19b8e36d73933a4d394eef3c86c8019b8b4e2352860b82d2e88614011cf7ae49c4c17b5bf89283508e4ab1c91a35b03dae4b4fc7ac09af99a1560d6532324c86be16e2d452e3b1afedfd0157b7cab2a9b9527d847a542aac7fc75583647182d611df4aa1d620ce3099ae3e41d053a63feda651d98de78378e0912abed549986acac7a2a1a92536c8859c4fe33773b5cb0ad3dfd76f3090f8ba59612862c77d2e8f8982f5d77847d2cafb39b92b3355157509eab6d2b099d2929ef335aff779cbc2b3cb825b6afc1d22a948b62f2a88eaa29d07a12aa9ec3061cbcd0d21cb359732bb580472d7962c2e81b55dd2f7b47c6022c573ae87afdf082290de517392411c5b5aeee200e913ea3e2b68abd731c40bdc616df985f91804992891e895d88ae0a07e3a3f667de9cb34d59cda485384e176e4b8242170d3d094c2f879108c32eff7d7b39cf46fe395d49486177ae637513f3c7c7ccf231cad871a6d2c654d62c464701a742feedb01ef2cc217993c50e39ef0d0753c642438155e4c6fbd255465ab2abd7414edcbacfb4ac20d2a27b878b44c4fcd965ef8244bb5b853f899edbc753b725d74b4ee418b16e6839c7196e4eb589e7c1ddb2b1333a756fc4fd22a0b04d247d9900fceda6c5d7d4ee68a04c692a76c0dcc6ede39ac09c62dac427a48da501c25d58a06b6e3ac1a2cc3a6b89fa15da8168c8ee6c16497e24887bcb96e386ee1f03dd9462a60072f5bfdc92bfde3f1e2c53530816042a9e9ad47eab783e27ee21f07f2ef6e5b44d4c785442d0b8f1d8e784f7510127bfcc162c1695308490ecc32a7a3ef268d1fcfd61e87a97f39340f9454c588da662a579eab0f25cf78091275db5472a0f2a2364e4942796ee970e384d0df031a3d9ba669cd44a638789bf86b20f4fc80ceefeed2090f102787dec9ed8d99db740ccdf6eb627cbd8048d62266de08e926cd21473155ecb6254c2d07d9c45b5f13ab1889e161b27d164ff670b395e3fa54df71c767fd514bdb7c036be5b2dc05cbbd87ae2f23c970d94cad51d62898e84d67c9ab57d6b57b0a905ba21b787634936b1acbd51ff9fc4432d2b6d6e74f7f741082e272ba1112bcc6bed62ee9a013aeed217083d359c10dff1438eeeee51a8cfb5e479427daffe1c1e24a569373356827965f7d117cd539a1f000956a6b6ace8bedb2e432058eb9c094ee954d1ea216e79c23bb79bf9b88e7349cd924af090ece7adaeb75c1dafdb39dddb50edf9e0c0143f8ebf542c45207e539cc8ed6fdbed0b0f8b87ce078c04a6fbef262356a010d1485b01c12d9bcde50f3296b9559fe77519c1b260f6abfa9f36378d0fa8a472035b1faf63b5fe2bfe0d0a34a2be23423ed46c3f93642d2ed651c7d856aa562449d2a966a2945738b36e3bfec2e937a857028bdead69b09654c6944b419cf774da672f27692acb02964c3ce1d919b403437963e3d13c9105812f94b141bf7b54ded7bcaeec90d655dfa77fa9481bb59211ca7cdcb08af55e77cd97141697d6921abd7870e417f2b7e3f73ef254b00bf482123099b1363d1c3917629316fba284a0372d3e4a96600c31b785a244122f2921f31711b338da2138dccb743f516b1f0f71f2ef22dd29b83e6b3ede569114973dba93520b1477833567b0937b47324922861b0fd8efe8e927db2498c1ca3add9270fc510b969d595d0030739fe5bb8629491029a8461431ffd9c2d7459c4fff2fc8e8dadf7347ce932ae07c723be1fa2141820c618655edaad16fe0154bb6792d0179355a4f3054f70ef8518e08e8a47c340d054379a9ad698a0d053cd29a4cf7e6c0b7f450f1671081bd3c71a240dd69978957702bd005ea9959721d3d805b4197f9d5ef93031b1c161b801122e9ccca3248901e54e732193d39f305add9bb8cfbd861f2dabaf90a8253ccdb0c24fe39a71651b096f24f9529b285ad038c51688b629d435a3a56c1cbb989a61a302bddbb72af0a3893d2314765b83bceb81e42da3033dc750221fbc93dd10b79fe0ded0be1f00e1d793babab3afd9aae5e4f973697cfd3479a77117385091cc147b7efefb0fdac52cb0421136681741fea41b8f4773659d53239f6a61d21e7aa8bd39f1b909c0d6f09975c6e92cc252452d1a77685b5993de47a3cabde1b1e8a0bab79302209420881a7bbdb5d6d1468de24a82e7d76b864cd7db0ebea60cc3f1b4055c3fdaa607ad6f7680c64ad16395fe71ccbcb2c902b896b68fa18163232829493cbc57e415822845920697625a8ba9becfc00c3575de36be7dc5300e6ff626a59b268185d57982ac23819eca095dd3c35488c014fcfe788202956728498306dcd79468e72edc0af257e9bd4073794b640171103dcc3f52979497bd02429ae888135b935a90c48fcad25cca1202a1af0ffb3b1089aa6272684c62608c26889b6ae053f0861a182ef33c7f1b80a426ca5a0b3b7b6579a3d656ed993de35ed724c4cf9116aeec1840eccffac753baf832a557660807dd2220e5a35f9dd44f73fb9a4fbee196619078052d3fad467d98cbc3324f8aff1d10ce8150a4737a2c3b1416c5c4946fd20aeac65a56fdc952e94e500b5b59919069c0ccf81433fe5ae15788eba9fe1a3a647eec6d9989393fc3fb493735d7221e58569fe56eb0c23a994dec0c6f0c8d58fcbf616835e65bece2ee347e1aff48d861034354490755c4e999c5bd8cac98f20ca9b4c7e35dc44f6d2dd4130033776c1c11f00dfd35f9d4bc3403f9d36b7936163d35d8ded141d99521d23acb1ea65a09e88dfce8592c5dd80d5df4b3c434e498945cc47948d955e8419319627d5c9f3e55a3c621a23396aec1415d438c1a802c3ba948c274e424ace98920b1f1db9ac4d825fcae8c2cbb0ed46956e417b0e978d98984d8be924d1f73a8882f0c2ad48c730a8d5a2f9542af5c122757edc64a29997ad28fc1e56208989093cb4c093139944ad988d1744aa8170263c844e98b1aa80c9a775c3286bcd75b9c1edf3289063e1d4071cbfb108142f370e187d2e040039de04decefad3d1369e02a567ae2cf456ef13b353e042e55f2cae9af5a2188dfd88a8a407b84c2129052a606ea57fd9c36b65fd98867ff9cacfb1a010cc3801c45dd64976e5d59e163368044e169ad6dc2e9c28fbf29259b0bf4dc562784fa2e82a2b111e2a7d6bc85117ff907a75138408a4368b46e390ed7611aef32b0ad864c01b8287ddb0f804f404d16bc28a9c3727550bafad716a86777ed04a58f1ebc98fe8fb28be8a379a455078b9f869b1d61b9032165abf1abed07f8794b2767ad88525592ad52bafb512bf93401ddc26191b1ebca811c0b67f9bfbd56dab9d2d6118c34123a5869f952cafb8474fefaf47f96a81d7304a4815271573ae3cc2330a8e45e2070166a379db470f93097443b5113a03a410da9bd501efd802e45c13b6bc7d136cb665961cd5dc21c564512eb8068a5831c3d511dfedab9a253043ef80530ad2b562683e98bb44999e04305c2ef2050237d528cf2c04c96943f7d41e2e4ea4d926ebc1e38315f8ff285492078b8e65ebe29caa60e8d3acde753d5153749b4424180d37008d8b4bbd9393619e751a5d26d22a68ac9555f79174e3cce258777784ee8dd8a5437c45d922c12b1751cec063accd1c2110361bf8ea5479e9f789db8eb950a96d4675f9b2c96d2e16bb392dfe02ac56de36c3360c2492f6a9125dcc7403b0181ae9d745bb02977bbc3a7d45127c2514823b6e17ac96fb2d67af65db0db35f2ca4b63045bd6b5c8ae45de693a46dfeb652384625873ef6ecbbcc9d44210377263a2db2d1a3e8f513ee00996921446daf63888a53ba67c4e0cb15178e5e2993d91c9ec3227fdc0cef9341c56d095588a6ab7f8016342524e338d79e321bcfedbc53381fae1a9c83ea890e6fbff14f32733a830860c5a0bbbf55d32f7cc9a0e97a3820a027363dcd0eb361b996ed900accb3de4d1befa714c49f1d10ba6a33261a9592f26c80abdf8892f329446fced29f812b8cded52eac71c135e04f9cf0fdf047eb03475401a80ff4830235192b1fddec282144566dd7a546802d05499d017cd6e56a5151129031a3437a0204e986113a2d7bb708d69524357e939cfa30936e5d3a97966bee9e3a37e60734379d657308b40b6278c03cc4e77112ec4cc3bed29b33717e6fef751f6d18689b744df5559868d76a6aa78cac4a5843a0a6bc53dae4f8a645bee72222607304fd415db7f904d21be8f97137c20af3b03e9ed5454b2957d250944b7f02063ac88dc2874353f2368c5925e6b8ee242c809bf2f95405cfb6f5afa4d93faba8bf3c598ed3ddf598fb7692d6507f124a848d79a792a1f755cd1347c12c190a06e30ea7c49e84f7cbed9ca26ed11915cb73e14d746649c78d0551bbf0bc315aa993f3c18a00689a8a682553d511341c0a8741ea35b43c505f8922c9ef827b4765a22b7b8db5b3a2923f22ab9dbafd6d3b3d942e63d188ff0785834fd32b94b0f758883763cf52ced74c72d85c873b2d9496d2a95f1b26abb3bc7b526ba70764a35fe7d01f9503e5d439d76c6ddfe256e0d00f9085f39b05e8ffcc65d0bdc9dcce2bc5ac1724071a709403c295258c24bfeb94856a461c024461df0c4f4726ecb931604f9d0f8a80fbd3b0fe362d5ca24b6d423fe27461e6533d2740318c0a9685c057b978f72fdb2e2ee6d1473f1e09bcbec2e5310aa601cc45585b780ca85cefaac74538a41f81beb21521859981a6385de48fcd4804b4ea471e9e7f5e04d6f4b55dfe895242d09458fcb6af88c28a658c5bbf23b1840be5843322ccfeccca4d0830cdd8e4069b9a5e6dd27d927b0adce506f214c7ad3679e8341436aabf3bd78f897fcc8d56a625df9914d8887dbc2ed4534235416a3f688e74066ac4dbbd72b6bddba231ecf47f18a949cde296cbfe38bc7f147a20135571e050b9ad5eac016538ab01559db56448a5bba8b46906fe411cd43433adab3dc42d5432c9ab88f9c91680162323bb832da0a4bcdef01519ae42f58207eeacef5581c444b93a569afa595738ec3f7da5fa6b4accf5cca0de526d09b6f5e7ecf693d2a1c5d171348db111310cbd1cee972405a953a7f53a3f4c427e16f379c0daa88c3f40099e75c0cb4480e7f2684b8107fd889d083545f72377f6a14c9a1b575d42e1811a7dd552a9082fa6ca396440003519b6cda92ea344b465122a5b10acdf6a8f21d60c8d8d4ee1aecc8b2b24b96b0b3fae70356a219932991bac1f60508b6b8a2c6bd666f7e8672bd12c07bacdce5a2f36ceeae18c03f99a971750caa807208cf00930ffba0951bd72c30fd0f2136f176fe23c8bb6c182c5b491706b5fbd0f49b383c5e023310cc7c7e93102e00ebc6f5cb35a28aa5fe9d064c43305317a19cc3150d25f16a146b821ed62b428b02c10b0c2fa7c9be0ce580820ee7768a888e655a72a9742db174490369044e1d96f7484ba52c621abdafe63331865631c556d42596cab4886b12afe1bbd9fbeed8fe0f273e2a80c5f02febae49acef34ee45c7f786d8348ed1be7c900ab64e7962ac677f685b1c5e5988be7b8cf1c17d677e8d8177fed9622198dacc3c6f4beef11a79769a058dac2e8dd9f13c883bb9097195c264fefac3e5394a6bfd2bb3344dd861dedc7e8d567ea2dcd67a1ecf8b2a600122e58b1ff4697e267acd2ce322e177f82b7eb58ae264eb229a75f360aec4b0f8f654d0d44433f7c4afb7ad8df170ed70c3b6a5480ee97c648e537dad05f1c42d1ca2fb9b3e83b5548f152a70402508458ebf7a98ff30d2f5d1363ddccd6cbd6c5b1b71db9e40268e0b4719f50adc706503786b67d2970dad279c8192f09b3640eae8ca171d0251b98dd497ea37d1ab3a94f0a71a9d151a9c144c2dcffd23f6d57d551068fd3bbd0b9f05c904eb640b803798f5a14076ffe6ffab0b0bc1bd3566304110fdecf0cf8fe950a5ffa0cf0895bb90ae12bbcb7e2d350bd29d950bf4d3d61380bc031a23c43f1ab5da8237c5e60dded8b05384354cb3d88e7294e4087b912ec6cb6807e0321d938116d83c1709b1d27c7c57fa9e1e65b2915d9683d9f9bf36f83b3c8983dba0e83393e81f2543f32872e72a012db22de460ee619379dea9aa886a0ed7e39869648fb209d827025fc46da957ee095d4cbd0633ab7a602f9f6caa6d9fc83aeb0d9b25a35d4c020333c597fe25a21bea3fd8e20a119ec64e23679b5afdcf5f7296547da4cff7ad5d399ffa7867718f0c3ad9864213c6eea4a27ea12cbf65a6b262f6586fa1f30ad815ad19490629681b1e7b587bee5a17bda3a5cf13cb357cba623b1cedc335e1d499ddc0ba49f0aa68aeddd2d62665c4cd10b0066252520d42d624974b90494a1ef0155d87dd02db83abb8888dc65d0af45a1376e6d5f59fa5a3f56837e96a37f948e57508d1fe41703c9bfa624331240166bfbcb8191f3dec011305e55460f7d872669852221727c48ad422db1cd31accdf9ea708eda6682a8ff182bccdd7ab939875a2fee8937bf9febbe44cb45a979a4aa9d5a3989b0bb15c566a7125c5289a15b89c4656a0fa45e74950fbce1b37026d1939ba1db6dc1d725bcad48a53f24f9012daf8800e033ffa7eaf951145cd5d9a7ce534ddc799854d60909b184f2ebe345b35dbb90138b8b864cf046f3ad1614cda3aa4c0d08a404db37f2d57e0be0479a2ea6e8ebc301824244274c931faf6f4bbdbbc500a621320be53c49e1aa6d0539731fa5df8c04a4b9fa4696f44e7c5e5e57b13e816824a700dcc2ad43aa162c751da8461fb9e8d09d3980b592605016571dfc9b9297826803357e44943a79989b29904925f885c986f4b7ffb5f98f81fef1d61230b555c0192cbff41809d2347fa5c4125503e92d6b4339d1d9a0713b61eceec369d3d060d8819eb307c6b887f7f2649f1634caf762ee0d867586f8182f92127d45302aeaab58a28c8827c884a57568da54e169014eaaef51eeb2414f4617616c78e50cb86db9cf0a6d00a4fbac4a7c076729d0e6c9916cba1182cef05ead7930fc8ac1798171b8a6885020138484fa01aaa2d6c90f6a7d31a0b82c5c6691b79f89dd8432359bebc722af7f58e3e68daab5ed5423ba2f4f0a2fd6b5faef47c2e9120245fcef5cdfff9dac0e9eb1480262402ebfa2f97ad29d506d6cad2e8fa9a71fa9425c4d12ee06808bd1a07b3ab4467bd9935d46242a9dc3b0e79e861b47ed8ed5dcfea9eb7edadd7f7129c235d7157ecade185a5b0e4f0627c3fcebaee8e6bb85b92fee90ea8b8b25a5ebb4b7f2ff92c3f8782e3aa385c42e7cc95f0a287f343322bd2a33f659421eb4ce420b3b452e514e419f9d62e047b6c4dc37686c83a91e44c49f7b16b6db42c608602fd63c9e224373bfb12b050f4f6267c03cf782bfbbbeec1ce079d9d86d81637090dd2657ad3a8186dd353e4ac81b5094508b5f25b5f0dad7fec2e889b6d75dec5510fc9ce5d952db5561e8e0bcc6bcfa28ae5e6fe76b03704bad689a32da5142bc142d96a005f46d435b3c5575d2c038e2b7ff765c3844fde26cdffb43b56003935274b48aaa23e4f9a74fdaa8e126208e81a225e9029b5c77f7e3ae9c40a364dffe250f0118d891e71b9caa7323fd3111d20bca0a582d435da4b45fdaee49869a8438e26019133e84c9453255c67abf6868c2f99578d1d036f3e65dc0ff6243d5819a7e01e9a97f1b370656bc04ce6dd7dbb3c18e057d460e9f29ea0269684a6adf756e478d0bffb3d8c325f07bb40a81db6472250062b648f485b47a4d2037ef77857517994b18aa6580e6ec713bad033286974a46c076616529b4d043ec8b4c532cd53de2922be674fb9978445a5e5ab1bd8b501273428010569cc6a706582283d55febdd1e6b8ecdc70cc36d77de40eea0f74c2ae5df66a8d5675eabd03e9d7b8a4f3bd33701f6427278c73364b8291f5415710192ea36500a968642463508cf2caab0d57d9fbd29964d3b0b91825f036abfdd779a1566e83b6776ced023fa66622e8a3bf57dba0250e9a4371ff862338be327dfdce92739d8863c2f4bbea9216b8eca8382b010bd37447b7f808c9ead104951218c173c3aac3ec41bcaddf92ba6dba93ce1333d3082e9244c2128c2dadd17335583a742903d32a1ddd0424a6c6557f189ecd9ee47df064048a9b8287d827fe377993629dd82cd2298d0b51069093060cc0b91abcc99cdff9d3eb9ea4bda8e18efdb390b22d9d296a4383a789ae3863844cd21718af9610b381655712c2410a922d382ac6548ca31e551905e8a942e927813323abef7ef95ad6c6978cf6fa1633d2d61d6856797c66770727ac1b6018b5cc4f3885d35d85b7da2c36b63ad64f5df12eaef01fa416849b97a0dd2b673abbd32cbbcf4a78eb6f9a616fddf1cdd94222cc0f9e51660a3263fb865e84151ae0d3afa66eacfaa7f14cc1fc8608c41525f3d4bb5ea0866a6b637421f48dca7241790f7e12933f68f6994d8cc8177522372923763bae9d9cdb970a71691b21950633c1a64d5091279bd85b25e758b2e3f461eef34eaf8d120636e963a2ad5a1696b05783617fd0e2db9eebfe4c67ad794f04c3645475393ac118bab01eb552df21b9f12c3bdded92bd29a318edcc4137f75179caadfd772df60de6b2255a80437596e8ab075589d15512277205d174942d5377cb6e9392b59e466575be871b3ab8889a2f557e83afb3750b41170ee7d6e806292bc40d36ea7a2bfc8771fd29022d389535a1bee33c850f35381ec9337e487ca64940302552c57b428051ba5d065bcf71181b7dd7c320a43ddf479577a950c80b1ad55b885ae57cbc644e0c395423d94d1692196a2a65bc230c4ba145227190ebeaae6d9dcfeee9513b653652fa631354b0f181b484729c1b06397ae85fcdea9da6ec5acc81b333735490ba7f99a87b8e2519dd4fa202943098f740b95dc5acc452216e635f4f87f17178b7a9d00e56f883be323f3b982fb9e60479b61e4b6974d47df8e8668a0217d94df780986229dd4eddd496bb1a6fd0cb8347a985e726ffc0755f117fdc4a22d865f9362160b06c2120d6a0524d3e98bb58075554371eefc218c9839938302ebe97b13204178c9e9cda49a4603f80d774b7b9c29ad84e7cdfb4967cbecd050fd8a9352ca311d2c7e16ba0e33d909dd6064bb01ebc7496fd3826852f3998b482e7e32ce6c3300edf002819ac4fe2759bd5e07efc5ea7f5d94920c389dd92ec81b582508f3d3c15126e1f7de2a77b59df952e97ff93b13db03f6ab755abc05a08882d0faf0d168ad216ce27c37e5ca90701fad61f23a95f6f961080058d25b152456b30fad72205a47c097b8f2b71fa87a98a02f1ef810c9ba4c63eb6ec28765ddaa104812e48068d0f94b75007895c1c3a8cc237ac205b5cfa0f6e1fe2bdebe13c8a1a99944914499e839d0f9d6090511434d430ba63d040c893d64d3b06bc715e2ae3852ec83e630363e7fb18e4e95ab9744bd27ccf6aae4a3655d182536e28fa600a492fb6bec19afc86a9a982b21a93120e208f21e2b26d19c2148ce11da64edefcaee5e02ff58c4b97f1aca72f19acccb8cdc8e832f292c9eed4eb329c67492fc086849a2d221ca44cb54dae46ef0fe3600439aec55b8004686205fdc8bf5e42d61670680407ae740a882b85e8df1016482ea36bc1995452af845d7b4306cb063c1dfa13481530f4aa2591b80333252e902606572c99da308b6e71acc05d4e65112d55a68c03f593b211de2f169798449fbe45f68a3705a75a3aae5e729335764f2bdce0dc9d8773e2f65cde844a80bdd62719eda544f54872a41599b6b155e0c2e34f90601ef26474100000a47864c8af6a55aa358a96f33aa07e59b27f566282e7f195e6b9a20a251fc6475a35a6f0eb6d41e2170c88a3c754df663f479a93f9f2d77614898b998211574e353f847e6335c18cc053004c9358c191e4432aadf70adc7c4234ec7a469357c98a9026145e43c813ce339ec822ecbcddd3d9526eef2b614ff27b0811387675b89dc5b42b365c8f59d18688743fe198f2629cd1fa0f59cf719a4b19bb24ea381b080d6291065025a8fa96044ef8e2dbd27e8a3d2acb643dd69589f84da1ee511780b9edd4901ec07dfa344d69efe7e37d63d5f585759e3acba1c2030b917d251494bfc37deb50402a1763eb04ad8a24e3bf8aa533aa3870ff690b476d176cff172009195feab56dad3951102b8b25d731a0e9498e237c707cc99fa0525588e53f113f20da789edaaad97980fc9905c8f18bf063b21c9e586b02b02f10b398c9c17f758c5fcee4c45c91cf5eaf902a9c1ad78246048ff2fd1e3297edeecf287d64e166fd3a19ea4aa7062d9a1d64e68a001f9a3a7c55caef0c91c3c66a58c1a80367cceb7bc15bc2bc404458b62fd506e7041276c5974da303b7c727551abcbe2c328916b723b16451f6a38fd83f165949f73ad8283caa1402589e49952b9245c060e0eeffc84c013a84fa078f5062e1fcf2b4ebe7b05b6b1129ff69d437a6352e1213027841765f158fe46ebf1e55d01947dd17a74d97189fa103c129c3974e03a71489f87ce36782cf950e712a84acbf0c309eea2224d89384082239ec0668319e66726368361593c1acd0c16e55103b5c684a741939abf67892066068f3382333f43e17acd281ca0ac02f0332b218a89dfbbbdeb159d94bad91be8abaa8bea27de3e50f89d2841e8d711e4aeff05e76e49ef77a12f91657eb88cd83139f1df5606cd5961bd5193f3a9b7db005e5c91bd8ad9d4a212bab0dc1a6d04c0802500d5700eaafb558e35792ac5c4f0023ced75479e94faad4174aa3a168ec4516dfe521cb9d8a72dc288dd241508a534cccc2ae3c96c6f12290ebe04375176f40c8f2e623038a294954a80cba5fe9b9aa18e4f1540f3ccbad3600319fbaba262bfc134a248de15431ea6ec367b64be9616d6fcdc3d65cd2003f8fad289a5fac2127163f13d34a7c7bbeb8ec33c0bcb862128947dbe20d9976ee596bd3ac5a1088fa38169978d32cf9548bf0a47daf053ab2818622486b73af5290d24785d7b04e7d7825569d5e3608f3d475188f85845b53fb5b739f0fda874991d5e7183436a5fce366001442d3f46791a729e8f369200575faab2ff38b8fa8b4303838054ab97d11b61888ecd29742a254cbf69316bb0637141a1b97f10216cc938017d3122ae0d64efa0401116154fd84be8ad172976f8b0079db973570fa4e31f293e14c7af516a310032bfcc9fac88a06e5c5651ab6f8b780bd7c25baf28efd479c497cf8f7d705dfc86d4c9a66c48b17f0f4ed3fea1e7af5b5a4528fae28d654e2f49730acb54e9cc4f7959287b59f8cbd6537e0540fcd9f4d1c45ad3b357ce50a54e8f815938c3b49cb4afcc08af176c64e53e6ab5261603384bf154b5b6f16fad7306bb6d65d11f8faae3a1810eb48ac5c73fde519ff7f1f290789a0d9384430aeee1ad5f7a3c44e53ada5218d630767e431f63b7a3820484e565be0c755334e675cba389c884d1b0cdd217e58fdfb956aa56e0cc03e25ef1d372e030abd8864fa8f5881e68e842133b72da53ea6e22ddec8f764a8a8ae1ab72ce686a175d433ef19aaf3a3f4a50004f7beab0b756b6aacb3906eb73447a68692105484c9a8768586151351217ade9906344683952e77ef6e50370889fb58745ae3fdec65f6e0ba0c7933368102c3d2464dd2d0cbd78ba84619c10780768bb817e35e696dad2b8e56e40a1ebdeddb531799c5db5491bd0b29e5af9008c9e06ed20a1bfb1c53b9be55e16a8fee117f529a3929bd987365c5f41c11353495d912e5986148e41ab12db47fb7d0161f9c645c797d5660d9ab13cca1e552a11fc8fe6d4a073210942600b31549cdebf679e161946285dc701577ee7f691f1982f9c30b231b148a5cc585eaeab14004b6baca28b24b78f25c30a5cf5a5e3d4ed5c342296b6fb7c71a2fef3306ccf81ea565d83f7d72d365a37a9559b2ea28682bc2d6d07813175f4ef9704db6068006bdd35154e6174f00292ebba80b417660d382064f6189f0adbd0d48bf65a7f1a493f33d80a85a7d053813d697d76372513211ffefd0b9c2b7bf38d79719bb91bfdad4d27a3ff920a93d8203ee8f1eae549ff1acdbfff18ec325de797ad52c0f7f65942f15976a38527e7899b349bb769fb19dc6628a6b6a6428078ce5eeb51fdf9a3d2828b9de7c814e3e5ca8f5ade7d675c2cbedd7786e95e92a44790e809b5d2695e2195794f0c495738e55e46e32b997c2bea636146595dfd257778f73f99bd641c1926ccc77163420e11b5167d1765a8f586e86c5967c439c9bd94f6511a405afe67a5a870b5aa1ab2efdacbfbbd489512eefb3884cf0f9d48339e7dc109629a965445aff3b33823764641c9884081c674adceda3d99717e2092e93827b18c7d9e4d344ab08ac3e70527197ef90c4b60ea74a422ac0870ed94be847f202d93f11cc5f8406766a5f8863816bd2562732b2897e322fef66b2f2c15b97b1f905887b78ae1ef1bb19dc5b32471c68723c2bb65ad1e9bf1e68d262803b04d8dbb1d28cac7fcebdb2f3dcf292a7c66ca62e58ddeebc59c1d2821349157d4e98dd131b1935416e4491f63befae33422058d7ad59c222aa697eb621e740bf1064fe0ddae4d70587361154217cab55a9bfffa19ee31644dd61fb3318d8c7c027af01fcd9b7f681253b954c4722acc49acd5a24ac5dce27dad128c0e847440adc01df3f851046a8c83425be0b926115c0037c29bbdbfee838643aad6ee2c2e75b9755c28ee39b61e27704f2af4de2f3ae6b87e66bf270c02ff55b0c66c83c6524c9d72759025233a0ed94e35f73f55c2eb5c4a9acb26f6bab512b6b3b9f3bc7bf1c4a7ec6430d2ea632d57bd267c36afee619a9324a5a9332143c54c74f9e21ba5b4911a3005d56b7c3c5fe919a6f70adbb8ff7e41d33876ff2b7e2ed3332d0b252f0aec2a4505a6b0056fc8a4ee8c920a251e5d1a1bd8c141a67a91c6b12ee1ea7aa7ae082d6b53cffc25dd9366662d73e07f2edf3d84c54327d35b0cd2dce703818f06fc2d1014abe233eb95193daee4ae7ea763b1878e223ae6962beb3dc4b4d3169c771fec7a59740f08e4aa099364dd40a7e3030a0865c70858820a9817a4174706b42a681fb5de78aec9cad4f02472c00ed4f07c7a0ce24d1c91a08f590561b9c8a62e053c2afa12493619a856c2f3c81b26ede0c0caa6f7f8be7ab89ceca7de1f6907be7b102858cd94144e1c2b141238614312944794a8a048aba6ed5a36fbd95fddefe165bd6a11a2ee88decf70dd1623326bae5edf73630dd417a0e8c702f06092e3c796bdd6f566e1591f9150fd50d892fed8859f2f064ca9a4d84dab00ce8ca30dff66736bab7fbdf2041b1f3ee2d0c90e3b9b52420958340073d4e0523efa778957dba5d0966b491629b29e4b1e7012e48168849688a2ccc2ce99b17d292dafd7919e38f4032894524ba39bb8c400420410fde65c15f98b6fa3ec9dc2ef16efceaa82a8ca60923b65655487da29dead4a8d27139f239cd7c07d42bf9ea44da914b9a3231bf263a68f507911b96c39a262981ad3492a7eb92ba53c727b27135d444dcc354ad2ed3fb43d9a8655bf6cd531cde0fd75e3b6619b441bc9b8bc73a373de1af68436fa163db2de91fa5b805a971782cb22121928bf47fe70371b0884510bf01db5cde6f44f7c850ea6f75ccd402c0d844697b020821956a89d24f6cd940e27d3e7b92a2c2274b9cf341448925e0904f24b97fb8d644116742becc7b2e0b3175517114e7a78a2a8ad1261b9477f2007d8dda8efee1746dd0615fa528e9b3958b3a2d2c2982306ad998b258da5cf6a96cac3a80cf4c871d3eebde6131456e850e6276caaa3ffa345931ef296fb31efa93bad54dd454ba0607f4b9bbb5b1b06fc2446533b796aa309ee5e3ee259dcc3d5be604072f55faaf8918ccbfbb1b45431767be04f8633affe6c7aff7790bb45cae9f93303988abd76456117e0c6e4ed3cfc594e53812482eca9064274876ebf76852d7f6b033fdfd2801fa5eac788f0f674478dfb9c290dda0f9b71244594136e90866f75faa5fe2107d0bf03e3ad6a3888e080b3b92d2573af6c9eba238c29a7091bc9a873f177d65461bfedf09e5bafd6c8d8ab49531089728437e68e85ada50bc9683ed69eda393c0869f0faf4e0be14513f98e2e1a94c3021531feb40cb60e322216ccfb61e069f736162954ef198bf9d397e0c755c8d436f8143c2f5babf233eeca05fab7934c765de52e662cb601307292e349e992c422e3dadffe74dc45848679701cbe09d8b54959753177f96c0b5f9339970960b1bc1133d102aad5a1dc44345a6319dae32cd28e186af7cc30671ae0a025ae85d9c74ffd5536085c64403df8efc70a1c070cf2a33b4d064ec72b2186660ba35e0a3709abde401e495e5a335bbea68a8215860c49252a66a351172199ccdfb22e79bd7dcea35a3bd6163483507037930874c86a0bdb2466e8576b85f902458310754f7895d2b74d609e7ae404e37486e5bbd3a1fb25dcf9b8bf4a4ad058eac9e585c6c2a9ee51e3d2dbdd6a96c2c926192bad0441ec06a67b0709096ee43b7751737facaf48d909adaded38f48d12868a229e2440f74628a0b84a5e332cdc3e6f74f176c8c2fa4cf241e2bcd1c2860a9991655c8b4ca9bc3f8c5a0b109f7e79a10d76b4dabac39efa681d3abe323ba2a78fc98f64dcda816c4c4f75e203cdd91c4f6693a875a46e88b844143a8ef9382897f24cc841f81e0b70d69413e366f483893acb92bd0cc19da3994966fc62a039685cce7562caf20147dda4ce580be57f40317d3159ba2c4aa7aae655842196b001b3005dce862f081d3dc6cd1c8d92943541f76c265526988c9da14bac5a52ba607eef99d67d16285698e9789373a8698110ae657d0c8d54f6ea1921a35b94c283181e07334cbbb993c53fcadff1a957838fd89996a0a5bac2130b0de8a0a0d8783f595ceca4f113fdaef21085414211a411d26e1dd1d3f7351f716594128ad336ce6838b4dab89ed03da7a40bfa9519f4f22ca84b31c21a50bf9ef4a3282377d942f566cfe947048b24a20c472c7458f5eb719e38a875949a835e01e2a597b2a98673873d5d2fa9e769ca343b9913c790bd8abdb42064f4ec0d0d3f9442986da7fe771f47789ec5a265f6a3587c092453d82dfbe967f980b069942c9aba1911e886335ae6fec29d1929c4228f4b5230a029826d5d849204a3fb7740c8b406f4340418dc5b13f99e8c081a4611f5b166be987593d3f4a2648e16eaa33b837ddf699c391c4f1bd96f502c1835a29772404" + }, + { + "address": "d5b5c6988bf23d7e8508f01e82dba6627f3bb49705ac147d5d93d0803c2c8b4b", + "content": "b957c5142bb640e65b81e52eebe91fecba09b6df4fd35fa122fb66a56411" + }, + { + "address": "d5c625b768dcd0a6c9fa0d7db5327266f94803e00c389896a0abdd5b812c8648", + "content": "1ecd28f7cb5c1544591d9ca0acfd5370acab758414b2d5c23ee911d0c621" + }, + { + "address": "d5e529d3ecfb3673a2802a2027dcd5775c0b93e9f3478d72abafb0743096f66a", + "content": "e47f1e71289e748d80fed4e04d7d02ca86b9b2b7a6ae68f840629abc7571" + }, + { + "address": "d5eac03f1171928a75eed1736c260f593d15fc28c6e1bc471ad2a041b3e8e4f8", + "content": "938b3989afdb6df8113c1153e1fa149f0e1fe49c07fc9f7ed3bef85d42ba" + }, + { + "address": "d665136393932e2c2654b0f9107b9a9df8a300dbfc869ba429b0e011be79cbe8", + "content": "eed6f25f17fcd5a09c2bd4501f4747567a89175294cf11241eca98babeeb" + }, + { + "address": "d68bab492667d125ed439a9bad67eb47cf801d44666add70e43ad793d1f990c6", + "content": "a0edef540fafaad8035e4be7c81059647262f75742cc54a6b8aa2a4fd07a" + }, + { + "address": "d69a42befea94848f42aa42b2b65e1a0f6518dd26d26f6ad3e65e1ae75995053", + "content": "822e068729224e5892b7ccec5b589a1aa99c08324a3130cafa2b0a1018ae" + }, + { + "address": "d716358d836d79ccd44a23324c2ed629518d7214d0ab316a137869e69454d1b0", + "content": "19d73d87affc7de75e1963689d326de3c2b680989ff4d445af23a7f761b9" + }, + { + "address": "d717b264584124306545495871fc8e23f23ed32d8e59969d724391503d47d7cd", + "content": "daf0dbfde1059171041561990515fab373beecfa77955430c427f0d10b37" + }, + { + "address": "d7477178ef560ae17f9706e067346d2dc5e9ffc8308073d4bb51e3fd2ddca0b4", + "content": "bac2fed881b8831a591b40c7f86d1133d80839619124288cc9e71d554d7d" + }, + { + "address": "d7794aecfc577f7a79ecb7cf4cd39b17cb221d485d6aacd3f58dcac633485f49", + "content": "80406d73776fe6af3d704b073fb9788c8a875d209f1ef3f10233ed101651" + }, + { + "address": "d7a7c792da85dbf72c8db44ed5dfecc82c5476bb92cd82afb260d69644d1a414", + "content": "e9560babb6b331383395fe6649dad5613b3a2a442d24990902b417d24364" + }, + { + "address": "d7bafeaf48399555ab4afed603adfa2de9ab484f6bccb53b1c0742ef9b708766", + "content": "455609e744f539c5e602a0fb59c09661985d0fee0d22ab38b5a1c21ee7ff" + }, + { + "address": "d7c857518217df0f9a9fab022da045951e83fe4e7b4fff0b85ddc39010d84add", + "content": "12882ba2851090d86546aa2996df625c0517d5b0b7596ae81013644b65de" + }, + { + "address": "d7cd9d522443ae3c57f862a6864f0c977d8e83fe2801dbde67c5e2d614b6fae4", + "content": "bf1d62c1f85e4acb058495aa5079f0dc663471240801b99df776f7044ea6" + }, + { + "address": "d7dc1aa80abf64d752cd5e835a21629ecaa51bd0e72e130c69c4b50857cbf71b", + "content": "bccdc0f3daaaaba8cfd97562d91b36dd9e46130e2fa4d0f21d3d208bddd8" + }, + { + "address": "d7f8d19472cc3439f23999a072d9125948f87375423010677c8c12c2d4a71a5c", + "content": "3b020bdb3c5be7a9cb12634b27a5bb26abf899c96f5bdd6e832c918c7b75" + }, + { + "address": "d8210ec2158f87c96213cc90102653dc00b4a5fd5683097370dfc1118554436e", + "content": "d34ffeea9bb1577b6da4573a41af53095ae0e0237e9934256e4a6f3d7875" + }, + { + "address": "d8244f288614cb225a6455d5e11b89d248b5b9bc04c68a5a07b6d2a72406e2e5", + "content": "fe99cde3d9f11da3b514320126d32613ec20bcf75a416921081759f611a0" + }, + { + "address": "d824c1c2732d1f83f5c58346956db592345114165416b302c17291d98c79d5c4", + "content": "3d65ee27c9b306f258ad763ebab458e8aea203fb5e6817aac91007b3d315" + }, + { + "address": "d835b9c619c6c8659f288a79992faa0eaf06141adf8ece7acd023568608a0e44", + "content": "932c882d68c8fc0f46ac4c5867cb21177211fcd6a283b70e04781e977a83" + }, + { + "address": "d83671078f743865b91ac959d748ea812bd320a6b421d47140244f88e0d56343", + "content": "c0871de459e2be1e9bb0d5807db5335e1363696d646d92a740e3a8f1aebf" + }, + { + "address": "d85fbda8378dce2ea532466ccf2aacf2b3dcac4603a81c2507db446c7b32a631", + "content": "5ea05ee661d9695c1e703147d16089a195070697d69c16f05693d07e010a" + }, + { + "address": "d8853168d23950e4565040f439634eaf2bb8e896b486fabb0fb87110e4a41d5d", + "content": "477940fb652f9a02f86bf5c8555f6aa86c1ea602974fce484e7ee9547c0c" + }, + { + "address": "d8ff8acd981c5512bc030f72b157865a5bb702d7cfd10cfe027186bb2d4e4dd7", + "content": "b8bd7c64a432647608146bad8c32b53eccbd75018c235151fecca4814aa5" + }, + { + "address": "d940c1c8942cde17bc97ba11bcc29b1576897bbec049de4fa66b45e3ec602176", + "content": "2e65af5fe166a3a846d4e3288bf1f45aa3fd0e2f0cc1f8d4ade8ec8d3912" + }, + { + "address": "d9c4d572b0c5c2a9d1fab0929eab167ce767feefab2ad1a05907790fab1f63af", + "content": "0f8d1dab14dcb9553850e84fca4a5947e3533b73647a6c0bdb9a72ab0fb2" + }, + { + "address": "d9f0cbf5e9cd28418ac62cff1101aeea3b735d11dde0afd0385787950395330d", + "content": "0800ae3fa49f4b402172309b0585329a8158214df4675f41b27057dae8ee" + }, + { + "address": "daaf490b85021526ca34dd919f8d1c912fb0e1421a5a3393b1979c5b56ba512e", + "content": "82d56d54d8ee5f962b8488d3502e80307b33e7f6bbf18d1b23498e044ff4" + }, + { + "address": "dae9e949ca532359eb6d5b54d69ab2f975ff6ffe9991f6c77322c05346bce4ca", + "content": "7db44aaa2abdd2a5bc189a157e806b448229717478361096aa21f17e7a32" + }, + { + "address": "dbd41cc228b0a6964140ee7aed1570a691eeb31fab11cb5b0be3a5a0c9b00042", + "content": "767859453a212345332f0e897b02cd415b66d636e894e1249d3150fd2ff2" + }, + { + "address": "dc61c0abecbc927d92df58c715b4b786003b14b1f9f4b1928e8813ebfcdc447e", + "content": "d93797df3b7d40b4c3d76410a4578452580d7b1c23a7b3bb200d546957b0" + }, + { + "address": "dcfb3e721ab88c4175792b603dbc1645bcb2751d4ebee67aabeddc2c04369140", + "content": "d23dfb2e95e1f7f653efb862d7a1fe6f078a5d409833eee8e0e62f13cb0c" + }, + { + "address": "dcfdf657775434f9cb6c6bca66e7d67262ea1fe82a741983759fd56f75598c55", + "content": "317b04c137cbc14ff3f53ac4c197d3f4f932d746680b7f9d55d5b548038b" + }, + { + "address": "dd0e5df01371bfcb879e1061ce47fc5bcff2f168268b757cade3eea1ea4a4f0f", + "content": "a13733ca369a29224806987651cd0e70e5a7b00eefaf799e206d45cf88a7" + }, + { + "address": "dd3149fa0f8881219b1d53d21c3717f7a6bd03b0a694a6205821b81c28b362bc", + "content": "5028346b2bcc28cd6444fbf0774a8d62194be3e13b8ad959becb540adc7f" + }, + { + "address": "dd5a347ef1ac9c5de3de70a2f5e035c324b4c0e3190f8355ef28b7f45382721e", + "content": "6244293ffb277bd811c23f264222b81b6d0de7e6b7bc34bb3998b27f8bc6" + }, + { + "address": "de389f5294c9e1d9cab3ee3cda4653dfab9f2e47931e741b8ee26b7d1183eca5", + "content": "ac9b68fe4d842189ed17404955ec858adb22a6fc4bc9aa093ba39e2c022f" + }, + { + "address": "de63ed3419c3d8aa85a4ad3449493c0409bd72d7fee39e023f166b5104a87719", + "content": "ec6241be72bb47b3221fe89caf935bc62fb092d49323427263d46e0040d7" + }, + { + "address": "deb85dd715e39154c3d5c669219ceffab2a0eb703c5c9a9d9b658aca8b3554a4", + "content": "528e253ca47554a545a1029b3f62390e71f07346c496f9f2183033cfec0f" + }, + { + "address": "df1880bf2d426c8622d878731a280e33202480a24657b83afd92fbaf19b73cc8", + "content": "6c39506081848f4015e9aa7135a27b229d2baa4575a20301f86df8ca6cb1" + }, + { + "address": "dfa0e0e1f56381c5326a85cf1f0bd2be6249ae70a176bd964ede8a418289c1a1", + "content": "4acf22b550567f7d61214e7876157924087dfca7e6c84c10f0833ea5f6fd" + }, + { + "address": "dfaeb22efe1b9680d00f7184f5546abd5bb803d9d011bb424086deaf13bc14af", + "content": "fd7e5e97251c1e3028906f4eca3be93f2c099152378ce08fe66b80821fe6" + }, + { + "address": "e005abc40c059268023aef7f83cf8d4db415ccef3b66da6d2e136ab3a322cfac", + "content": "5ea7c1c4e5c6b6a4fef4a0bd67e742095e87395511157cfc2dec8ae85fce" + }, + { + "address": "e05d9d6f2bd02d2fbf6f1f7171c4fc072696d9b33bc54a386bc691f55d4982ab", + "content": "d9d7ec9a3c5e6fcdca2e710ea83b524b8023aa8511d9235ee748b8af6c87" + }, + { + "address": "e07b9ffc5f0b2a9be633bbcef8027ae82565a317c77ee4e480d60552d90f9a3d", + "content": "b619f7234af7e8b8190b7f3b617f16d8e47e10a57de78df89afc62cd1940" + }, + { + "address": "e0a6f0addeafa506b9a8e4fcc3abe276271905673c816fa10ea596d77c41ada2", + "content": "a250a6e87d650e163cd333b209819e291017c539686ae573f5dc08de0688" + }, + { + "address": "e0eb35c4316e44e56d54f83f3684a488e7ad5a21e7401a3324d3f4d07f4a18d6", + "content": "95805c12b83ee96e8f0040c8db7ffd75047794cc302336659a7b03508e33" + }, + { + "address": "e103687b1f520be75bf6837c1ee81fccf3d8eb14db8d3821a451cc8432ea7355", + "content": "bfd837dea74c9d3ab9b66c31aa843a94784c0eb31dac26d9f3dca19eff3b" + }, + { + "address": "e136695b3b019b77a8723586484510ecb319cfb7f9885d284535577c264d7a57", + "content": "a890893b4abe1b7145e24ff3a4c91859c53c2eaa25964862843d4976ed8c" + }, + { + "address": "e14d568723cff3ab6cfebc693aec6d993bef3ddbd1d0689de097b62a71f45402", + "content": "a21a60a3cad2c6c495d9a732996d1c00fc74b00d717f14f6fbdacadaa508" + }, + { + "address": "e16c909eb28bb0332952c8851ad5a69edf8ea715ef47b96ba19c7de12af1b913", + "content": "c138b61d11cc80dbbd026b8df83a2926586182bc717ace4e57f3d09dde96" + }, + { + "address": "e17ea31a1f0a71512b82eb74e0507fa4aa8e596da8d53f559187374cd6511fe7", + "content": "be3817b71235d16864facd8dee9fb131ec4211d7a5c85f78463ea7f6e927" + }, + { + "address": "e189653d1533c2a1adc75c154425c58e17e872f18cf70cb03341478a9ae2865c", + "content": "e0381dce663b29e162aa27032537249a148b4f1894f0827e40efb0b46d1e" + }, + { + "address": "e19b429254a417451f8a5578911020988c2c64b4ad7d24dfe8a24c0799045fe0", + "content": "1f9c02aee24604fc5b68d91275f4b08a88c2dbf92d142d967af1d10d2ef5" + }, + { + "address": "e1d156944b13a8d9b5434967bfbdee56e60679ae967918ca1d2248b763338538", + "content": "bc593bb38e582dc1650011f38ad2ee2f78f0781e2f25ca024074ae0d7c20" + }, + { + "address": "e230139ddb6f3b887b32430beb0d37abcef7bce014275e5609aa5ec3a56af5be", + "content": "52966be19075191d0231d06fe60783225394b2709e7d8dc8990828fd996a" + }, + { + "address": "e271e9efaad1d8ea37dc3810ceaa2c6cbe1ddbdb497b41db7925e632e7dfafcd", + "content": "e1da463356fc2553d12a21d05461177832cb152f308a2306e0ac1026aff8" + }, + { + "address": "e2c793c08a01c362cb268db15be13922e6bdbeb8bc95984e536cd0cd33bb8637", + "content": "58cae870fa1dfd883e447cab9c7bd410d12c10ef22630cee1de604b77bde" + }, + { + "address": "e2ff947e77906cb36a020cdcc605d906d2c751a8f38c40a67a1618d51457ebef", + "content": "5d6ad88c6116009412af4584cbc16cac3e3ee3305c04e5323fbbf49a45c2" + }, + { + "address": "e33321576468ab325dc4e716ce4b6ae3b7df48c92c90dd43b599cf6481c370b9", + "content": "3567e82a0d66e0bcaa33e1f0c6e6439e555c35ac272c3c0044da4637acc2" + }, + { + "address": "e3bf6c59547ef9b5c0d03cd97384b5af0a8305461bbe9245df082fcfc21ca948", + "content": "e4b225b6a9d12b8884fe8f9b7ab49bf3419ed7d5d14907ea0360b3bf53dd" + }, + { + "address": "e3d4639cfdf9f97560c0c48102b14f89d4e2916ef0af68320385371fe747d47f", + "content": "feedf553adc136837871220cdfdee8260084935cb767b3580d6067193c8f" + }, + { + "address": "e3f6298992a5497b2ceea50aa10bb3e76f3a2223d47157ec79510a0a30370eac", + "content": "29961b2b40eba45b269c36fe8be6892b157c01f93d489e2c2fb6e7011922" + }, + { + "address": "e40f5c18003aef67b6c0138ae5347ba540dcdaab41a51d1b34224b406ed39cd2", + "content": "2d3dd4f71bdfcdf5a5c9054dd566e0db56f59744a6af24d4663bf2df177a" + }, + { + "address": "e4b3890b73dc478774ef7469602baf45f2a2a7aa5d877b1df567403a23b5002b", + "content": "9a1ea906e3a37c16c89863c2d2295006464dafa335da22c833ea42ec42ed" + }, + { + "address": "e4b9fc5246196808210a4b1512057e87117c51b1d4b920447b85e27269de4d9c", + "content": "98f144931319764d729eb8c96a4b74f3f798a4f597772d703d0e7ab966d4" + }, + { + "address": "e4db5c4125fa47c29017007281feb2917eca8ca47fa4024c1e60e00d0e26f2f6", + "content": "746f267103a83bbfed496e6ba5529a86ae2cf74ca69da5aac009fe92b94a" + }, + { + "address": "e4e8c662bac88eb7c37bae52c3f56b7eb1f47a9c0dafc95a8a7c521335c157d4", + "content": "c37001f9527f78e3115f4b753ef9026ebbc6f7049ed44b94ef2233e07db5" + }, + { + "address": "e53c122479fe3f3c13214d96df38fc635267103c9e97ac1a6815f2670ed943fd", + "content": "04783ec12fc254fb3975be8d72a902f9843aa6981f3e214573e0e1290cba" + }, + { + "address": "e58465e3f0a6af6144a8ade4d4ba0bf63e774fb71f7402b23b62ec1bb4dd6f5b", + "content": "ff15f2ee2f1a49e47c5f4db38f3b2e4a29affee76743f7712526bd0e117d" + }, + { + "address": "e59f15496be604342a596c66367bbf2803d19c4defff820402693cd20ca70f47", + "content": "80673afb469a7ee9d926ff58b1e5f3415cce88576b40bbe2e1a0dba985d1" + }, + { + "address": "e5f8699de5f071d9d9b3f41ea91c0444688ad4d09fcaf28cfd77f5539ab95def", + "content": "efab610177224081c67c0fa0f9f8fce58777a149f4cf8b56a383a4e699a0" + }, + { + "address": "e636b3f681c209913e5865de06f06ed484fce99894f11add3ef404142b76b61f", + "content": "6becefe2d00509b61699a61a9fde21a6a72dd39267bd9f6e8814c8e99a01" + }, + { + "address": "e64afffae207adf619c2e6b5f1aef9524f5b6312e8628b0f9e5ca496e0e3e38e", + "content": "1cb1716c02a08e3174b80738a48fba8d76034e4ebe30f6ecfbe691931be5" + }, + { + "address": "e6bef0441363f1fff019738338ab3d4de7c05f8f40eb146196ffd582cff74d27", + "content": "4c831edb80c40b11d89772805abb03e6ee902accd1b2afec0e7b14bd61c7" + }, + { + "address": "e740ada6bb9aec43fc2b54b82765bac91aa6a28bbf518ec77dad2360a01be0fe", + "content": "2196c6257af3ba419e8cef0801b6c7b57f5239a3841f7d394f734b52a84f" + }, + { + "address": "e7b3f82e9b429a422694c21c421f6926b75d1d7161d84461416b09076738be2f", + "content": "39d17bf50db5634167f81ece1f90cef7c44991001a974e29b3f3c6f99a3f" + }, + { + "address": "e7be21fc5559431e8102809e52069dab88427fe314653cfbe3258e916659fc8d", + "content": "8be0b0a667f72d7ab41d901864e0a34f5d8253d10beca410242bdf143080" + }, + { + "address": "e84f86641fee2c4e5e580d3c710d387429fc23fdf5659564d803ad68502745a9", + "content": "3a64e53de54d7c1d244639e9012006308cf47008e4e93bd0f01501d97f59" + }, + { + "address": "e8b337e1121ef2ea5733a15d211f67f3314de9866ac0780c6ba406e11b63554d", + "content": "c602d91125bb0e03204016bd35f34b0df78bcecfbcf894781a09b69d0cd5" + }, + { + "address": "e8c559599a843a81e1a63ef75d5d0b632701086ae0bf6f6d25908d3b80e15899", + "content": "a8d27bf711e15efb8c5a5b4c5f3fd8c9a266cd6fbccc2f1874890ad2e997" + }, + { + "address": "e8d7eea675b2affe3e440f56254627f50368f29720791fafe081d8bdc1f9ba23", + "content": "e36fe2e5adc8c961ba97cc97a784e1e06aec1d2346e91d03814e766dab35" + }, + { + "address": "e8fe9889c73e6fb976dbee9df77a30d7ebb33e7cd8c6e1b552c0a4bc60c2ee60", + "content": "ef3877193b101c0746c29ae8645e387880eb401c26057f637cf81bd9d6ff" + }, + { + "address": "e9e55596e3cb3c01f0c220c64b2816ad83b586747f05269db289c5c1028dedfa", + "content": "4978f46df6389199f498a46ec76c1a7a4a9dd09db6536ddfa43f72e71acb" + }, + { + "address": "ea12513a1997597ce9c00851460d7818a52c403c1c02ef1f2a3cdd0abd0092e5", + "content": "c7f123518af25b8709f343edd01f11a955a38c9be54c0a0f70a447e57e4f" + }, + { + "address": "ea1a1267aab4f07733ac190d1eb574956a2ca3f3b261c351e95cbe510c749d6c", + "content": "d2f66e5bdaae36de33c60bcff4e02d68c3724f2ce5541347aa02e89003ea" + }, + { + "address": "ea1b691d12ec728acbec31104e6f8b17fada2bbc8a558bbc5898efe0127103e2", + "content": "59dfdb94c3914b418e816d3222cfbcf145ad57fd4a0ee4e291c6c6816c2e" + }, + { + "address": "ea247efb3e21ba0c3cabca99c2899f51701ea88c7b2a48f2d10b53acfb08ba83", + "content": "d63a4732018d77a793055a2b5d0d47a1a287b79fa36cd0398e6d20cd23c8" + }, + { + "address": "ea533915932f385bb7605e9a381c563c007c2f8446555934ec5d97a3b46cbb0f", + "content": "5bb004906d1426a33829cbce2bf229fbaedb9c476191947ba8cd0464aa4e" + }, + { + "address": "ea6bbea225846318eb509369f577653fafa9c3940f1dd453faecc3c66e26a0ee", + "content": "cf395915abaa7cc7d5c4321b22567cfc27c62c507391105fad2aa7d0f034" + }, + { + "address": "ea803d0d522fe6a1092dc9a78640639b068e3eb7e9a85234783923915e8e2669", + "content": "cc6de57e52c0339d3a3b9bebc9c7cdfdc27697ff1feda95759ec46e0ca60" + }, + { + "address": "eaf6cf9637351a70a9a0824a5bbce0c50e3a73ad39cd7efc84e8597dd1c23759", + "content": "7bf69fd5a271c2d151264497922955782e7ecaf66f4feec568fc25b02885" + }, + { + "address": "ebf892a46a62d8d7a0262909d55dd1eb7fc4166f880fc429f1d66736c979e056", + "content": "1f00f4bdf80ba1975e9cb90a8a09de74ad0abc26d94cd89161a7266d97af" + }, + { + "address": "ec2ac80971fcfd91c6f8f9d71e888c38bf0399433cacab7ab624003984608cf1", + "content": "1ac970fe3d7644c29981b108d09af621a0a88a07e6fd8cb7b2baaaf09d58" + }, + { + "address": "ec4a97d0b08f4e0981f11f46d90de799370d2c7cd268954b4debc90e442e05a5", + "content": "88a27878a64872c56bbbb15d33c18be931a778926bad7e3b2b264c210c3f" + }, + { + "address": "ec7fb9b02c7dce494506cd3251cd81ae2b4fc2aa95afeacae7ede80b4e693140", + "content": "ab857baecfc11b765af88126520ba2502b31c143ba33da6adae1dc9b82f2" + }, + { + "address": "ec8e15ff1c5af0e9a12b9450dcc4f1f5e9892a80bcfc60ec19cca869ce8c2faf", + "content": "2d2f53564271e6f7a476657d7a5228e578d3f74080d31d0f149dd68d499b" + }, + { + "address": "ece21c0a32d8a0cfabbad08fa66b50531c2c1ada1fa7edee0fba757f1b0719f2", + "content": "4e81ad5bb7d74d8a7d456ca29f37cc6095ede07d086c5ac2670b3eb80d82" + }, + { + "address": "ed06622b1ee8fb1cd5d51e5ed074c4d1918f34000b36c6a922301584c5e92f24", + "content": "7fb9e7316ec236f7b096a57e9905240a1b3a157b946d5f672c14dbf9755e" + }, + { + "address": "ed68e76d44acbece2ffe61dbcf94bf9d93cb7e87e89a6f4d6397aae26374af49", + "content": "93cd1de4cb603fd7406c4588ad4cb90a8709616d74956e3fdca3e4371e73" + }, + { + "address": "ed6a4a138f961c9d6793bfac4f99f722d07c4ebb77069af1ba096157ea69d88d", + "content": "bb0b7f2e3e31d452eba2e179b28ee747b5ba30cfda47fe4df4dea9a260fe" + }, + { + "address": "ee0aab0d6b0ce0a03f97696728c9000e2767189f350373ca0146257697c2b998", + "content": "0eaf46fe3af1f0f7fa7fa1323c40d23964de0983ec15a396c8a9bc33920b" + }, + { + "address": "ee0ed669e42a02bf05f0d40246270ecac630e161fb58be071b4dc475c0919a1e", + "content": "415bd533aa5cfd87529a1ceadfcb691f301ee138145e2930640fa2ebd235" + }, + { + "address": "ee5e858c692a1ae2fb28e0300612704cf3248a8f4ece83ae7dd623bcaeff13d3", + "content": "b52d613ed8a5d92b36822f22c6992eb04dd73d8e74d1f19d168607f2d1db" + }, + { + "address": "ee60dbec95032f7335478c11b6483e4d039601d4ecbf2771cbe6a8dc1509f4ff", + "content": "ec1bdf797e77ad92e9678af756b7cc47e1b88eadd536f272380542c4caa3" + }, + { + "address": "eebf51094ae5c5b7f1a04347c157ac200c3935ef7de9111dae86b864d5954d30", + "content": "767c18479355e359c801ab3dc1958b5ad92b43dd7d87fb471c893d74f4aa" + }, + { + "address": "eed296ebe3e9a32116143f3f02810dd393a0d9c63035c184bbd4915d5ac4739e", + "content": "2add462b29a55a3185f1cc33f349a9bc43510aebdb2da891e56474d87e36" + }, + { + "address": "eed5de366c42428f4a93407ca103e6a71a9b6fcc3ee7ddc6f45f9dee62d76424", + "content": "b102cf737a5dbe22395bcbd3005dc9042cbe77354c5a08ba948bde36f3ab" + }, + { + "address": "eee4a0878bb173642e7c70b42cf51b0146c6a2550aba4573eaebae5267c1d8ec", + "content": "2e597c90efde09fa4af6bf4418a061dd03097e17df97e055a05d93b7c6a6" + }, + { + "address": "ef556e598fe28a8eca33923e21c5cad66b3954c301879e5eb78c29ddb9ada079", + "content": "43a9fc9252f346283ffc45d0dbbe4bd792bb7d29ec9bec407bcc7f7885d6" + }, + { + "address": "ef630adfc775597434138f6f158dec0b3efd303631f2a8a36f792838343f02f8", + "content": "55a8ac4765df702063af75f96e2f9798a6edff67d16603931305cad6cbd1" + }, + { + "address": "ef6c168044a71a90fc1790cef88198494ac92c10eae85f8a1bd91dd2a24ca12a", + "content": "29f4ee503dea541a65174f71fcfe380a91a83ef5e300af24ee1ad3307290" + }, + { + "address": "ef8009c62302c4afa28d5ddce195a7225b9c3c53744627dccbf499cb422c6088", + "content": "f3de172cd428714cb609d2f81ebb335329002cc5fe0dfaef5fbafa18dc10" + }, + { + "address": "efea6be6bc3e5b9675a6196d31c01c557f5cfec17e00e998a144de5115c542fa", + "content": "bb13cae33fb6d73b303d61cc1f13b09e5696cea08daeaa13e09dd7cd0a9d" + }, + { + "address": "effdd1c12c6b3a2c863737112c9b2965c78b7f8a492145013f031cc75239282d", + "content": "79a7c87bcf9632aa6cfced994badbcd2a868b767461d9ee23836608b8454" + }, + { + "address": "f00fc99e04cd466e40223077b5ed5d9e44a5db99ca57987a5307178fa4b00170", + "content": "98f1816409846c6c7b041717d1bad2ddb7c5a4fa5f045328f4cbd33ed8ba" + }, + { + "address": "f025af1bd47d08c18e50127c506e282797d80d55496cce33efd8f3728fb1da22", + "content": "054f69999b2104390842877473f752c7eeb210809ab268afe7d1a1ccfcd4" + }, + { + "address": "f0461f459af9431e3f879ec1135de4d31d913180388bfc50e256c1932ba02b28", + "content": "6fd5b4b451992270f9d34e3f85ac6e0887d4a78773a3f64f682979e80fa8" + }, + { + "address": "f04f72ef0bf0551ef7b4aad0bc8bd4a94af9974ca48b5a82d94155e09df5f7c0", + "content": "40dcd4994a132e0840d7916ba6bebdf01b44199b7c50b476b8b7c220006d" + }, + { + "address": "f0870f93823526735cad8567d1525a17ba3f23141d8b1d5fdc40d6963f2c04b4", + "content": "cf13442d940d40f2a9e703fecff3659ed95ddb3226eb92daddf215accce9" + }, + { + "address": "f147c864eeb18049a13b12a7cf578713dbc492d1a4a61772e1d9c04ee50f53ec", + "content": "c0cfcc6e044705bd3fb4ecfd7794fe28444f1e0f06a6fc82662a82ca57ec" + }, + { + "address": "f17b948e017014439adb977a36abf2a020413e8df2eb00572aa97feff8d2d8b1", + "content": "c6caf319f3e1d778586531887a7932b54807c2a93c944e1127dac37d1371" + }, + { + "address": "f1d40dce28efd6bbd3a51dbf0fd06f4128b3bde4056c3e9da94adca30d4b22a9", + "content": "1c412661b3f73d98ef7cff00e1fb0cf2c65436ee7c074ece4d93d40904a0" + }, + { + "address": "f1e6e89261d0698cf6249b715c60b798829f3bc8ee9890929b6de2b478738fe2", + "content": "89d288553f278630da3ca61c9062635cb26f11260196317c4f1dfa60d3ca" + }, + { + "address": "f1ec67c4584d110a55987a1592e5893d7b5162f76cebf3b1b5e814975c6ec6cf", + "content": "9ca6eaed175bb5862088312ab4319d378494e93c64a42c69e3c44e3fc53b" + }, + { + "address": "f24e07cdccf59acb2eb1b329b49934352866b057dcff632dc0fa185ea746067c", + "content": "2f949f528f17d5ff0c916b5d4f403dc8625eba5bf446bbcce286757f9ae7" + }, + { + "address": "f250512452ec10ab44272f98a2288243148f9cb98b4b7469dae0d116320a9168", + "content": "5131c37f917eae1a3680b21c4972775c51b1226d9a072a86183c47c695dc" + }, + { + "address": "f25a1fdd43e0f450b57e187910b4e866eb844edb699f87d9445620ee396a4b7a", + "content": "8e541807b450fdf8ca497d6546b48c590a80a39a8af106c6bf4f7980348a" + }, + { + "address": "f32f43e16d9932d999e91a4237cfa845ab6bff6d8fb00d7c96a68e44b5c5c1c9", + "content": "8182d416325a397d382facd1f840dabfef43ab45fd35a50e431dbcd46d05" + }, + { + "address": "f3978e0eede5913f5346d9c894cb01e1ac5cb0756e494774d2b1089089c68c46", + "content": "9b4bdeb4a314b603f53b43f541620a7247eb65e8f3377b716195d583a316" + }, + { + "address": "f3d3a8b6d361f90e1f0daedb683e41e3707dbf22855f8854470fd6dbceb8e80d", + "content": "b194c75b5ee9145b3979d9affa51b1fc2b90406a021a9b6ec27bdae63763cd8cfea3ac09b7bcbf144beffba20026b9047381e4df23f9d7ace1e1c3bde1f02e9003a879e1f5b06a873e7a8f8085410268fbedcc7dee6a524bf832576a9ac2123b601936c5511f4e153ed0f3f983a803e60f0c57285aa9e07b7e481200e1c1abf6cb22f4223e07a6a8f7f27fc7cb38cd656e3fd6209484c61a916af2ce8e82bcdef1965348b9ffb1d72118168416e21dae58a147497e6fc5c7db26deb47b2dadb1b0151a027acd0c5a85ffd6f20b034c8e1d7e88332378b1d7fbf4f783ad3a36ceea2fca7523009fde5b5f9bb177cd7c2aa8982be0b41d0f21d8efa1db81aa34c07e5e15ee785199fb7bdfda5263034d5fc2e82ae1a330dc6129948fe88da180a0618a07fec8bed8c6b3e535083d73154399323d2b0cb0ee6cff90ef8d7dea7d6abbd4eaad85cca134ac7c284eee05a28c909f99cca9747f5a8119f03833744d47057e20e7dc2002b29eafc207179193bd935eec4fdb6549bb7e6d1fe70661152f430ad7ee0c3b098ce6fd0fca4953a89c169e347057ffa67c6dfa57104188de7d3c5ae41af8d93751640cb6b7f922a8e59917687320fc70363c930b2d3aac1a2e41978a30c8ff24e4e58fc95175b6b22c601187cb8ccb05578e0831b942b1e5310097a66f7921fcec59786c6fcedfc78a2cb87f8b9ea78c480da923d9753219eb0de7b5514eebd8007247ffdbcb39b3ff00e18b053f4d1a2536e021cf936ddccda5ccb003f7ccbc7c3b1f05e8c19c052e2dc0014bfaa9f4f00364548b0e2efba6473655c796faf72ae9df0bf159d72fbd542d4ea81efcabbd6f66cd4f13de36d38633e73ff304d66e57494499eb7372304e3b00d469c8eadf293b4568733cbf86f57c0d3575b5dce4b2b866059468f638264f399fe286cb3c1a6eac607e457a80bd066616ab362759473713de8080e60052b5bb07ef9cdea22a6d0b9c90ca89726cb8bbc7784f8d8dba201ce4cd56706495babddcef960beafc57e7567ae77a42ea76fed18838d9b46f36a2596b213d5a045374781cbb65a4e1146d9ca77607d85d96f14dcbed2542b5f45b85d98714e1d7158390c803441f7907d27bfa9b26646a49a538f97869d533685f16f4121461f6d8852a5d8d2661e45d73546580dae557ea2c90d8fb265dcf429e7c70880119a1d4af282302e9cb8f5d0263a0971b4e522cef38c382fb8aa964778599af1cfce63a12ce15c9a2f2bc0a2e7c9e5e063ce85942f2796d19861f90f0c9ab884195a9755a77ccb7851389186bfc7af7f3a5eb932e3eb80b578ee9a81e8ab86b1bcaebb92703e69dcf70ec7cea13c6149609aa09a44bb541244d62f8e2d3610905c264fcface717f7fa230b822d3a42684347664d39f09aa6192ca0b38b11a105e60c6a66cf21dab6d9a7adacd4934b3ea48aeba94fc0425e67ef70d2ed5731823307a7671cf2288a4d25df52ec5a4e18189980eebd7971f95e33129cf4aeacc70cfdfb4d46091ab05823f471bec9b1f5f14c2770afde39f306ef844cb166812d7fedc15bbd85f31144b6140b625beddad05c08e39e6cd6f1316c914094434ad9f5f71387b527cd7c78034e7eab162388663594bd7fdf50486915736bc59e3cbe615b7bf9bafc4a56d57ed1e8a478187ef48c26672d809373e40bb5fbcd58c78fdd8f1388df5db07bb11edeae0c36084663d1e6b9a1b5997d2c1104e0bc6a48e8b496e5f4107a44317acdc76ac19bd854ca4ea2a7772a94ba32852149309a4288052fc1fbd9ef167042264d854eb6a4edf86aa16940d60023d24616a96dffbf02ee3cab5cca94b33186075b6c50618630f67530833d2804de151d0518e79c1a7149d77c3cf603ffe43856a2f974768bfe49974cbce6493c984c746caf42e05ef556cb71a4e8e8d75d13095ff79fd24d4167df83bf07a467f8433d52220d1be362a7a0cf77d79aa09492e8dc4602f5789cb7ecfa835164634c6b51455d4f7577218e6a4675af7c730445c20e3ea55e520cad9ad5790322da2471142e826b7da95b26c03f7610fb894290ad2397fcb2a31717a8a5a5abe6310bad3f29bbfc1c8c2098fc2b684f2cae27cb46eece2788cb925e04624a5d964af75c516b8f564f19a5c6a23e7594dab9b8e96538643d948fc588a9f5714d1e84dfc8e9d47e027baebcc261e70744a86cbea90133ef9d33b21e05d230dac71467cdab6c03b435623f29f8ee6916a40362b774d04fbd286972547d414d0523ab9c388e02451f484eb948dee001d75f76d4fe192e90b934d9f16f3e3bcfd36ccc38b73f9a9211f1f9773609798de5fb20820b5f5027699cfd065942c46c8a66ff081e7d828a05bd5cb8a519f5d0c48c51348316319c06d6ac28d31be6984ad366eeadbff0f864f99706ed9f1dac486cd5a19e1c15c3e2fb97ec1e927f0ae98eb093c5dc122d054324cd3400bb84ef8476dbce3d9a979c05dcafb17cea0f1442904d934089151e5bd9fc4f87239464219ad2c3e6e50b6b9692285c951dff884024cdebdc4983ec9b2793f878274293f51e38ef241b7261fdcda2622dc3229b4eece17113bb76d8bc551eebf2dd045eb355728080c8ed9b046a4d3f4c703ec0351c04a6f41b9e7153340efb93af47f815b86316a2d1baf5349d5596c0b293f5faa41d870e608ae2f51a39ce6337569a48d586eaafef3299d7796d69d365ef05010b6e6b5a94779232110130c541ec5168318ecba0629a65588fcef0424115f38931e7179e17a2a87b6e2364d810bbd6bd5aded68eb9446f47af4d7fc99d9ef1d520f91b4ff84ff30ba5c8467f0838d39dea563308c0c391441745970e54a60082b845355c6aed0ddbceb3ab70f2c6c0c8b29fde62c0d2ab4203afcf90e61c13dc183709bc78e85bdc12d91fd08fd18c5a99940f0c490c9693d8ebad08a5c219e18fe96b0b884792a2cc0fbdc8e4580af6e488443d3e06420008150c69fd0627dd36dd9a966339678aa0a15858ffb9de9f066e4d0035185385d23743b254161958076f6e51dfa0e49c9c566fed64c71b5fcc562826271658f528c3760916910f61ccee460365e60b282f37762e009377a018e59ae149d78c466c5bc2e7ef0dcd5a6551782ea34f8469abd787a8760928fd1840de912a5127d67778163a666a8d01875e8cae544d7dae77e08373f9ad72cf926e0babc89496051f3f684709402d2df4626ad6ab2d6c514eb3318d0b32638fc89c6792e5ff5f29aa9de26aaf2016155d3bb0ea0285ddfb34d96213c15a78a21d9e47034dae9b34e226c8571aea57eb8c90276eb3c76a0f278119d00301b06efdfd4d0fd1b9a5131a0b3062251079cb759b8156cbd2845011c1d1711b0bcc7f9db938da90eeb6b7b5f01910bd7854fb236b93894dbeb91762a2cb07d6b1a1d85da1b97dfea240bc338b3a8a9c0ce2162ee1f08aa814786ddf8c8be4439c3e24aaaacbaab6ca516c945605f42ddaffd013e37134db7802b2134758aea1d10d9f22ba9eb9311020162361af70a4a8fafb7fbd142c823a0a26e5b368f70a0cde7a0a552f8d2ddb30560dadf8f59e93b4ba6c2275d2165a44155e1c28c5e10a5813f32824ef38f7b5a93b8420a9ff6fb86d3965dd16b718d3dcc7a17f0ea2d7fd183ca16467376714ec14808b0ab14e7a13785b68d63d4895ea72f46cbf93cfec93275f187285395d1110eb809f53266b61b5432b8b2707e261ee470db6643576fa6a1ff5a00d7ae6b4b0effae330c050f05a71ab1bdf7bb13c67038278456c6c9950aa806036e6826939fad7fa660c75c6ee4a12930c62eac7e11a91297bb3915b6afe6560d44cccaa4f8a8277a77f2008db643871f5622fca740c996a70503f48e8c464927a6f73384cd77c5cdc89cc50195159faec2dd5be4bd14121af758a4b71cf7162092e723505e3911d59f8f9d4b9bcf6344ab65302c67a02c1c2809a5d11238f6a58533138a489198765fc2cef744edc95605e556494b5e2463f02cffe389fc2c2b2044994791ad36f1601da7994a40bb26b314ecf8482b98e3d564ecf2889ce0c8d9b97c71184716623aed488bb185febc20de5d227250d69af53bcb46234e8cebf376fdb8dbf1d3eeb3523e48af1e8c9136ae1a1e98e2cbf64955e12c86e8e603cd8c245be08315893c56e1910e7cd1998a1c5f8e0aafcf4101cf17bd64036c1c91f18112927d907657f959e68c5e87d9d0983bf1c705a7792542a2f7029a31e6cef3869e7a1b14968bda8bd5e6d2350c49b8f87cc38654943a4de7eccdc356b3d022d065aa8dbb080c407486f598459d8bd2206e3cbd858344beed1d639b7339818a654f78755da5a845d8f33c77d4efc4d970abec95e1695ba95e46f9a21f617d6d6533c3577d0a7fdfc214faef3f2d0df39cde8139e295686ad99237308dcc44db7ba4fb071b0cb86373c30dc9e243b4af2c20eb9d12f08c6dcb9c2736834a32fcc115072ff1d550e84a52066e3fcfa6e6544bc81b293e4e3ee3642cb4f9e95501794517b0dcae4b528d1dd6778e05e246239d76fefd0be7c875f6a3d07c3380f5537522b0539f61017efa842c5c4371a126385162bd8830d1545493b5f19e24922f47e5d81fb4b9a50404bab4141e314b4c4179be75823755aeefaee6a5aa875429eaf66c9e34e8a96c50af2c5a14b8f6633cf131b2253411f2687c26b9e42d731c9e783f09c8778bd4618df1c79eb36336138d51ae13bea528d4a18be0054f50e5c0ba184e996ee356cf3f8ec20e81b7925e8e78e2cd3540dc18911af7f4623ff4b4fbdc5df57ca35b4e364026d7e6cb19fdc9156368efce8b2c62cfacb1b39b0c39c14f4744ecce56a6e2a477ab1995a11864ea207ce575ff98f6a827db73e2f09c253f732eaa599c8071226e3e3aae4aa1a32bbf18cff0c6ac1b3cd809daa3e35594cf43a211eed9ff1ceb558e42b6e5c0a98f56012c0d664c0973893a4d5cf1f28cbda4ab245c1dab654a06e14cd73725807ec853fa2c33d7b2800f02732c036e5aae8bac1d9d80bee764f41bfdc74949bb731ac9b116b2814c71837b4ff1f8fd91df16d66a1abb8aec5aa9883cb3ce2cde28fa39207358e9a3d068f0dc48c232314d55ec7234505e8c1d09f017608973705dfced8308ae45c187d14830bb26e7bbfa0f105cdaf1dc5e77b2b27f373a15a0d320e059d27aaacc7491993d09d4428fdb7cca6aa17810a53fb5b285c2fd7358d6ebf046a04b7c3d7643992482e0f96a8c0993c1eddd7448943901d60d4bebcbf72bceda78d77c90343f956c8f46044fdfe68ccc7550c5349d91b0e08b007f29fa59368add289793d136445b61d369c865b6ca550ed62c682c2d4ef4d0471ffb9e3f48b48c6c120d7ea98a1cd3dce44406a8c66f2053eb85b928c21d112b3272c0f6100edcd3a246667368f19a878fb842e31075c33908ae912a4e428489fbd7d095164cdda790d62d8a2fc29e8ca3c136b02b823a64b1285427a99ec1b3d755b781031b608d99b7b252a2ed66bd2f3d7dee08dd8692d95aa2be03ed5090f806c58d6d5de0fb4d5aeac6b915d12e57d53fe72bf58af370de72c1c188d094846d44e449a3a965be55c17568a1d283f28c03c98f853e73fc30379471eefbc5b9df8bac410dfd9ef0cf3841b011efdaff6642aab949e25731986fb0b2428f4bc68db370b1fd344b84b4bed1ebce4cc6224fbd768fab5b4a204386f9999f522b81db552262be595e49b1330519c75342413aa2157d4488d1499930a61ca6a6be0816caa17782237ca7a13ce3ee24049de716664901fb8c417b00c74a483f34e1a1374d5fa7f41c3d44c25759efc68ac8c5954f88f69b4be66221a97a0afbf2707c282f671d383c4e7e6963333fd63ac031f68f73f5245ffdb1988999e84e21de8b78f00bf2e25296ffc1013355c7031e27f63cec79ccbd913cea472a1a528de7faaff18e6bd00193e9ee77648516460275c8467fb1edbd615c38fc981d35317999edfd5f9f374dcd6071a5716d5d197c1f34bae92bf10246efbb592ea465ce53b1bd33d85c31f09c3e0d95a397b07a6b3be9e542fd5f4f5c96506d6e471786b7a99b0af7532c03d70ea23823c56e4d96add4944f3cef2a9453216eda5d5ebde68a01c8cad6132eeba684eb8442d696d682b83e8de19f5be0dbe5e1a9999cd82912769875d27110da42b95688a4f0ff539587ee914b42a8de02fefc4aa023351c67659c60a90c325a76f74d6efd0cef77329a4a2db2b05234d2509b4c2c6195a6db621976b8cdccb0bd20399568b3e28a2d31d563be1259069cbe5cba297d4d2533cdfbd4cd433fc7b36b0e174dd9e09d03994ca0ca70701d7c40f823dd8ca6b98d2883a7069faddf8f1f5cd38635b6d3d4d105b09ed950c7beb12522fb8591d02971e857dfbcdc3544e882127e7fc0c9d53861a9dd59eb16d58f21899a1f12e339789ec3a60b58e2ca51a068d70cf7925211bb610ba0c4714d030d5f254e47b49e5b6458c085f93aebde596682bf732be4fdb308a0b810c6644cc039fa13c63aa9ba37e0ab3f83caf57518517021660cb24438474912a6fae11ac94a9d3f716cb1c773498fe721a776ae754ac7aafab59372f3080ded3b97813113af532647b4db03b6fee9dc2aafb1904f71a0744c3ac0ce4bdcf89ee14888d306577817aa78aadee833abd5ca30541ed94c1211e97a3a7d5524bd1cae83de2bff755361c928f5d7d4bc2f590536918d64b49df6f83922476e2d5fce6ed5c01c145af5221e45a134d69ba32ce14d6d0e141007bbf4aeb75fd051bb544d6db37f4c8aa500d4a4b20d97e77a266047313cb5d7d66bd4f7c6e2e7dc12db946f705d4ee9ce11efbc8096dada533bbe4abacc472592fb7612bd67ad6f21fe8efa152c1ab086d77eedd1906094771cf17c00943a765e5841f93a84e978c7f6c18a0112755756b667eb93d0d36bd19b4712c550bdcedacee0b670e0486af167150d244621a8927423aecc14f9e5a7c3bbd242fad4398b636c27a328849cda58187b8126d08cc27b906590f315ec20811d8fb24df67d58bbe93b5dbc80efa242da2910f7564b8f951b7150c7f4de171c7b294a18bb4c53798fd0abd875982827a7f78b8d7ac46f04cb3e0cd917c41bb50dbd1bb2ce7ac5a3677d68d44e6f4b6c9817f46e229eaab2d6226dd8199226366e86901d28833aa9a3eb9aa2a0b780386ee4740e5f1f006731d856bdeab049d2947db38bf68a4ee03517862f40741e4ee2fcda507d7b1f6384b5671b649aadde6a29400dcfc5276019b08749a1aeba5b74e190ad970fda67d047b78e8f96239c1136367ca532b968c7072ea78071f7a76f717bc4840ea10effc43071e2a8bf81af7a9faea127b5cdedc83f7134c863ff9bbf9b5bb7231fbd1c06be60e7649ea99b8f4ceb79ed3ef67173dccd3764bbc2265ff15a07450c8273cfd082df628ffe6035f2fed749fdb97c322a6968a7db726dcdaec1b14485ddb18bacfc791f431037767725727e2f6b8aaa18654cc84daac0e517703ec638c5ea749375f53a6d5c4a650effecc7692caa7f291f58b959fde6058ac0f330033351547881e3039d72cc6f9368b0dc21604659aa33db67ed6798185983ba44fada9f3d066be8e75ea609298c6c41586e58f1a7794ba62e848743c8eaaaee3de69d1db213cbd5860cc8e3ee3db576e03bfc1bdc2e89fa7362978877089acb97384cb2874619776d2e56b79fa6b341594f1ea150767a88fd22626d9bb31868ea9014f792ac18d4ae8026e62e16155fd4885de86ecc06e7316654f330820e8f2d15825d3748f0137b0151c1874bdf3995b88635a3b47e20c76559d63cf03e44fa57c4552f3864505c0511cf0531360b33b7d6ee48c933cda67aefb4d710b8e329ffcd2262a1a253b523f6ac132269ab0f5f82d59c04529361defe1fadfbd60cbc22418a342af11fe94272aa9f962317dda68364ec93b96ad909c2a7bd2c8bf33aa5dcceccc5b116780ef5cc8f8b51df627cf1fde4ef385e5f4cfe241e3070ff87369f90a3382637d4dc72a0f0d47a824c2e8b8925b33151693c5bcc24a945fc1b19c97ebbb163d571fa0e1dc829b606be1918c5e4f3d9608de06259bbf256a3cbefe2699537fe4d0afd8eb3df21e04bd0efd533a19e9404f560c5b01338e1075b3a9ba6b076ab2ec6631d0d7d7b3bc0255512ca4b6df445d73bce50e5bc535c451cfece46370f859f8afc137421a36247d02909c03c33d4616b424098174ef752e4f7aff0f7ed702ae0d108d28a2f529c4a0e8bd7ccfbbe6a599af38eefcc4acbf2234c310bab95ee05c850013ce93b362e6ba57774b3175371229627fe4ec8ff40695e2ce6ee1aff2502211e01c6be5ac4a5ca72ef4c5f07a4f6ba507508bdacd49c02165c13112fc29ba154ddd5e195dc66c24640f8340e09a786e51a7977a7ad2840ff8805add7e7bc57e559fea85e0f68e8a2c0adba9aed1f7eb4ccd6b5690f0d712718a8c55b3d3022c96c59ea75b2ec54369a67fdadbdfca4be46412ffc7239c531d281ef2862e76958df88e2ec9d54ffd28955c59ace81bba55ca1cb81d467e40dcc07ba9c3625bf6bb848b0f34fbc67b885d04a7ad2fc921710943f4b7bd49794624b1ae8bdf948aa9231f669919a5d8bb376af3ab78c4338ee1d07d474b47090a488495399cba2ad7de2d55440e0e98f8cbaf35dcab41b5bc6e809b68c566466a93e35f3ee44893b75c718198f6795192c49b1e0a1e5c02d75b832168397de219d7ef11a2db250cea1a5ebbd2176c36a8e119897cabd8afaadd84fb6cfe8fca85e5f8e526461255b2d0277acd6d02efd21457f2a68040ae5c258f3b21128dd0f331f4f8e759c029a8fb93e5496df4bdc6ddd7e0cd496b044ee369d985507023c4167d1c37a4296e9b97904d7948dca9030e7f0a17f5c04dd6ea54c382f8e6d42a3c45515e8366a6ee0386ab0e78ce5675da2258e60893dc8f098636a2da01c545bb651536e2f78cdac0c3cf6f91f935b80ac79c8e917979288b98e4f3e6cc051a83321748ec26ae818624e116ca2fa9e8638fa0f51e22e8b5d1de1c9593ff3f786ad370b08d13d866f7f88ba343ea4f5d4cd2a67079dfb44f801bd7c018c92277f0fc2061273c8053a35ac78fdaad887e08a552c89e695ff684007a1da55a5b11f133b43eb7bdaf872ea8116ee136c6c9a59dfd36a7484587d156638614365e04924f07c0334a7b78692e80491dcfdc1dd08d26ce85160a79f73ddc2bb56b4fa4812c868169e8d6abf1c8014349a2adcc6495b5022297293fb6a8c41cc51356f6b62c377fc6f466a4095fbd847cc085b0975863c28ef0b0834d126a963ac8d1cb54b6e2de0ff5d9e4e24df4e7cabc9dbdb38bbd851c9175c38a0c964300540c12fe45b19eaa16f131cc0088ab54f19c84dda44a7fd0ed671c632314fd0b391b2020798d6fe4b46aa610ea73320df759cf103831c5f386071ac2853be5f55e75f90636fb19ab19e8907bee2d028a10b031c789f1d4230886f3e1486a5f40464de0ef26f97c007bb8523dd47dac0d512f8af017644dbc82f29c0b51e4137d9b2280bd6edbc3827c56d69917fe798a69f06a3531da622a6788eb4bac614eca878da781984f5c35d97d94f630b05df718386403070973f7ddc855983b1a9424a8ab81de30180654f454ae3540f816f4c29e244267d16d5cd08ab9311663ec23fb3d525f6efc61ca777642afd7ca30ebaf02ff2e5a27e6c8a78b2fc1b7b4999e593a43972fa8d4053b4e9c9945e0bb8f52d402ef9124d09994afa81385dc0031fda2bb9966899519a3863f53b7f50c45084b45f2374aa2922f36106ba8b31ca44687fc05dddebd0861961d3f5f3125effbbe538e0b3c4c649b8780f7e2dbbbfdead50a9ba471d86addb8d258c20a254786a1a7d93322dc4ddbc02b42a218adbce829ca62896132f1e0572ae11b7e9489d6a70f061e8ff566faa21a7792f105975d3995901b7b6c26b4956f41be6a437e55ccd3a0864c6484e8ead746a535732daa993779e37d8031d877979d76c2829317d4d6c595ecce56e018877ca4a7a44501db2305bfd29b06d082728772e10b3d15424d913b787603ca96ef19120848dabab78824ca2bd635d396d5a09bb5527680dc909e979ef470ca12865b251a5a17cb948d537e53f7857d01ae1acf6ba10d6cb1b4f723dbed61d2ff97f70a439434676bbb13c40061f8a24c4dd47cecf638d693855b9e95bdc056d08cc34301a1a57dc6dffc9b1fd7aa3e02bcf10a277ec327880ef7d82a4754a6ba9dca0be9db10ba77cb6b8009deb103607c4cee87ae476e39f9ed06aebdb5206b7adaee04016f587e9478bd2834bdc9afd447efdbd02877ee667d73d4b74103db4a6271575cb4b1cb63e82260b1aa71635d73566bbc0d4d8b3eca357cf322fdf1c648fd801926297394ebfdb7dbbf3c655db7f9643e5ea65c8b55574f18ed09ba3b1fe74f264a0ab14cccf1f2825d8ba21024db0af114b8e4e756eef81447c05209e758d3fa98bb133d997158f90e2f70acc0c9f464f5271cc23d1a4461a63aace6064afddf83a3f9916a12f3e17748cd38fb7004988168f99cdfc0f1d9df4ac29386097348273ed873c22e6bf758c013d849dd5057648e80468b04aa2bf7e0102816b4fc4705cfe0df43171377536bc392b577f9c32cea183e3ca33d5160dfd19c4c2c11c46299bcaa45fcdc202447ad0931ee5899bc49deb5563a04085442458bf490383d04c9bac2abcce059f23162aff2d6867702cb86b245c87ef0b891e18e5aae2137ede28d46de87f55b7e68e0f0bafb8b314087225fd27d1168108b4c996c1108a59a93381dc9962fcaec88b09b41c41f10fbb6508e84ea392001c98f0d165e8f3612f6c100379e373c74901e20f93c953e8a14adbaa78ca76cf68a3258962be58954a6ab551569ef8ee4e0d0eaf265429157bf18929fde9ede478998ee3395339798954fecb6389e59e44e7f8f15790f6b1c2d1db204e23166868f3f0b719bae0d931f3c7a8e7e68bf94f98af4d8c87db6f28a1ef1bc5a66f4211bd17d1fac1d9d90089f2bbb9e8624ee8feb9463d874a358263a709a28c0569f615cc036f9af48f38ca74980e4ef7f5b18237bf8e759cffb0f34a162895e8c58360540472f09bf4a94e9c250ea9175e8911e8387f502f40b100825e610eaac59b61c0982994297ab413db2da5ee0c014ff6998e7dd66fbdf2546d422ee76d6a9cd8ca6cd157565050ba38808a38f48a684cdd6ea68d7b32988537068e5aad77ff3da0728fde64712564fc8a490bc5e38f675288492bf288934c2f9c4cee3a89acbaeec3a24568aa3b5529fc8f3531186776effb1d21a1419b4aca796ea7dd5811b760d7ba617fad146c2cd60a7970d284fc5d6103332c45878d6aedafd96c1699c7ce3a8282a78bc1263e7558815689c104f97fe3633d6166aa9be5d9034920b46220e16bb4a06eb62edfb7fedf74bea4ff15e92c5a33111e13120b14aef131cb84e3b2568582a0b1613c7997c802f14beb9ad1b0d113fed72846bf3676ef824d559b562afc05d9e6172da9473f081cec8b1879f0a369c8c4445f4e870884fd17c7a11f026282e388ec66ebfe5a5961f71a925057528463728da24038da6ed2b856b6c3c70d7fc109e0a8adc701c47df5dc21d827f1ef7dbf129f6c34190ad90f906f29407f8e839d3776d77ba6387149a19a252f52291b332a3803de43ad704f66768baa42e09f13f521b3175d48338238edd2e65aa1f97565cd1eb4bdfe4498673edbeea7a6e1e2e31671886b12e10646eb25f80a1ba3e8fb63e87fe39e108e7542a8273527c8d95274a2cdb2f5ce352db7ee9b8d24dab3c63e653ff4ba6fff165a3fde65a66fc5bae4b6c418d3c7b211284b8718da96a0c88ad896da82c6775f5b981230bdc935556d3dbeae03a84785b2e5bcdb200ca332b8931815d6f8877714cd437218e7e53c77c4fae5f941c8d742bd7a8b17c00d41f3671a06d8c7680918acb73146b0e3dd93d78e709cddad953ba8f3342ca8e54559f17545e495186e174539a39d2336678b8bcd96596c45d7a3d0699331fe4514e7fc4198c556055af01238996f847cff42668b01776c8b18a91975291e56c56a2bd5080445ade49503511c13e943f4e6fae53a7524276cdbc59133bd10b5c310460a161443edaf0c06bcab5c719e3e5679ca9d421064991a9054c9977d8be87bc1ee871b476f2646fd4700216069fb88fd10925c1f5cdee4665aa9a01dc1ce637602b1890bc50cdde00681576f3b45aae6479075c5e911bcf4f3105668bcce2d95f926284d6f3a13a186fb678a624addb2176b22de5b923065739db2e48ed0e6a8f58b38411c271545ecf1596918a7a8ee6a22d3fc8ad3ad749aef830cca4e338deb819373fb30f9931cdb28d599ac343e08f84c8418aefb4e252991ecbe0fbeb19f1bd546667f0899d00ceedd155fecc5bce8696ade94128286c1e9dcea8f707c1ceb79fc07e00cb4b1f80bf69f6e5e3643e155617b06ef5e97daab26adfcf1fa041fabec4711724dc4292cd986631ef92d868e128396a95ab92afa9c8f6daa9b61ab2f9e5ecb4bd015ffe3956784feb131c6f787f81285868e267184c3048b98d65893bdef02b65f868b0722649b0946dbe9baf696127f7b17090eaa21df28890f2abe39de7932d0b2f23a5be5507aa20d896da6b5f0397e41db6d2d56fc697c39695b86e1ca16406becd2453f5a12febd4aaed3a9d4f61afbcbd204bd40c3260b85801852ab3b3702831d4806f061c895c96724e10a4bd77c471edf657779bd5b75fa52de2b5186a0fe964c83fce906adb8e84f5ddbfa4504a6e0ed2f376f5ca73c9f9341529a1eff55342fa79b3110664c7d621ff874b362e6fc719b04adc16a65ea4fb58ef1225d048e08ce064a9e878f45bac8c1482caa821f3e49d63f625d3d746befc120c50aacb21857b49dcba84d771702c9f51e12c705d1931718fa9ac4a43f25b2dbf5870495c4529d0f0433baf0f989d325a2ae95a4345b2879a48738262a66891d14476b0256b5d18088e5de4dd6de388ddde90c0c12f063208b584d952809343d344ebf8efea13d93174d724d9cc4d22e108c53d05cd018c94a2863ac298b233e9d340458aa3f95a37ec1f6bd0503726ce316d724e7c38d08e96f06c3a1fbd09d71c1140612b69cb2eec6806377bc63286f63db342c430e2adca6e02cb8050c18931cf30ba6045b2e85cb8c7ade95a0274e70525c06adccc3630d4ce7d26846c409010fb637cdaecb89594fc5d5b6bb022e0e6c36d3a9a886e2f29d78d312505246280884e781dd414e48daf7e217a4b633bd94b6f5ab55a8136dcaad767b493fc416792c79fc7f5bf174ade8c14966f94a825f68574cfb2b124d29b6c1bfe2a054296948f22f5f9d0ba677b4b6e975fbc51aa9dfa07fc94459b3a26ad7dc743f8b2d21a4b6689073feec26b10eebb2b05462f533ef0a3473f6e58a85d9d42cc8c49ee18ca58c10ad820ca49f741bb68dde8ec1b05ef3dcb8fd2f1eb01f4e4fe0ad0c08ee38c5453db12bf4d0e5b1ec0337c66f003ff2a4f1754411dfb45623635de1d99aa4487544bfb313902e3c89af68db749b741148ef8276c8383d4b127753e61ca706dfb2bb50410bd84569f8de2d34a33251c5666b88a31b59487d9c4befe25ecfcf48b0466e305bfa2516cc0ffd4a1cc66e5f17a619fbec7e66307bb8dbf6ba8cba6c52e51cc98e556b9175e9aec6d6360d8977de54cc885e4aee82d82d3f9dc7fdc6341d0e6b43561a14e4237d17d71e1ba57bdb8d2dfdadceb70353904722ec8ebf0e896d998194ac61415d54059d9f23d5f99dcba35c96b3204acabac099901932285754a9ee5dcae71355b4a807f8b0a96ad909fecaf4234a5dfa3ea20cb7af54ac6881ec950935039497d275d3dd6e156e8b0dc9f85d1f6eaaace6df263aab2d7db1529adeae29cef59d88660ae77097f3775c6ce218d806ac319a71ae7793aaf4847b964ec9d5de6acb726e8e042f079915f9f6ba7347f248dd87f077961ed0e22282e8051fafe46e4d941ddb6c6080d9f06ce58d4e38a9f972d4edfe67513ca7fc1ea9304b887aefa1d997d135157a006739bdc879222c7fee25d721914d91fb22b270eea71b4a0ef276549d93e8266e8bc3a65bfebf44b28c9b2f84219d4c8e762f12c14970452397e6a934f442653d13450bdaa37d2ba780cce875cf7a589419ef4e516066dfc5ea5441f9f6f831d449f14478c308cc29e18e127fb78b20776d4d62773b5d6c4f86685044c76bb0767c4d3cdb8c8510ac6d577dd39f6199ca8010a5b79b10a00e2e39ebc8d0adb4eb2bde3a3b71e1a55689e23935cea2ea34c9f79bb4ae225220070df672ecff5f9d18edcb9fc92d4f7d1f41ef7f1a7f51e64463f2d27c8a673f4328f2b62a9045b37cce3389c9f856e6290064eb597f563686d852be93a0517161c5fc6382b406ea9c42eb23d5f4e8514738d48b65fc1001cb363deafaa6c0c99fae515263590b0da0f0f1df76d0b5083118269b95ac2ff2e7c05ad142e1e2e4c21ed9063a80be81eee682fa850e8bc32a7b3571967e8703863de4327a28f447578c32321847bfe4a4203922c632d05676aa6c24af1194618fd4599d45b9082d79ce41c6de649b699ba2ce7c2d13b884ca2aca1404c506c7dda096d321019cb5c6cc53786e735a41a8119f058a2bce94fccd8f11121861c56801c91adda4d5e02850f88c4ece677102dfe28bde6d5dc403d65d52c5dd9bb36e38831f55d5b942e400963bf941f0c2e5bd2c5859b5bd1e4f348048a9afb4f11705937ece71643f9c0f258fd46a67f5870596c5c72e52fdf2820feba616ec48b5c89c694eb6b711010b16c737970c377bae67210556523a11a7cd9986b54606bfac377301246ace6f88e87e2eec6c514fb31882588bdfe674d2398b2b40739d22ffebff04a28dcbdd9fe4a01bae79f1cf846a8c7d4e89251a652ae3a85d83e5e728ab0c77478abe1aefaa037c7fed589732a29c76eaf793e6ccef07cf2f02c6e9f5a3bb791d3a04ef77064824ccc04dd39b6325ad78232668648430d81c0ceec36bb8da2d4857808e9e5664c8289b30cd840e37b3be5a71c4c28481da210fcf5826c2448ef1aa6a33a87d70ea6d53d34edf47362d76517d5d24e381bf13e28ccbb70e3b935dad64d4991063d99ce548aba1d52e9c3f88af3045a2a028f99550a433c7df79e7df01805c174d8a0a8a29b42c6b02de881a9d9164ff7650b1f5a1f4124bf71bbde7ecac4a103380961caed6ed7fc4ed7e6b8c597ddccb51bfcff5c97ea527784f568acd72a32587edcc16445b8b9c375ccba15407bc75085cc3c30ff7a08166a8a4f992b9c2bf9a60f81b846c834a679064f4988631d5b176f297e63aca1d443432f69b980ebde6b3838f0f7ef9d9f07f7b7bc441103eae842bef6416cf8a92df00de27d513faff8797d22a87f9ecf153719930797c27af72f80669172f6a0d32b55b1d6188a1282720a953f2eb8447a8255e91c13ca27302df1c87dc072a2cd24353ff1ddea9e2c3ec5d3ac64d5a55c4235c7a8e9c1709cc0ddf73bd60948e8255e45b26ffa3b2ea4c0fe6c6ad4e0947facd916b6534b771cff1421715e024ed7db47d24df8c9e93129c717d7890f98bdde2db1d9e7dc145bf6e1dbf5a96e7e8557b9d224db2af9de44cdf5a2c8a0e19730de0d98b29e5e32a856c5fef189cef4cc003cfbc3cbdd8c028ce1435fc234d2242301b43302233b4821447a09c43be933a182583d06574cbfe90ca9c3bf6c9df167dfd39bbbdf7300e53c73aaf4d0b53e8ba0d25c9acf057c730604a5676aa5084ccc3650ddffdf2577498985ebdd369ad29bab280e1c31b0ca80ae8b094540f4af8c263c7aba73d5cb59f8ec3975dcc6da4ad9c9070d60187277ad945c7b8e45175cb74a9e6877ce82caa5d59385934d861d256728d9d79279077207a3b93781d19f7f4d45d7c1e0e82f812081f1ad3d022ae1f3278cd28fbc7f984b9aae8c1ec6d045ebf5ba23357f9a71f00b36a0e085ada09cb426f49de6c9d9b3f586d14a88f79a89a3aba4cf840ae4acb943ee5e8e2150dde56711404a0284e9f0799ae8ba1ed501f069dfc0d410c4620591ce16d406ee1fef856d7dbeed9e3c046d139aea77ce92c0ee0bca0c3e56a554d6be6340f98cc0b9a81d4b148fb39416f3fcd3389ea439827d1701cc1d21d157874a40a0a801c895c61e7e85db81d2f8c893c486c2a89fc74bc228b2598080e0f6761be47719864753b2dad41e92d5ca328a00c6103babd362734a2a717ccceefd0db1afc02c876503c0f86975bc854370b2ae2fa48eebaed6782b2963addb5a1383585a1285ba30eea94fb6cae090d631bdc78c3b76b90085acf04376283cf404209c0ef8344e6929fadc084378e8625b9aa543cafcbba31c09372a3e7fda46b3cd49810bdb3aea2bc2341d886d44e746e800eec4a0e7fa21ed5eb0741691e60759271a6c6551bfd9c1e6377c291fbb64ba697cd16785a3ff88961e68a88203578dd65f28631ee7d678e1400ffef8346f56c42925f9fc2f0f8acbf7bf7ef35ea3dddd54ed4c8de40ac270bdd9c338e43102dfe9e1e031a70b58e3bc2614b60c58f4ffa8559d4e475596013742ed92a484c7e0af0ae4addcae7eb72d70811d3f391a055cc40cec0f24187e9e85cd166ef47de4b1acc5e4678c24d91f23a8c39b99c4f04f4372d7d9e03cf0b7aa713524b40b4b571eec664fad285ebc2f2d5fb034d2028388d382ed5e77829479eb482da" + }, + { + "address": "f56da415af8090d68a786ed54a4c83a3ba11869b024083a8b691c65956bf9d7e", + "content": "6a8690bf207b66a3546ff9b91092a235d3e18240a2eba1fc545a5846e436" + }, + { + "address": "f56eeb133b4813d1c3fa729387dae050715ff6abaa67299eceddbabd6911e49d", + "content": "355a19813c03315d721ace1aaedb5a4aa11a9caffc102275f80c5b3db013" + }, + { + "address": "f5910abd62d3dd49415eb7c61ef72d66108f93171b622622af7fd193ac638fa2", + "content": "f24a5bbc6263f8f678870e5bcba74b0ca6596c93e268a824d8809f5a6807" + }, + { + "address": "f5ab24bed8efdfd743426bc3274e4259472830206b825bd2a088b767bbbe3c4c", + "content": "b328476168e506f840f030b1768b412b8525132d9ea9f9659c64e47990c3" + }, + { + "address": "f5b70dbe31a69b1065575268f9c5db0a895c18554bd706c0a8b4edfe82b38499", + "content": "77c203943253f5bae78b601fad3ad7b2e167f89fa4657d047124db76712e" + }, + { + "address": "f5c9e66b42d4425e781c11444e8af96ecd8b608d582db1206999dde5ce733b97", + "content": "0d83c9afd6d210ccd1d4d82fc0827ce46b3cbb919052ab0ae533ef5dc779" + }, + { + "address": "f5db6cec271f425b25b8cc94d392b3474c5108608cf698cf3dbf9a4fbc58ba4d", + "content": "c857c271578af176f4cd966802ab5028c809ae120182165344263da98294" + }, + { + "address": "f5fa1a1112612fc506deefb7cd3b0fe42b9510e9147c139fde4089dc693103b1", + "content": "146f714f238b4fcf89424e41e8f88d4e5276c9f218258347de220d378a71" + }, + { + "address": "f64b895158abe6614ebf61e0a79c97ae22a1d2754ce92dae317f98736a5c9d69", + "content": "f2b375d5b67a81a823aecde1f1f01ffbbe16761137842f8ad9f796380920" + }, + { + "address": "f6507be5c219d6bf99f8f449f39a5ec07f472dc896711b4447685f81e47178de", + "content": "305069feda6ed021482fa27d40cec121bafd7092ad4f2ee3cd60452b4d37" + }, + { + "address": "f66bbeecc328b57f27fa67c1fe0d7d42ed59e9656d68516d9ad9f3c3740ec723", + "content": "9acf3ca212772214289eff5b70eda7cad263dad930b48697124d73cef984" + }, + { + "address": "f684adce0b460cb086bf38ea0005e6f04fa940bf9ba9042e267465b75c28684b", + "content": "ee4d047f43e9ba82dee0cf2b0ed4f3da060b4b9accc2cf5390969e7ec08a" + }, + { + "address": "f6b75fb4b85f623b95f79d5a060c6528a4d9f41e16b99158bfba1357fa4e985a", + "content": "e80c1a6532ac04d25a31c211101c6d3c83a06f76d6bf07caf07b2bfe4d9b" + }, + { + "address": "f6d85f6af946c48094738356d11c2c8eb4edfacc0c17df02bca4f518ef694932", + "content": "70838741a09341d293b2154e7c0fd6376f819c0dca818e86bcc75a7e6b85" + }, + { + "address": "f70bfdc0d15a8a142518b0ebcc8eac765d465e7ffabafb8ca228a6a2f28d59f4", + "content": "287fc4f56d761a91c50b4290e6fb4d8132c23e3240d4b68858f9729fa1f4" + }, + { + "address": "f758fa3dae4a7732f145d45877a9693606b2ad7a804b2e3113e411bdb6a38fc9", + "content": "859b65df4aca31272388df910c06084d5911c158a2fa7ef486a4738e7f26" + }, + { + "address": "f7ba354f74d103ae25e0b5633dd84aee08be10e444e3cc242fa93217de3201cb", + "content": "67312b548c96c6187f06f464333397e410e4cfdb2e4ee7697ff70279cc25" + }, + { + "address": "f7f30d8b0a54e008bc28c9b1430c9efb40aa3aed8db62d60f319a533dd014395", + "content": "9777e892208af0eecb67fd6443e8a88dd94919efc9cb59b8c126fdd53a73" + }, + { + "address": "f8128275242102869b7df15e0d31c346f4f7ce3844d67501a99fa3c37a082e76", + "content": "56bec8513dc11f7b0bd49f61b52e3c088d51d9d21d230ece02cb4f38af19" + }, + { + "address": "f82aff175a0b1a758fc56c002d5ee180f162458a5ad004398fb0584f3da54aa9", + "content": "dc2b8e77d76cff0f814de8b1e46237c7b2cedac5e6b03ac0d5c78d4d5b12" + }, + { + "address": "f88752b660355c77dc2772eb6c4074bf1a63cd10ff1b040af7c2711622706526", + "content": "4a1f35a28d7f767b4c6081d3bb4ff39c07c1f6e0d2aa7bdcdf20ce3b59cb" + }, + { + "address": "f88ed6e1d8c01360849a11df03bd6bb18145221be1f6b8566bb2f70086f14e06", + "content": "31b92009400b45f7aab004b0575b21b96a7a66a5e6dbb75a20d69379e380" + }, + { + "address": "f95dff9dc17faf81fcbe2385f582ca3bc785c4522148b8237d3ff86350ea0a00", + "content": "a5321cab7274824681191b6f57d592e972000d9227c5f1a6ef3cac280c0f" + }, + { + "address": "f9a50683d9c92470afce3c5b19f0d2b142eb85c67b003bd414c740a82014beed", + "content": "5577f21872788bc51fdb01a989bea23e655ffff36097f29a0d5aef846c7d" + }, + { + "address": "f9fc334744f619e99d08d2e23f9a9ec747fc36e0f26ac528d3de27bb0253d277", + "content": "e2d54a9ccae8ca83842cb79c10d60b91d5fc57b5072b049ffd2e41f083b6" + }, + { + "address": "fa11ea7ddd492d3c92fc6baafdda2f4437a0f429c2858758a8e346a4bc491346", + "content": "821abd7c3798ba92e67c756b6beb4da4c519256249a7b0da605f9062f1b8" + }, + { + "address": "fa422e96cd0863d53e591e5083519a80b990db536f42621299d784c4675604d8", + "content": "b936de99559441dd8ec02257ac6887e8e1276e5987d333811b71a6cc731a" + }, + { + "address": "fa521998f449d4c13d41eaa2d1fb3dab45b3598074528e50d62090c2e6e99f90", + "content": "8dd999dce4979549ec4391320d11e4bd74b5421887983a05e69dca778924" + }, + { + "address": "fae8415937941cf2648a05f5bddf9bfc4f6373efa409a8709e31cba7d7583973", + "content": "9974f585107228b8b2c762cc3a02706a4edf2815eedb2c4b40f2bafaca77" + }, + { + "address": "fb1fec2943c343651d97b338b0d2809c120a0f0603cf2a8b18eaf2472f4f6cfc", + "content": "5a7367305ad238c4f82ed5ec7089bc4d5dfb09e1d77a46586ac5795e155c" + }, + { + "address": "fb35e7221b609320bc2f7b251cb7112d548b15eab6eab863d0126bfb4390a482", + "content": "9f998aed63d2c6d27d31cd9556d373afadcdf5442b6df6eb67a6e9aebfe3" + }, + { + "address": "fb84cff2740a404b4d76470eebd8b71732c1a14a25132b90baaa613be9ebf29a", + "content": "51376f586e757437d948d5172f46181dda32a0deb2bc32b4abbce2e10331" + }, + { + "address": "fbc007d07c63ca3ef940dd629a1998e31c8a0900877cbdbd9c785a464ed97a5f", + "content": "f08509d7efcbd2c2a40305398bff62d8b2199d02d2239f23d724aeb700a6" + }, + { + "address": "fc3117f4c66240f2cd2973bf458ed7dec29b291b2d77fc3a2d7765d15f2a005f", + "content": "b9d5d36b4c025c02199b551362bd18e851d0f702c6198e15d4b2d8f5c9ad" + }, + { + "address": "fc446e57242b8cce3d487447b32cc54bea8a717b520605bad0ff51d400889be2", + "content": "ffbaa686fbd196d06a24e210dc3ab166ec2f64021f28256d6198bdb1ca48" + }, + { + "address": "fd0a6fd4a40d26705814ccafddfe41c28ab5467785c474a5f78a6eb819230637", + "content": "f868f8b86942f8a38b8ac608f665fe52edef8ed2132f556a934dc4171467" + }, + { + "address": "fd12ee41c753d85ee58e0c61e5d27b12321545a07960deb3da3af57c4719ce58", + "content": "2c368b36f09de663d54de02a78702e0fc56048474ba0c6fa5da555286abe" + }, + { + "address": "fd20fd7cdeb10e62b4ca44ccc6598374876c237e1476c1a57a45d93d62d6f7c7", + "content": "a05cdad85f62918cae68d59fd9c11dfa043e010a486bb855a92d3eed8e2c" + }, + { + "address": "fd610df775c0b229ccc76d1777a51c2f5a022fbb88def3f49dac8888beba7a4f", + "content": "48a3171e0ecd4c23845ce5c36840d8064bf033ba7fe6a14b927d054f6859" + }, + { + "address": "fd613b466d3dfa826cd26c739542866cce62834815747a9b4cd10063b6a360e2", + "content": "cf9b453f4c43d83704ab8a68fd34898d2e88caea31c900efc518aa110862" + }, + { + "address": "fd6e9fa58b0c0fc77f98a0214338e55b22a948a2c94287e1d8966458e54ca688", + "content": "952b5846261158f5bb42f719e2711f51a23d1cf59b124e64320f3840c2ab" + }, + { + "address": "fda305bfbd2e90750a40bea424e47a21bdafa32005d48a3734a20250b193e8fc", + "content": "086452d6497376313e56bd0c0b3e02a4a032b2e548ad831e03103b53becb" + }, + { + "address": "fdac2b2c969f2f80d3dc205063f0660c2e299ce60841ea74e86f7237686d730a", + "content": "112726ba27f23a10564d1d06d032f9d96f03d3a01afa58ac44a201401dab" + }, + { + "address": "fdc950e24a7f4301d4471ba2d22b569379e381584e35d5d827ea1b1c523961c9", + "content": "51629861dde4b6e6f85d817ddcce900a201800b1002fde6175dc3a16c37f" + }, + { + "address": "fe1945ac4a3a0238e610b9bf6abd02d65bbf2e71f99c1a96c447ba3c5c612aed", + "content": "d65606042a8b1e4ddc0d0e6f918475bd25da6461a39c415f3f1be537834a" + }, + { + "address": "fe78d104a593bc8305f231f88bb729b37cd69c34fc306ab6ef76b402ac5bb06c", + "content": "627bc4902f9065ff2990ef9d862aa24df3854e589d1b73e8dd7a8a21fe01" + }, + { + "address": "fef0a3462942ad58b96828fba739f50fac55d18ada485efc9cc9df42940edb68", + "content": "e5df0345a2a0009fe56738a754772d01a180979d92532e5ae1d56563cc01" + }, + { + "address": "ff1f7f14a33af77095e787226667dd2aae42fc302fcdb3590bc8aa74286a9ceb", + "content": "fde2dd3e31151cc8ceeffc136d6697e974be62a30282ee5e0a6ca1aa09b1" + }, + { + "address": "ff20dc69d8f0b0e623d560861566c00b7f3c55af7aedf4fe460fb0ecee906fb1", + "content": "f75818138da91714e8e50589f16e7b4a78a8bc1380222e87f1020eb6688f" + }, + { + "address": "ff306180e648f8293b41583606d837e93d338c5ca2e990bf5006aaaa9d765c57", + "content": "93ff31441f8b9ea667a998cce4cbafe428369a0b05a7fac387e9106f0d99" + }, + { + "address": "ff5c3fcafc00d8e9308782e22531966dd253df38a3b9864043e123c40baa3aab", + "content": "52f7be291d5fab72c9120e7e656072e47f1eb982ee9a5e420e1fecd3a811" + }, + { + "address": "ff84567982fbb5f104dff20967aae016ece5bd079cca9815a4ed1f166e79c4e0", + "content": "32862b8afdb4f945c92fd00d4edc15ba01bc0f7da936867a7bf54cce3956" + }, + { + "address": "ffbd9447c4aa92dda582eb2473b609e8e0f1645b5baaa7010d0f28233179e017", + "content": "2691f343b853a9163c9f925a0375b348815bad9f640cbc6041ef258c6c9e" + } + ], + "size": 4303347835 +} \ No newline at end of file diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs new file mode 100644 index 00000000..ce5529c9 --- /dev/null +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile, mkdtemp, rm } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { Writable } from "node:stream"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BrowserNetworkClient } from "./client-fixture.mjs"; +import { encryptPublicFile } from "./pkg/ant_core.js"; +import { mockWebRtc } from "./mock-webrtc.mjs"; + +const large = JSON.parse(await readFile(new URL("./fixtures/large-file.json", import.meta.url))); +const records = large.records.map(record => ({ + address: record.address, content: Uint8Array.from(Buffer.from(record.content, "hex")), +})); +const map = records.find(record => record.address === large.address); + +function fixture(source = records) { + const rtc = mockWebRtc([{}]); + for (const record of source) rtc.stores[0].set(record.address, record.content); + return { rtc, client: new BrowserNetworkClient(rtc.endpoints) }; +} + +test("native files larger than 4 GiB support exact public and private seeks", async () => { + const { rtc, client } = fixture(); + try { + for (const open of [() => client.openPublicFile(large.address), + () => client.openPrivateFile({ data_map: map.content })]) { + const reader = await open(); + try { + assert.equal(reader.size, large.size); + assert(reader.size > 2 ** 32); + // Cross both the old 1 GB cap and the wasm32 position boundary. + for (const start of [1_000_000_000, 2 ** 32 - 8, 2 ** 32 + 17, large.size - 8]) { + const bytes = await reader.readRange(start, 32); + assert.deepEqual(bytes, new Uint8Array(Math.min(32, large.size - start)).fill(large.byte)); + } + assert.equal((await reader.readRange(large.size, 1)).length, 0); + assert.equal((await reader.readRange(Number.MAX_SAFE_INTEGER, 1)).length, 0); + const gets = rtc.requests.filter(r => r.method === "get_chunk").length; + for (const value of [-1, 0.5, NaN, Infinity, 2 ** 53]) { + await assert.rejects(reader.readRange(value, 1), /safe integer/); + await assert.rejects(reader.readRange(0, value), /safe integer/); + } + await assert.rejects(reader.readRange(0, 2 ** 32 + 1), /range reads are limited/); + assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, gets); + } finally { reader.close(); } + } + } finally { client.close(); } +}); + +test("a complete native >4 GiB file streams with bounded writes and matching BLAKE3", { timeout: 180_000 }, async () => { + const { client } = fixture(); + let reader; + let written = 0, largest = 0, closed = false; + try { + reader = await client.openPublicFile(large.address); + const sink = new WritableStream({ + write(bytes) { + written += bytes.length; + largest = Math.max(largest, bytes.length); + assert.equal(bytes[0], large.byte); + assert.equal(bytes.at(-1), large.byte); + }, + close() { closed = true; }, + }); + const result = await reader.pipeTo(sink); + assert.equal(written, large.size); + assert.equal(result.bytesWritten, large.size); + assert.equal(result.hash, large.hash); + assert(largest <= 4 * 1024 * 1024); + assert(closed); + assert.equal(sink.locked, false); + } finally { reader?.close(); client.close(); } +}); + +test("large-file memory budgets fail before content fetch, and allocation errors permit streaming", async () => { + const { client } = fixture(); + try { + await assert.rejects(client.downloadPublicFile(large.address, undefined, undefined, 64 * 1024 * 1024), /memory budget.*pipeTo/); + await assert.rejects(client.downloadPrivateFile({ data_map: map.content }, undefined, undefined, 64 * 1024 * 1024), /memory budget.*pipeTo/); + for (const value of [-1, 1.5, Infinity, NaN, 2 ** 53]) { + await assert.rejects(client.downloadPublicFile(large.address, undefined, undefined, value), /safe integer/); + } + } finally { client.close(); } +}); + +test("pipeTo writes ranges beyond 4 GiB to disk and honors write backpressure", async () => { + const { rtc, client } = fixture(); + const directory = await mkdtemp(join(tmpdir(), "ant-wasm-download-")); + let reader; + try { + reader = await client.openPublicFile(large.address); + let unblock, firstWrite; + const entered = new Promise(resolve => { firstWrite = resolve; }); + const gate = new Promise(resolve => { unblock = resolve; }); + const path = join(directory, "tail.bin"); + const disk = Writable.toWeb(createWriteStream(path)); + const writer = disk.getWriter(); + let writes = 0; + const sink = new WritableStream({ + async write(bytes) { + if (++writes === 1) { firstWrite(); await gate; } + await writer.write(bytes); + }, + async close() { await writer.close(); writer.releaseLock(); }, + async abort(error) { await writer.abort(error); writer.releaseLock(); }, + }); + const start = 2 ** 32 - 8; + const completion = reader.pipeTo(sink, { start }); + await entered; + const gets = rtc.requests.filter(r => r.method === "get_chunk").length; + await new Promise(resolve => setTimeout(resolve, 30)); + assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, gets); + unblock(); + const result = await completion; + assert.equal(result.bytesWritten, large.size - start); + assert.deepEqual(await readFile(path), Buffer.alloc(large.size - start, large.byte)); + assert.equal(sink.locked, false); + } finally { reader?.close(); client.close(); await rm(directory, { recursive: true, force: true }); } +}); + +test("writer rejection and reader cancellation stop a stream and release its lock", async () => { + const { client } = fixture(); + try { + for (const cancel of [false, true]) { + const reader = await client.openPublicFile(large.address); + let writes = 0, closed = false, aborted = false; + const failure = new Error("disk full"); + const sink = new WritableStream({ + write() { writes++; if (cancel) reader.close(); else throw failure; }, + close() { closed = true; }, + abort() { aborted = true; }, + }); + try { + await assert.rejects(reader.pipeTo(sink, { start: 2 ** 32 }), cancel ? /closed/ : error => error === failure); + assert.equal(writes, 1); + assert.equal(closed, false); + if (cancel) assert(aborted); + assert.equal(sink.locked, false); + } finally { reader.close(); } + } + } finally { client.close(); } +}); + +test("in-memory downloads use one JS output buffer and return recoverable allocation failures", async () => { + const original = new TextEncoder().encode("In-memory download round trip.".repeat(100)); + const encrypted = encryptPublicFile(original); + const { client } = fixture(encrypted.records); + const NativeUint8Array = globalThis.Uint8Array; + let reader; + try { + const result = await client.downloadPublicFile(encrypted.address, 2, undefined, original.length); + assert.deepEqual(result.content, original); + globalThis.Uint8Array = class extends NativeUint8Array { + constructor(...args) { + if (args.length === 1 && args[0] === original.length) throw new RangeError("allocation refused"); + super(...args); + } + }; + await assert.rejects(client.downloadPublicFile(encrypted.address), /cannot allocate download buffer.*pipeTo/); + globalThis.Uint8Array = NativeUint8Array; + reader = await client.openPublicFile(encrypted.address); + const chunks = []; + const streamed = await reader.pipeTo(new WritableStream({ write(bytes) { chunks.push(bytes); } })); + assert.equal(streamed.hash, result.hash); + assert.deepEqual(Buffer.concat(chunks), Buffer.from(original)); + } finally { globalThis.Uint8Array = NativeUint8Array; reader?.close(); client.close(); } +}); From ada3e706d8efa6456f68b9be48af26b74e1b4b86 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:43:13 +0200 Subject: [PATCH 02/15] docs: record large-file browser download decision for V2-1398 --- .../ADR-0004-direct-browser-read-client.md | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index ec38600c..92dcbba0 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-09-25 +- **Last amended:** 2026-10-01 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -655,3 +655,59 @@ in flight through the shared chunk cache. The bounded read-ahead described under streaming reads keeps at most five, so repeat the playback comparison of six and ten on it. The method, raw results and scripts are in `docs/investigations/2026-09-29-browser-lookup-alpha`. + +## Large-file download destinations (2026-10-01) + +Related: [V2-1398](https://linear.app/autonominetwork/issue/V2-1398). + +The browser download limit is independent of upload limits. Root-map file sizes, +seek-index offsets, and shared range arithmetic use `u64`; only chunk indices and +individual buffer lengths use `usize`. The JavaScript facade accepts nonnegative +safe-integer numbers, rejecting fractional, overflowing and imprecise positions. +Manifest validation no longer rejects files over 1 GB or over 1,024 chunks. + +A reader retains a validated root-map index and uses binary searches to identify +overlapping chunks. Decryption delegates to `self_encryption::decrypt_chunk` +with the original indices and neighbouring source hashes. This avoids the +dependency's pointer-sized whole-file range arithmetic without changing its KDF, +DataMap representation or stored ciphertext. Encrypted records and reconstructed +plaintext chunks are verified against their respective hashes and sizes. + +Complete downloads allocate their output as one JavaScript `Uint8Array` and fill +it through bounded WASM reads. The optional `max_memory_bytes` argument limits +that output allocation. Allocation failure rejects before file-content transfer +and directs the caller to streaming. Automatic free-memory detection and +implicit disk spill are not portable browser capabilities; applications own the +memory budget and selection of a storage destination. + +`BrowserFileReader.pipeTo(writable, options?, onProgress?)` streams the whole file +or a half-open range to a caller-owned `WritableStream`. It waits for each write +before fetching the next range, writes at most 4 MiB per call, computes BLAKE3 +incrementally, closes on success, aborts on failure, and releases the writer lock. +Closing the reader cancels at a read/write boundary. The core does not open file +pickers, own browser filesystem policy, or change the companion SDK. + +Removing only the 1 GB check was rejected because wasm32 offsets would still +truncate around 4 GiB. Whole-file WASM buffering was rejected because it couples +supported file size to linear-memory capacity. Disk-only output was rejected +because small files and consumers needing bytes can use JavaScript memory. + +This changes the public Rust browser descriptor's `size` from `usize` to `u64` +and Rust signatures of affected WASM exports. JavaScript reader sizes and offsets +remain numbers; the download memory budget and `pipeTo` are additive. Ordinary +native `data_download_range` callers retain their API, with an additional +`data_download_range_u64` entry point. Wire, storage, payment and node behavior +are unchanged. Upload size limits are unchanged. + +Content-buffer memory is bounded, but root-map/index memory still grows with the +number of chunks. Streaming also depends on destination capacity; in-memory +output depends on the engine's typed-array limits. The JavaScript facade supports +positions through `Number.MAX_SAFE_INTEGER`, just under 8 PiB, rather than +claiming literally unlimited file sizes. + +Validation includes a complete 4,303,347,835-byte native-encrypted fixture streamed +through generated WASM with a matching BLAKE3; public/private seeks beyond 4 GiB; +disk output; backpressure; cancellation; writer errors; allocation failures; +invalid offsets; manifest validation; and shared native range tests. The generated +WASM tests use mocked WebRTC hosts with real Rust protocol and crypto processing. +They do not establish real-browser or live-network compatibility by themselves. From a43fbf418bd7296d3d18045541d16e16f32cfcee Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:20:20 +0200 Subject: [PATCH 03/15] fix(wasm): restore download concurrency and harden large-file streaming Complete downloads fetched one 4 MiB range at a time, ignoring the requested concurrency and restarting retry rounds per range. Reads are now chunk-aligned: a shared engine primitive fetches a set of chunks in one deferred retry pass and hands each decrypted chunk on as it completes. Complete downloads use one pass over the whole file and copy chunks into the JS output out of order, hashing in file order. pipeTo fetches 32 MiB windows of whole chunks, so no chunk is decrypted twice. - Restore "Downloaded chunk n/total" progress used by startup probes. - Abort the destination for every pipeTo failure, including invalid options and a closed reader; do not abort after the final write. - Add an AbortSignal option for per-operation cancellation; closing a reader cancels in-flight fetches and retry waits at once and evicts only its own records from the shared cache. - Take download and pipeTo options as objects (legacy positional download arguments still work) and reject unknown option names. - Accept DataMaps whose chunks exceed this build's MAX_CHUNK_SIZE, and cap manifest chunk lists at 2^18 entries. - Build the file index once per resolution, keep no hex chunk list in readers, and remove the unused progress plumbing and expected hash. Co-Authored-By: Claude Opus 5.5 --- README.md | 43 +- ant-core/src/browser.rs | 22 +- ant-core/src/browser/manifest.rs | 46 +- ant-core/src/browser/wasm_transport.rs | 110 ++-- .../src/browser/wasm_transport/download.rs | 565 ++++++++++++++---- ant-core/src/client_engine/files.rs | 551 ++++++++++++++--- ant-core/src/data/client/data.rs | 64 +- ant-core/wasm-tests/README.md | 6 +- ant-core/wasm-tests/large-download.test.mjs | 119 +++- .../ADR-0004-direct-browser-read-client.md | 58 +- 10 files changed, 1202 insertions(+), 382 deletions(-) diff --git a/README.md b/README.md index 252f13bb..4e58ee32 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,17 @@ resolved DataMap and seek index still occupy memory proportional to the number of chunks. Individual chunk buffers remain bounded, including for seeks past 4 GiB. Uploads retain their existing size limits. -`downloadPublicFile(file, concurrency?, onProgress?, maxMemoryBytes?)` and its -private-file counterpart allocate the output as a JavaScript `Uint8Array`, then -fill it using bounded WASM reads. They no longer retain a complete plaintext -or ciphertext file in WASM memory. The optional budget limits the output buffer, -not total process memory. Without a budget, allocation is attempted up to the -JavaScript engine's buffer limit. Allocation errors reject with guidance to use -streaming; browsers do not expose a reliable portable amount of free memory. +`downloadPublicFile(file, { concurrency?, maxMemoryBytes?, onProgress? })` and +its private-file counterpart allocate the output as a JavaScript `Uint8Array`. +They fetch every chunk in one deferred retry pass at the requested concurrency +and copy each chunk into the output as soon as it is decrypted, so WASM holds +only the records in flight, never the whole plaintext or ciphertext. Progress +is still reported as `Downloaded chunk n/total`. The older +`(file, concurrency?, onProgress?)` form remains supported. `maxMemoryBytes` +limits the output buffer, not total process memory. Without a budget, +allocation is attempted up to the JavaScript engine's buffer limit. Allocation +errors reject with guidance to use streaming; browsers do not expose a +reliable portable amount of free memory. For large downloads, pass a `WritableStream` to `BrowserFileReader.pipeTo`. For example, after the application obtains a file handle from a user gesture: @@ -64,16 +68,21 @@ try { ``` The same API works with `openPrivateFile({ data_map })`. `pipeTo(writable, -{ start?, end? }, onProgress?)` optionally streams a half-open byte range and -reports `(bytesWritten, totalBytes)` after each write. It fetches and verifies -at most 4 MiB of plaintext per write, waits for the destination before continuing, -closes the destination on success, aborts on failure, and releases the writer -lock. Its result contains the byte count and BLAKE3 of the written range. -`reader.close()` cancels at a read/write boundary. `readRange(start, length)` -remains available for media and custom streaming sinks, with a 4 MiB per-call -limit. A sink must persist or consume the bytes instead of accumulating them to -keep memory bounded. The application chooses a memory budget and supplies its -disk destination; the core does not open file pickers or silently select a path. +{ start?, end?, signal?, onProgress? })` optionally streams a half-open byte +range and reports `(bytesWritten, totalBytes)` after each write. It fetches +windows of whole chunks, up to 32 MiB of plaintext, concurrently in one +deferred retry pass, then writes them in order, at most 4 MiB per write. It +waits for every write before fetching the next window. It closes the +destination on success, aborts it on any failure (including invalid options), +and releases the writer lock. Its result contains the byte count and BLAKE3 of +the written range. An `AbortSignal` cancels that one operation and leaves the +reader open. `reader.close()` cancels the reader's operations at once, including +fetches waiting to be retried, and evicts only that reader's records from the +client's shared cache. `readRange(start, length)` remains available for media +and custom streaming sinks, with a 4 MiB per-call limit. A sink must persist or +consume the bytes instead of accumulating them to keep memory bounded. The +application chooses a memory budget and supplies its disk destination; the core +does not open file pickers or silently select a path. The adapters supply QUIC or WebRTC discovery/GET requests, runtime timers, caches, progress callbacks, and output handling. Browser descriptors and wallet diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 0d524734..922890c1 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -27,7 +27,7 @@ pub use protocol::{ mod wasm_transport; use bytes::Bytes; -use self_encryption::{DataMap, EncryptedChunk}; +use self_encryption::{ChunkInfo, DataMap, EncryptedChunk}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -255,16 +255,16 @@ pub fn decrypt_public_file( } fn chunk_infos(data_map: &DataMap) -> Vec { - data_map - .infos() - .iter() - .map(|info| BrowserChunkInfo { - index: info.index, - dst_hash: hex::encode(info.dst_hash.0), - src_hash: hex::encode(info.src_hash.0), - src_size: info.src_size, - }) - .collect() + data_map.infos().iter().map(browser_chunk_info).collect() +} + +fn browser_chunk_info(info: &ChunkInfo) -> BrowserChunkInfo { + BrowserChunkInfo { + index: info.index, + dst_hash: hex::encode(info.dst_hash.0), + src_hash: hex::encode(info.src_hash.0), + src_size: info.src_size, + } } #[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs index cc8eaec6..56b7a342 100644 --- a/ant-core/src/browser/manifest.rs +++ b/ant-core/src/browser/manifest.rs @@ -3,11 +3,15 @@ pub use super::protocol::BrowserPaymentNetwork; use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint}; use super::BrowserChunkInfo; +use crate::client_engine::files::MIN_FILE_CHUNKS; use serde::{Deserialize, Serialize}; /// Current browser testnet manifest version. pub const BROWSER_MANIFEST_VERSION: u16 = 6; const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024; +/// Bounds the work an untrusted manifest entry can cause before its chunk +/// list is checked. At the native chunk size this admits files of about 1 TiB. +const MAX_FILE_CHUNKS: usize = 1 << 18; /// Largest exact byte position exposed through the JavaScript number API. pub const MAX_BROWSER_FILE_POSITION: u64 = 9_007_199_254_740_991; @@ -166,7 +170,7 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest file.data_map_size ))); } - if file.chunks.len() < 3 { + if !(MIN_FILE_CHUNKS..=MAX_FILE_CHUNKS).contains(&file.chunks.len()) { return Err(BrowserManifestError( "public file has an invalid self-encryption chunk list".to_string(), )); @@ -181,7 +185,8 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest } chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?; chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?; - if chunk.src_size == 0 || chunk.src_size > self_encryption::MAX_CHUNK_SIZE { + // Sizes come from the DataMap, which may use another chunk size. + if chunk.src_size == 0 { return Err(BrowserManifestError(format!( "invalid plaintext chunk size {}", chunk.src_size @@ -314,4 +319,41 @@ mod tests { file.size = MAX_BROWSER_FILE_POSITION + 1; assert!(normalize_file(&mut file).is_err()); } + + #[test] + fn file_descriptors_accept_other_chunk_sizes_and_bound_chunk_lists() { + let chunk = |index, src_size| BrowserChunkInfo { + index, + src_size, + src_hash: "33".repeat(32), + dst_hash: "44".repeat(32), + }; + let large_chunk = 2 * self_encryption::MAX_CHUNK_SIZE; + let mut file = PublicFileDescriptor { + name: "large-chunks.bin".into(), + address: "11".repeat(32), + size: (MIN_FILE_CHUNKS * large_chunk) as u64, + content_type: "application/octet-stream".into(), + blake3: "22".repeat(32), + data_map_size: 256, + replicas: 4, + chunks: (0..MIN_FILE_CHUNKS) + .map(|index| chunk(index, large_chunk)) + .collect(), + }; + normalize_file(&mut file).unwrap(); + + // Rejected on length alone, before the list is sorted or decoded. + file.chunks = (0..=MAX_FILE_CHUNKS) + .map(|index| BrowserChunkInfo { + index, + src_size: 1, + src_hash: String::new(), + dst_hash: String::new(), + }) + .collect(); + file.size = file.chunks.len() as u64; + let error = normalize_file(&mut file).unwrap_err(); + assert!(error.to_string().contains("chunk list")); + } } diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index e273f8a2..f52ecfb3 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2124,10 +2124,10 @@ enum BrowserFileSource { } struct ResolvedBrowserFile { + /// Descriptor without a chunk list; `index` holds the chunk metadata. file: PublicFileDescriptor, - expected_hash: Option, data_map_node: Option, - root_data_map: self_encryption::DataMap, + index: crate::client_engine::files::FileIndex, } struct UploadRecord { @@ -2403,48 +2403,38 @@ impl BrowserNetworkClient { serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) } - /// Download into one JavaScript Uint8Array, using bounded WASM buffers. - /// Optional max_memory_bytes limits the output allocation. For disk-backed - /// downloads use openPublicFile followed by reader.pipeTo(writable). + /// Download a public file into one JavaScript Uint8Array, using bounded + /// WASM buffers. `options` is `{ concurrency?, maxMemoryBytes?, onProgress? }`; + /// `maxMemoryBytes` limits the output allocation. A number is the legacy + /// concurrency argument, paired with a positional `onProgress`. For + /// disk-backed downloads use openPublicFile and reader.pipeTo(writable). #[wasm_bindgen(js_name = downloadPublicFile)] pub async fn download_public_file( &self, file: JsValue, - concurrency: Option, + options: JsValue, on_progress: Option, - max_memory_bytes: Option, ) -> Result { let file: BrowserPublicFileInput = serde_wasm_bindgen::from_value(file) .map_err(|error| JsValue::from_str(&error.to_string()))?; - self.download_file( - BrowserFileSource::Public(file), - concurrency, - on_progress, - max_memory_bytes, - ) - .await + self.download_file(BrowserFileSource::Public(file), options, on_progress) + .await } - /// Download a private file into one JavaScript Uint8Array. The optional - /// max_memory_bytes limits output allocation; openPrivateFile and pipeTo - /// support files that cannot fit in one JavaScript buffer. + /// Download a private file into one JavaScript Uint8Array. Takes the same + /// options as downloadPublicFile; openPrivateFile and pipeTo support files + /// that cannot fit in one JavaScript buffer. #[wasm_bindgen(js_name = downloadPrivateFile)] pub async fn download_private_file( &self, file: JsValue, - concurrency: Option, + options: JsValue, on_progress: Option, - max_memory_bytes: Option, ) -> Result { let file: BrowserPrivateFileInput = serde_wasm_bindgen::from_value(file) .map_err(|error| JsValue::from_str(&error.to_string()))?; - self.download_file( - BrowserFileSource::Private(file), - concurrency, - on_progress, - max_memory_bytes, - ) - .await + self.download_file(BrowserFileSource::Private(file), options, on_progress) + .await } /// Resolve and validate a public file for random-access range reads. @@ -2663,18 +2653,13 @@ impl BrowserNetworkClient { async fn download_file( &self, source: BrowserFileSource, - concurrency: Option, + options: JsValue, on_progress: Option, - max_memory_bytes: Option, ) -> Result { - let progress = ProgressReporter::from_js(on_progress); + let settings = download::DownloadSettings::from_js(options, on_progress) + .map_err(|error| JsValue::from_str(&error))?; let result = self - .download_file_inner( - source, - concurrency.unwrap_or(usize::MAX), - &progress, - max_memory_bytes, - ) + .download_file_inner(source, settings) .await .map_err(|error| JsValue::from_str(&error))?; serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) @@ -2691,44 +2676,27 @@ impl BrowserNetworkClient { "Ready to stream {} ({} bytes, {} chunks)", resolved.file.name, resolved.file.size, - resolved.file.chunks.len() + resolved.index.chunk_count() )); - BrowserFileReader::new( + Ok(BrowserFileReader::new( Rc::clone(&self.shared), resolved.file, - &resolved.root_data_map, - ) + resolved.index, + )) } async fn download_file_inner( &self, source: BrowserFileSource, - concurrency: usize, - progress: &ProgressReporter, - max_memory_bytes: Option, + settings: download::DownloadSettings, ) -> Result { - if concurrency == 0 { - return Err("download concurrency must be a positive integer".to_string()); - } - let memory_budget = max_memory_bytes - .map(|value| download::file_position(value, "memory budget")) - .transpose()?; + let progress = &settings.progress; let resolved = self.resolve_file(source, progress).await?; - let reader = BrowserFileReader::new( - Rc::clone(&self.shared), - resolved.file, - &resolved.root_data_map, - )?; - let (content, hash) = reader.collect(concurrency, memory_budget, progress).await?; - if resolved - .expected_hash - .as_ref() - .is_some_and(|expected| expected != &hash) - { - return Err("complete file BLAKE3 mismatch".into()); - } - let mut file = reader.into_file(); - file.blake3 = hash.clone(); + let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved.file, resolved.index); + let (content, hash) = reader + .collect(settings.concurrency, settings.memory_budget, progress) + .await?; + let file = reader.into_descriptor(hash.clone()); progress.report(&format!("Verified complete {} as {hash}", file.name)); Ok(BrowserDownloadResult { content, @@ -2836,26 +2804,20 @@ impl BrowserNetworkClient { .await .map_err(|error| error.to_string())?; - let mut actual_chunks = super::chunk_infos(&root_data_map); - actual_chunks.sort_by_key(|chunk| chunk.index); - if actual_chunks.len() < 3 { - return Err("ant-core returned an invalid public DataMap".to_string()); - } - let resolved_size = crate::client_engine::files::FileIndex::new(&root_data_map)?.size(); - if resolved_size > super::manifest::MAX_BROWSER_FILE_POSITION { + let index = crate::client_engine::files::FileIndex::new(&root_data_map)?; + if index.size() > super::manifest::MAX_BROWSER_FILE_POSITION { return Err("file size exceeds JavaScript's exact integer range".into()); } - file.size = resolved_size; - file.chunks = actual_chunks; + file.size = index.size(); + file.chunks = Vec::new(); // Listed from the index once the file is read. file.data_map_size = encoded_data_map.len(); file.blake3.clear(); // Computed when plaintext is read; not a second content identity. file.content_type = normalized_content_type(&file.content_type); Ok(ResolvedBrowserFile { file, - expected_hash: None, data_map_node: None, - root_data_map, + index, }) } diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index 39ee05da..bed314a7 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -1,8 +1,22 @@ //! Bounded plaintext reads with 64-bit positions and caller-owned destinations. use super::*; -use crate::client_engine::files::FileIndex; +use crate::client_engine::files::{FileIndex, OrderedHasher}; +use bytes::Bytes; +use std::ops::Range; -pub(super) fn file_position(value: f64, label: &str) -> Result { +/// Plaintext one `pipeTo` window may hold. A window's whole chunks are +/// fetched concurrently in one deferred retry pass, then written in order; +/// nothing more is fetched while those writes are pending. +const PIPE_WINDOW_BYTES: u64 = 32 * 1024 * 1024; +const STREAM_HINT: &str = + "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; +const CLOSED_READER: &str = "browser file reader is closed"; +const ABORTED: &str = "download aborted"; +const ABORT_EVENT: &str = "abort"; +const PIPE_OPTION_NAMES: [&str; 4] = ["start", "end", "signal", "onProgress"]; +const DOWNLOAD_OPTION_NAMES: [&str; 3] = ["concurrency", "maxMemoryBytes", "onProgress"]; + +fn file_position(value: f64, label: &str) -> Result { if !value.is_finite() || value < 0.0 || value.fract() != 0.0 @@ -19,6 +33,41 @@ fn method(object: &JsValue, name: &str) -> Result { .map_err(|_| JsValue::from_str(&format!("destination has no {name} method"))) } +/// An optional callback option: absent, or a function. +fn optional_function(value: JsValue, label: &str) -> Result, String> { + if value.is_undefined() || value.is_null() { + return Ok(None); + } + value + .dyn_into::() + .map(Some) + .map_err(|_| format!("{label} must be a function")) +} + +/// Deserialize an optional options object; `undefined` and `null` select +/// defaults. serde_wasm_bindgen reads only declared fields, so a misspelt +/// option is rejected here instead of being silently ignored. +fn options_from_js( + value: JsValue, + known: &[&str], +) -> Result { + if value.is_undefined() || value.is_null() { + return Ok(T::default()); + } + if let Some(object) = value.dyn_ref::() { + for key in js_sys::Object::keys(object) { + let key = key.as_string().unwrap_or_default(); + if !known.contains(&key.as_str()) { + return Err(format!( + "unknown option `{key}`, expected one of {}", + known.join(", ") + )); + } + } + } + serde_wasm_bindgen::from_value(value).map_err(|error| error.to_string()) +} + /// Release the writer even if the Rust future is dropped before it completes. struct Writer(JsValue); impl Drop for Writer { @@ -29,28 +78,220 @@ impl Drop for Writer { } } +/// `pipeTo` options, modelled on `ReadableStream.pipeTo`. #[derive(Default, Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct PipeOptions { + /// First byte to write. start: Option, + /// Exclusive end of the written range; the file size by default. end: Option, + /// AbortSignal that cancels this operation without closing the reader. + #[serde(default, with = "serde_wasm_bindgen::preserve")] + signal: JsValue, + /// Called with `(bytesWritten, totalBytes)` after each write. + #[serde(default, with = "serde_wasm_bindgen::preserve")] + on_progress: JsValue, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] struct PipeResult { - #[serde(rename = "bytesWritten")] bytes_written: u64, hash: String, } +/// Complete-download options: `{ concurrency?, maxMemoryBytes?, onProgress? }`. +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DownloadOptions { + /// Upper bound on concurrent record fetches. + concurrency: Option, + /// Largest output buffer to allocate; larger files fail before transfer. + max_memory_bytes: Option, + /// Called with progress messages. + #[serde(default, with = "serde_wasm_bindgen::preserve")] + on_progress: JsValue, +} + +/// Validated complete-download options. +pub(super) struct DownloadSettings { + pub(super) concurrency: usize, + pub(super) memory_budget: Option, + pub(super) progress: ProgressReporter, +} + +impl DownloadSettings { + /// `options` is an options object. A number is the legacy concurrency + /// argument, which pairs with the positional `legacy_progress` callback. + pub(super) fn from_js( + options: JsValue, + legacy_progress: Option, + ) -> Result { + let (options, progress) = match options.as_f64() { + Some(concurrency) => ( + DownloadOptions { + concurrency: Some(concurrency), + ..DownloadOptions::default() + }, + legacy_progress, + ), + None => { + let mut options: DownloadOptions = + options_from_js(options, &DOWNLOAD_OPTION_NAMES)?; + let progress = + optional_function(std::mem::take(&mut options.on_progress), "onProgress")?; + if progress.is_some() && legacy_progress.is_some() { + return Err("pass onProgress in the options or as an argument, not both".into()); + } + (options, progress.or(legacy_progress)) + } + }; + let concurrency = match options.concurrency { + None => usize::MAX, + Some(value) => match file_position(value, "download concurrency") { + Ok(0) | Err(_) => { + return Err("download concurrency must be a positive integer".into()) + } + Ok(value) => usize::try_from(value).unwrap_or(usize::MAX), + }, + }; + Ok(Self { + concurrency, + memory_budget: options + .max_memory_bytes + .map(|value| file_position(value, "memory budget")) + .transpose()?, + progress: ProgressReporter::from_js(progress), + }) + } +} + +/// An AbortSignal's `abort` event. Detaches its listener when dropped. +struct AbortListener { + signal: JsValue, + callback: Closure, + fired: oneshot::Receiver<()>, +} + +impl AbortListener { + fn new(signal: JsValue) -> Result { + let (sender, fired) = oneshot::channel(); + let mut sender = Some(sender); + let callback = Closure::::new(move || { + if let Some(sender) = sender.take() { + let _ = sender.send(()); + } + }); + method(&signal, "addEventListener") + .map_err(|_| JsValue::from_str("signal must be an AbortSignal"))? + .call2( + &signal, + &JsValue::from_str(ABORT_EVENT), + callback.as_ref().unchecked_ref(), + )?; + Ok(Self { + signal, + callback, + fired, + }) + } + + /// The signal's abort reason, once it has aborted. + fn reason(&self) -> Option { + let aborted = js_sys::Reflect::get(&self.signal, &JsValue::from_str("aborted")) + .is_ok_and(|aborted| aborted.is_truthy()); + aborted.then(|| { + js_sys::Reflect::get(&self.signal, &JsValue::from_str("reason")) + .ok() + .filter(|reason| !reason.is_undefined()) + .unwrap_or_else(|| JsValue::from_str(ABORTED)) + }) + } +} + +impl Drop for AbortListener { + fn drop(&mut self) { + if let Ok(remove) = method(&self.signal, "removeEventListener") { + let _ = remove.call2( + &self.signal, + &JsValue::from_str(ABORT_EVENT), + self.callback.as_ref().unchecked_ref(), + ); + } + } +} + +/// Stops one reader operation when its reader closes or its AbortSignal fires. +struct Cancellation { + closed: watch::Receiver, + abort: Option, +} + +impl Cancellation { + fn new(closed: watch::Receiver, signal: JsValue) -> Result { + let abort = if signal.is_undefined() || signal.is_null() { + None + } else { + Some(AbortListener::new(signal)?) + }; + Ok(Self { closed, abort }) + } + + /// Fail with the abort reason, or the closed-reader error. + fn check(&self) -> Result<(), JsValue> { + if let Some(reason) = self.abort.as_ref().and_then(AbortListener::reason) { + return Err(reason); + } + if *self.closed.borrow() { + return Err(JsValue::from_str(CLOSED_READER)); + } + Ok(()) + } + + /// Run `operation` unless it is cancelled first. Cancelling drops it, + /// which stops its in-flight fetches and retry waits at once. + async fn run( + &mut self, + operation: impl Future>, + ) -> Result { + self.check()?; + let finished = { + let Self { closed, abort } = &mut *self; + let closed = closed.wait_for(|closed| *closed); + let aborted = async move { + match abort { + Some(abort) => { + let _ = (&mut abort.fired).await; + } + None => futures_util::future::pending::<()>().await, + } + }; + futures_util::pin_mut!(operation, closed, aborted); + match select(operation, select(closed, aborted)).await { + Either::Left((result, _)) => Some(result), + Either::Right(_) => None, + } + }; + match finished { + Some(result) => result.map_err(|error| JsValue::from_str(&error)), + None => Err(self + .check() + .err() + .unwrap_or_else(|| JsValue::from_str(CLOSED_READER))), + } + } +} + /// Random-access file reader. Content memory is bounded independently of file /// size. The resolved chunk map and seek index remain resident in memory. #[wasm_bindgen(js_name = BrowserFileReader)] pub struct BrowserFileReader { shared: Rc, + /// Descriptor without its chunk list; the index holds the chunk metadata. file: PublicFileDescriptor, index: FileIndex, - closed: Cell, + closed: watch::Sender, } #[wasm_bindgen(js_class = BrowserFileReader)] @@ -73,89 +314,50 @@ impl BrowserFileReader { /// Read up to 4 MiB. Positions above 4 GiB are supported without truncation. /// Fractions, negatives, and integers not exactly representable in JS fail. + /// Closing the reader rejects a read in progress. #[wasm_bindgen(js_name = readRange)] pub async fn read_range(&self, start: f64, length: f64) -> Result { - let result = async { - let start = file_position(start, "range start")?; - let length = file_position(length, "range length")?; - if length > MAX_BROWSER_RANGE_BYTES as u64 { - return Err(format!( - "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" - )); - } - self.read(start, length as usize, usize::MAX).await + let start = + file_position(start, "range start").map_err(|error| JsValue::from_str(&error))?; + let length = + file_position(length, "range length").map_err(|error| JsValue::from_str(&error))?; + if length > MAX_BROWSER_RANGE_BYTES as u64 { + return Err(JsValue::from_str(&format!( + "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" + ))); } - .await - .map_err(|error| JsValue::from_str(&error))?; - Ok(Uint8Array::from(result.as_ref())) + let mut cancel = Cancellation::new(self.closed.subscribe(), JsValue::UNDEFINED)?; + let bytes = cancel + .run(async { + self.shared + .data_download_indexed_range(&self.index, start, length as usize) + .await + .map_err(|error| error.to_string()) + }) + .await?; + Ok(Uint8Array::from(bytes.as_ref())) } /// Write the file to a WritableStream (including a file-system writable). - /// Awaits every write before fetching the next range. Closes on success, - /// aborts on failure, and always releases the writer lock. Optional `start` - /// and exclusive `end` select a range. Returns its BLAKE3 and byte count. - /// Calling close() cancels at the next read/write boundary. + /// `options` is `{ start?, end?, signal?, onProgress? }`: an optional + /// half-open byte range, an AbortSignal that cancels this operation, and + /// a `(bytesWritten, totalBytes)` callback run after each write. + /// + /// Whole chunks are fetched a window at a time, and every write is awaited + /// before the next window is fetched. Writes are at most 4 MiB. Closes the + /// destination on success, aborts it on any failure (including invalid + /// options), and always releases the writer lock. Returns the BLAKE3 and + /// byte count of the written range. Closing the reader also cancels. #[wasm_bindgen(js_name = pipeTo)] pub async fn pipe_to( &self, writable: JsValue, options: Option, - on_progress: Option, ) -> Result { - let options: PipeOptions = options - .filter(|value| !value.is_null()) - .map(serde_wasm_bindgen::from_value) - .transpose() - .map_err(|error| JsValue::from_str(&error.to_string()))? - .unwrap_or_default(); - let start = file_position(options.start.unwrap_or(0.0), "stream start") - .map_err(|error| JsValue::from_str(&error))?; - let end = file_position(options.end.unwrap_or(self.size()), "stream end") - .map_err(|error| JsValue::from_str(&error))?; - if start > end || end > self.file.size { - return Err(JsValue::from_str("stream range is outside the file")); - } - self.ensure_open() - .map_err(|error| JsValue::from_str(&error))?; let writer = Writer(method(&writable, "getWriter")?.call0(&writable)?); - let result = async { - let write = method(&writer.0, "write")?; - let mut position = start; - let mut hasher = blake3::Hasher::new(); - while position < end { - let length = (end - position).min(MAX_BROWSER_RANGE_BYTES as u64) as usize; - let bytes = self - .read(position, length, usize::MAX) - .await - .map_err(|error| JsValue::from_str(&error))?; - let value = Uint8Array::from(bytes.as_ref()); - hasher.update(&bytes); - drop(bytes); - JsFuture::from(Promise::resolve(&write.call1(&writer.0, &value)?)).await?; - self.ensure_open() - .map_err(|error| JsValue::from_str(&error))?; - position += length as u64; - if let Some(callback) = &on_progress { - callback.call2( - &JsValue::NULL, - &JsValue::from_f64((position - start) as f64), - &JsValue::from_f64((end - start) as f64), - )?; - } - } - self.ensure_open() - .map_err(|error| JsValue::from_str(&error))?; - JsFuture::from(Promise::resolve( - &method(&writer.0, "close")?.call0(&writer.0)?, - )) - .await?; - serde_wasm_bindgen::to_value(&PipeResult { - bytes_written: end - start, - hash: hasher.finalize().to_hex().to_string(), - }) - .map_err(|error| JsValue::from_str(&error.to_string())) - } - .await; + let result = self + .pipe_to_writer(&writer.0, options.unwrap_or(JsValue::UNDEFINED)) + .await; if let Err(error) = &result { if let Ok(abort) = method(&writer.0, "abort") { if let Ok(promise) = abort.call1(&writer.0, error) { @@ -166,67 +368,158 @@ impl BrowserFileReader { result } - /// Cancel this reader and release the shared encrypted-record cache. + /// Close the reader: cancel its operations and drop its records from the + /// client's shared cache, leaving other readers' records in place. Use an + /// AbortSignal to cancel a single pipeTo without closing the reader. pub fn close(&self) { - self.closed.set(true); - self.shared.chunk_cache().clear(); + self.closed.send_replace(true); + let cache = self.shared.chunk_cache(); + for address in self.index.record_addresses() { + cache.remove(&address.0); + } } } impl BrowserFileReader { pub(super) fn new( shared: Rc, - file: PublicFileDescriptor, - map: &self_encryption::DataMap, - ) -> Result { - Ok(Self { + mut file: PublicFileDescriptor, + index: FileIndex, + ) -> Self { + file.chunks = Vec::new(); + Self { shared, file, - index: FileIndex::new(map)?, - closed: Cell::new(false), - }) + index, + closed: watch::Sender::new(false), + } } - pub(super) fn into_file(self) -> PublicFileDescriptor { - self.file + /// The descriptor of a completely read file, with its chunk list and hash. + pub(super) fn into_descriptor(self, hash: String) -> PublicFileDescriptor { + let mut file = self.file; + file.chunks = self + .index + .chunk_infos() + .map(|info| super::super::browser_chunk_info(&info)) + .collect(); + file.blake3 = hash; + file } - fn ensure_open(&self) -> Result<(), String> { - if self.closed.get() { - Err("browser file reader is closed".into()) - } else { - Ok(()) + async fn pipe_to_writer(&self, writer: &JsValue, options: JsValue) -> Result { + let options: PipeOptions = options_from_js(options, &PIPE_OPTION_NAMES) + .map_err(|error| JsValue::from_str(&error))?; + let progress = optional_function(options.on_progress, "onProgress") + .map_err(|error| JsValue::from_str(&error))?; + let mut cancel = Cancellation::new(self.closed.subscribe(), options.signal)?; + cancel.check()?; + let range = self + .stream_range(options.start, options.end) + .map_err(|error| JsValue::from_str(&error))?; + let write = method(writer, "write")?; + let total = range.end - range.start; + let mut written = 0u64; + let mut hasher = blake3::Hasher::new(); + let chunks = self.index.chunks_overlapping(range.clone()); + let mut next = chunks.start; + while next < chunks.end { + let window = next..self.index.window_end(next, chunks.end, PIPE_WINDOW_BYTES); + for plaintext in self + .fetch_window(window.clone(), &range, &mut cancel) + .await? + { + for piece in plaintext.chunks(MAX_BROWSER_RANGE_BYTES) { + // Checked before each write, never after the last one: + // a finished destination is closed, not aborted. + cancel.check()?; + hasher.update(piece); + JsFuture::from(Promise::resolve( + &write.call1(writer, &Uint8Array::from(piece))?, + )) + .await?; + written += piece.len() as u64; + if let Some(callback) = &progress { + callback.call2( + &JsValue::NULL, + &JsValue::from_f64(written as f64), + &JsValue::from_f64(total as f64), + )?; + } + } + } + next = window.end; } + JsFuture::from(Promise::resolve(&method(writer, "close")?.call0(writer)?)).await?; + serde_wasm_bindgen::to_value(&PipeResult { + bytes_written: written, + hash: hasher.finalize().to_hex().to_string(), + }) + .map_err(|error| JsValue::from_str(&error.to_string())) } - async fn read( + /// Validate a `pipeTo` range against the file. + fn stream_range(&self, start: Option, end: Option) -> Result, String> { + let start = file_position(start.unwrap_or(0.0), "stream start")?; + let end = end + .map(|end| file_position(end, "stream end")) + .transpose()? + .unwrap_or(self.file.size); + if start > end || end > self.file.size { + return Err("stream range is outside the file".into()); + } + Ok(start..end) + } + + /// Fetch one window of whole chunks, returning each chunk's plaintext + /// trimmed to the `pipeTo` range, in file order. + async fn fetch_window( &self, - start: u64, - length: usize, - concurrency: usize, - ) -> Result { - self.ensure_open()?; - let bytes = self - .shared - .data_download_indexed_range(&self.index, start, length, concurrency) - .await - .map_err(|error| error.to_string())?; - self.ensure_open()?; - Ok(bytes) + window: Range, + range: &Range, + cancel: &mut Cancellation, + ) -> Result, JsValue> { + let mut plaintexts = vec![None; window.len()]; + cancel + .run(async { + self.shared + .data_download_indexed_chunks( + &self.index, + window.clone(), + usize::MAX, + |chunk, plaintext| { + plaintexts[chunk - window.start] = Some(plaintext); + Ok(()) + }, + ) + .await + .map_err(|error| error.to_string()) + }) + .await?; + window + .zip(plaintexts) + .map(|(chunk, plaintext)| { + let plaintext = + plaintext.ok_or_else(|| JsValue::from_str("window chunk missing"))?; + let span = self.index.chunk_span(chunk); + let from = range.start.max(span.start) - span.start; + let to = range.end.min(span.end) - span.start; + Ok(plaintext.slice(from as usize..to as usize)) + }) + .collect() } - /// Allocate output in JS, then copy bounded plaintext ranges into it. Never - /// retain the whole ciphertext/plaintext in WASM or serialize a file-sized Vec. - /// A caller can impose its own memory budget; allocation failures recommend - /// the disk-backed path without downloading the file first. + /// Allocate output in JS, then copy each decrypted chunk into it as it + /// arrives. The whole file is fetched in one deferred retry pass at the + /// requested concurrency; WASM holds only the records in flight. A caller + /// can impose its own memory budget; allocation failures recommend the + /// disk-backed path without downloading the file first. pub(super) async fn collect( &self, concurrency: usize, budget: Option, progress: &ProgressReporter, ) -> Result<(Uint8Array, String), String> { - const STREAM_HINT: &str = - "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; if budget.is_some_and(|limit| self.file.size > limit) { return Err(format!( "file exceeds the download memory budget; {STREAM_HINT}" @@ -249,21 +542,37 @@ impl BrowserFileReader { // Reflect calls preserve Number offsets above u32::MAX and catch JS // allocation exceptions instead of turning them into WASM traps. let set = method(output.as_ref(), "set").map_err(js_error_message)?; - let mut position = 0u64; - let mut hasher = blake3::Hasher::new(); - while position < self.file.size { - let length = (self.file.size - position).min(MAX_BROWSER_RANGE_BYTES as u64) as usize; - let bytes = self.read(position, length, concurrency).await?; - hasher.update(&bytes); - set.call2( - output.as_ref(), - &Uint8Array::from(bytes.as_ref()), - &JsValue::from_f64(position as f64), - ) - .map_err(js_error_message)?; - position += length as u64; - progress.report(&format!("Downloaded {position}/{} bytes", self.file.size)); - } - Ok((output, hasher.finalize().to_hex().to_string())) + let subarray = method(output.as_ref(), "subarray").map_err(js_error_message)?; + let read_back = |chunk: usize| -> Result, String> { + let span = self.index.chunk_span(chunk); + subarray + .call2( + output.as_ref(), + &JsValue::from_f64(span.start as f64), + &JsValue::from_f64(span.end as f64), + ) + .map(|view| view.unchecked_into::().to_vec()) + .map_err(js_error_message) + }; + let total = self.index.chunk_count(); + let mut hasher = OrderedHasher::new(total); + let mut completed = 0usize; + progress.report(&format!("Downloaded chunk {completed}/{total}")); + self.shared + .data_download_indexed_chunks(&self.index, 0..total, concurrency, |chunk, plaintext| { + set.call2( + output.as_ref(), + &Uint8Array::from(plaintext.as_ref()), + &JsValue::from_f64(self.index.chunk_span(chunk).start as f64), + ) + .map_err(js_error_message)?; + hasher.complete(chunk, &plaintext, &read_back)?; + completed += 1; + progress.report(&format!("Downloaded chunk {completed}/{total}")); + Ok(()) + }) + .await + .map_err(|error| error.to_string())?; + Ok((output, hasher.finish()?)) } } diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 5beadab9..540d4e62 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -3,8 +3,16 @@ //! in self_encryption, the same implementation used by the native client. use bytes::Bytes; use futures_util::StreamExt; -use self_encryption::{DataMap, EncryptedChunk, XorName}; -use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range}; +use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; +use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range, time::Duration}; + +/// self_encryption splits every encryptable file into at least three chunks. +pub(crate) const MIN_FILE_CHUNKS: usize = 3; +/// Content chunks of a resolved root DataMap are encrypted at KDF level zero. +const CONTENT_CHUNK_KDF_LEVEL: usize = 0; +/// Seconds to wait before each deferred fetch round: the first pass, one +/// immediate retry, then retries after 15 and 45 seconds. +const DEFERRED_ROUND_DELAYS_SECS: [u64; 4] = [0, 0, 15, 45]; pub(crate) type RecordRequest = (usize, [u8; 32]); @@ -124,7 +132,7 @@ where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(std::time::Duration) -> SF, + S: Fn(Duration) -> SF, SF: Future, { let root = resolve(map, fetch, cap).await?; @@ -134,8 +142,14 @@ where .enumerate() .map(|(index, info)| (index, info.dst_hash.0)) .collect(); - let chunks = fetch_records_deferred(requests, fetch, cap, sleep, retryable) - .await? + let mut records = Vec::new(); + fetch_records_deferred(requests, fetch, cap, sleep, retryable, |index, content| { + records.push((index, content)); + Ok(()) + }) + .await?; + records.sort_by_key(|(index, _)| *index); + let chunks = records .into_iter() .map(|(_, content)| EncryptedChunk { content }) .collect::>(); @@ -204,20 +218,28 @@ impl RecordLayout { /// pointer width; only individual chunk buffers and indices use `usize`. /// Build once per reader so each seek is O(log chunks + overlapping chunks). pub(crate) struct FileIndex { - infos: Vec, - hashes: Vec, + /// Plaintext hashes in chunk order. Each chunk's key derives from its + /// neighbours, so decryption needs the whole list. + src_hashes: Vec, + /// Encrypted record addresses in chunk order. + dst_hashes: Vec, + /// Plaintext start of each chunk, followed by the file size. offsets: Vec, } impl FileIndex { + /// Chunk sizes are taken from the map, so maps produced with another + /// self-encryption chunk size remain readable. pub(crate) fn new(map: &DataMap) -> Result { if map.is_child() { return Err("range reads require a resolved root DataMap".into()); } - let mut infos = map.infos().to_vec(); + let mut infos: Vec<&ChunkInfo> = map.infos().iter().collect(); infos.sort_by_key(|info| info.index); - if infos.len() < 3 { - return Err("DataMap requires at least three chunks".into()); + if infos.len() < MIN_FILE_CHUNKS { + return Err(format!( + "DataMap requires at least {MIN_FILE_CHUNKS} chunks" + )); } let mut offsets = Vec::with_capacity(infos.len() + 1); offsets.push(0u64); @@ -225,7 +247,7 @@ impl FileIndex { if info.index != index { return Err("DataMap chunk indices must be contiguous".into()); } - if info.src_size == 0 || info.src_size > self_encryption::MAX_CHUNK_SIZE { + if info.src_size == 0 { return Err("invalid DataMap plaintext chunk size".into()); } let end = offsets[index] @@ -233,10 +255,9 @@ impl FileIndex { .ok_or("DataMap plaintext size overflow")?; offsets.push(end); } - let hashes = infos.iter().map(|info| info.src_hash).collect(); Ok(Self { - infos, - hashes, + src_hashes: infos.iter().map(|info| info.src_hash).collect(), + dst_hashes: infos.iter().map(|info| info.dst_hash).collect(), offsets, }) } @@ -245,28 +266,174 @@ impl FileIndex { *self.offsets.last().expect("index includes zero offset") } - fn range_records(&self, start: u64, length: usize) -> (usize, Vec) { - let end = start.saturating_add(length as u64).min(self.size()); - if start >= end { - return (0, Vec::new()); + /// Half-open plaintext byte span of one chunk. + pub(crate) fn chunk_span(&self, chunk: usize) -> Range { + self.offsets[chunk]..self.offsets[chunk + 1] + } + + /// Chunks overlapping a half-open byte range, clamped at EOF. + pub(crate) fn chunks_overlapping(&self, range: Range) -> Range { + let end = range.end.min(self.size()); + if range.start >= end { + return 0..0; } - let first = self.offsets.partition_point(|offset| *offset <= start) - 1; + let first = self + .offsets + .partition_point(|offset| *offset <= range.start) + - 1; let last = self.offsets.partition_point(|offset| *offset < end); - let records = self.infos[first..last] - .iter() - .map(|info| (info.index, info.dst_hash.0)) - .collect(); - ((end - start) as usize, records) + first..last + } + + /// Decrypt one verified record and check it against the map's plaintext + /// size and hash. + fn decrypt(&self, chunk: usize, encrypted: &Bytes) -> Result { + // The dependency's get_range uses usize file offsets. Its public + // per-chunk primitive has no whole-file arithmetic and retains the + // canonical KDF (original chunk index and neighbouring hashes). + let plaintext = self_encryption::decrypt_chunk( + chunk, + encrypted, + &self.src_hashes, + CONTENT_CHUNK_KDF_LEVEL, + ) + .map_err(|e| e.to_string())?; + let span = self.chunk_span(chunk); + if plaintext.len() as u64 != span.end - span.start { + return Err("decrypted chunk size differs from DataMap".into()); + } + crate::record::verify(&self.src_hashes[chunk].0, &plaintext)?; + Ok(plaintext) } } -#[cfg(test)] -fn range_records( - map: &DataMap, - start: usize, - length: usize, -) -> Result<(usize, Vec), String> { - Ok(FileIndex::new(map)?.range_records(start as u64, length)) +/// Whole-file and windowed browser reads. +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +impl FileIndex { + pub(crate) fn chunk_count(&self) -> usize { + self.dst_hashes.len() + } + + /// End of a chunk window that starts at `first`, holds at most `max_bytes` + /// of plaintext and stops at `limit`. A window always holds one chunk, + /// however large it is. + pub(crate) fn window_end(&self, first: usize, limit: usize, max_bytes: u64) -> usize { + let budget_end = self.offsets[first].saturating_add(max_bytes); + let fitting = self.offsets.partition_point(|offset| *offset <= budget_end) - 1; + fitting.clamp(first + 1, limit) + } + + /// Encrypted record addresses in chunk order. + pub(crate) fn record_addresses(&self) -> &[XorName] { + &self.dst_hashes + } + + /// The self-encryption chunk metadata in chunk order. + pub(crate) fn chunk_infos(&self) -> impl Iterator + '_ { + (0..self.chunk_count()).map(|index| { + let span = self.chunk_span(index); + ChunkInfo { + index, + dst_hash: self.dst_hashes[index], + src_hash: self.src_hashes[index], + // Each size was a usize in the DataMap this index came from. + src_size: (span.end - span.start) as usize, + } + }) + } +} + +/// Fetch, verify and decrypt `chunks` in one deferred retry pass. Each +/// plaintext chunk goes to `sink` as soon as it is decrypted, in completion +/// order, so only records still in flight are held. A missing record is +/// retried alongside the rest instead of stalling chunks behind it. +pub(crate) async fn for_each_chunk( + index: &FileIndex, + chunks: Range, + fetch: &F, + cap: &C, + sleep: &S, + retryable: fn(&E) -> bool, + mut sink: impl FnMut(usize, Bytes) -> Result<(), String>, +) -> Result<(), ReadError> +where + F: Fn([u8; 32]) -> Fut, + Fut: Future>, + C: Fn() -> usize, + S: Fn(Duration) -> SF, + SF: Future, +{ + let requests = chunks + .map(|chunk| (chunk, index.dst_hashes[chunk].0)) + .collect(); + fetch_records_deferred( + requests, + fetch, + cap, + sleep, + retryable, + |chunk, encrypted| { + let plaintext = index + .decrypt(chunk, &encrypted) + .map_err(ReadError::Invalid)?; + sink(chunk, plaintext).map_err(ReadError::Invalid) + }, + ) + .await +} + +/// Whole-file BLAKE3 over chunks that complete out of order. Chunks are +/// hashed in file order; one that completes ahead of that frontier is read +/// back from the caller's output when the frontier reaches it, so no +/// plaintext is retained here. +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +pub(crate) struct OrderedHasher { + hasher: blake3::Hasher, + next: usize, + completed: Vec, +} + +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +impl OrderedHasher { + pub(crate) fn new(chunks: usize) -> Self { + Self { + hasher: blake3::Hasher::new(), + next: 0, + completed: vec![false; chunks], + } + } + + /// Record `chunk` as complete and hash every chunk the frontier can now + /// pass. `read_back` returns the plaintext of an earlier-completed chunk. + pub(crate) fn complete( + &mut self, + chunk: usize, + plaintext: &[u8], + mut read_back: impl FnMut(usize) -> Result, String>, + ) -> Result<(), String> { + self.completed[chunk] = true; + while self.completed.get(self.next) == Some(&true) { + if self.next == chunk { + self.hasher.update(plaintext); + } else { + self.hasher.update(&read_back(self.next)?); + } + self.next += 1; + } + Ok(()) + } + + /// The hash of the whole file, once every chunk is complete. + pub(crate) fn finish(self) -> Result { + if self.next != self.completed.len() { + return Err(format!( + "hashed {} of {} chunks", + self.next, + self.completed.len() + )); + } + Ok(self.hasher.finalize().to_hex().to_string()) + } } pub(crate) async fn read_range( @@ -282,13 +449,15 @@ where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(std::time::Duration) -> SF, + S: Fn(Duration) -> SF, SF: Future, { let index = FileIndex::new(root).map_err(ReadError::Invalid)?; read_indexed_range(&index, start, length, fetch, cap, sleep, retryable).await } +/// Half-open plaintext range. EOF and zero-length reads have the same +/// semantics on every platform. pub(crate) async fn read_indexed_range( index: &FileIndex, start: u64, @@ -302,49 +471,46 @@ where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(std::time::Duration) -> SF, + S: Fn(Duration) -> SF, SF: Future, { - let (length, required) = index.range_records(start, length); - if length == 0 { + let end = start.saturating_add(length as u64).min(index.size()); + if start >= end { return Ok(Bytes::new()); } - let records = fetch_records_deferred(required, fetch, cap, sleep, retryable).await?; + // `end - start` is at most the requested usize length. + let length = (end - start) as usize; let mut output = Vec::new(); output .try_reserve_exact(length) .map_err(|e| ReadError::Invalid(format!("cannot allocate range: {e}")))?; - let end = start + length as u64; - for (position, encrypted) in records { - let info = &index.infos[position]; - // The dependency's get_range uses usize file offsets. Its public - // per-chunk primitive has no whole-file arithmetic and retains the - // canonical KDF (original chunk index and neighbouring hashes). - let plaintext = self_encryption::decrypt_chunk(position, &encrypted, &index.hashes, 0) - .map_err(|e| ReadError::Invalid(e.to_string()))?; - if plaintext.len() != info.src_size { - return Err(ReadError::Invalid( - "decrypted chunk size differs from DataMap".into(), - )); - } - crate::record::verify(&info.src_hash.0, &plaintext).map_err(ReadError::Invalid)?; - let chunk_start = index.offsets[position]; - let from = start.saturating_sub(chunk_start) as usize; - let to = (end - chunk_start).min(info.src_size as u64) as usize; - output.extend_from_slice(&plaintext[from..to]); - } - if output.len() != length { - return Err(ReadError::Invalid(format!( - "range returned {} bytes, expected {length}", - output.len() - ))); - } + output.resize(length, 0); + let chunks = index.chunks_overlapping(start..end); + for_each_chunk( + index, + chunks, + fetch, + cap, + sleep, + retryable, + |chunk, plaintext| { + let span = index.chunk_span(chunk); + let from = start.max(span.start); + let to = end.min(span.end); + output[(from - start) as usize..(to - start) as usize].copy_from_slice( + &plaintext[(from - span.start) as usize..(to - span.start) as usize], + ); + Ok(()) + }, + ) + .await?; Ok(Bytes::from(output)) } /// Native file-download retry rounds: retry missing records together after the /// first batch settles, immediately once, then after 15 and 45 seconds. The /// caller supplies typed fatal errors and a runtime-specific timer. +#[cfg(any(feature = "native", test))] pub(crate) async fn deferred_batch( requests: Vec<(usize, K)>, fetch: F, @@ -357,17 +523,44 @@ where F: Fn(usize, K, usize) -> Fut, Fut: Future), E>>, C: Fn() -> usize, - S: Fn(std::time::Duration) -> SF, + S: Fn(Duration) -> SF, SF: Future, { - let mut remaining = requests; let mut results = Vec::new(); - for (round, delay) in [0, 0, 15, 45].into_iter().enumerate() { + deferred_rounds(requests, fetch, cap, sleep, exhausted, |index, bytes| { + results.push((index, bytes)); + Ok(()) + }) + .await?; + results.sort_by_key(|(index, _)| *index); + Ok(results) +} + +/// The rounds behind [`deferred_batch`], handing each record to `on_record` +/// as it arrives instead of collecting them. +async fn deferred_rounds( + requests: Vec<(usize, K)>, + fetch: F, + cap: C, + sleep: S, + exhausted: impl Fn(K) -> E, + mut on_record: impl FnMut(usize, Bytes) -> Result<(), E>, +) -> Result<(), E> +where + K: Copy, + F: Fn(usize, K, usize) -> Fut, + Fut: Future), E>>, + C: Fn() -> usize, + S: Fn(Duration) -> SF, + SF: Future, +{ + let mut remaining = requests; + for (round, delay) in DEFERRED_ROUND_DELAYS_SECS.into_iter().enumerate() { if remaining.is_empty() { break; } if delay > 0 { - sleep(std::time::Duration::from_secs(delay)).await; + sleep(Duration::from_secs(delay)).await; } let input = std::mem::take(&mut remaining); let pending = @@ -376,16 +569,15 @@ where while let Some(result) = pending.next().await { let (index, content) = result?; match content { - Ok(bytes) => results.push((index, bytes)), + Ok(bytes) => on_record(index, bytes)?, Err(key) => remaining.push((index, key)), } } } - if let Some((_, key)) = remaining.first() { - return Err(exhausted(*key)); + match remaining.first() { + Some((_, key)) => Err(exhausted(*key)), + None => Ok(()), } - results.sort_by_key(|(index, _)| *index); - Ok(results) } async fn fetch_records_deferred( @@ -394,17 +586,18 @@ async fn fetch_records_deferred( cap: &C, sleep: &S, retryable: fn(&E) -> bool, -) -> Result, ReadError> + on_record: impl FnMut(usize, Bytes) -> Result<(), ReadError>, +) -> Result<(), ReadError> where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(std::time::Duration) -> SF, + S: Fn(Duration) -> SF, SF: Future, { // Preserve the adapter's final typed fetch error across deferred rounds. let errors = std::sync::Mutex::new(HashMap::new()); - deferred_batch( + deferred_rounds( requests, |index, address, _| { let errors = &errors; @@ -436,6 +629,7 @@ where .expect("deferred record has a fetch error"), ) }, + on_record, ) .await } @@ -443,7 +637,6 @@ where #[cfg(test)] mod tests { use super::*; - use std::sync::{Arc, Mutex}; fn fixture() -> (Bytes, DataMap, HashMap<[u8; 32], Bytes>) { let content = Bytes::from((0..18000).map(|i| (i * 37) as u8).collect::>()); @@ -529,7 +722,7 @@ mod tests { (usize::MAX, 5), (0, 0), ] { - let seen = Mutex::new(Vec::new()); + let seen = std::sync::Mutex::new(Vec::new()); let fetch = |address| { seen.lock().unwrap().push(address); let bytes = records[&address].clone(); @@ -549,15 +742,18 @@ mod tests { let expected = &content[start.min(content.len())..start.saturating_add(length).min(content.len())]; assert_eq!(actual.as_ref(), expected); - let (_, required) = range_records(&map, start, length).unwrap(); + let end = (start as u64).saturating_add(length as u64); + let required = FileIndex::new(&map) + .unwrap() + .chunks_overlapping(start as u64..end); assert_eq!(seen.lock().unwrap().len(), required.len()); } } #[tokio::test] async fn deferred_rounds_retry_only_missing_records_and_preserve_order() { - let attempts = Arc::new(Mutex::new(Vec::new())); - let sleeps = Mutex::new(Vec::new()); + let attempts = std::sync::Mutex::new(Vec::new()); + let sleeps = std::sync::Mutex::new(Vec::new()); let result = deferred_batch( vec![(0, 0u8), (1, 1), (2, 2)], |index, key, attempt| { @@ -686,10 +882,15 @@ mod tests { let (_, map, _) = fixture(); let mut infos = map.infos().to_vec(); infos[1].index = 0; - assert!(range_records(&DataMap::new(infos), 0, 1).is_err()); + assert!(FileIndex::new(&DataMap::new(infos)).is_err()); + let mut infos = map.infos().to_vec(); + infos[0].src_size = 0; + assert!(FileIndex::new(&DataMap::new(infos)).is_err()); let mut infos = map.infos().to_vec(); - infos[0].src_size = usize::MAX; - assert!(range_records(&DataMap::new(infos), 0, 1).is_err()); + infos.iter_mut().for_each(|info| info.src_size = usize::MAX); + assert!(FileIndex::new(&DataMap::new(infos)).is_err()); + let infos = map.infos()[..MIN_FILE_CHUNKS - 1].to_vec(); + assert!(FileIndex::new(&DataMap::new(infos)).is_err()); } #[test] @@ -706,19 +907,189 @@ mod tests { let index = FileIndex::new(&DataMap::new(infos)).unwrap(); assert_eq!(index.size(), 1030 * size as u64); let start = (1u64 << 32) - 4; - let (length, records) = index.range_records(start, 8); - assert_eq!(length, 8); - assert_eq!(records[0].0 as u64, start / size as u64); + let chunks = index.chunks_overlapping(start..start + 8); + assert_eq!(chunks.start as u64, start / size as u64); let boundary = 1026 * size as u64; assert!(boundary > u32::MAX as u64); - let (length, records) = index.range_records(boundary - 1, 2); - assert_eq!(length, 2); assert_eq!( - records.iter().map(|(i, _)| *i).collect::>(), - vec![1025, 1026] + index.chunks_overlapping(boundary - 1..boundary + 1), + 1025..1027 ); - assert_eq!(index.range_records(index.size() - 1, 10).0, 1); - assert_eq!(index.range_records(u64::MAX, 10).0, 0); + assert_eq!(index.chunk_span(1026), boundary..boundary + size as u64); + assert_eq!( + index.chunks_overlapping(index.size() - 1..u64::MAX), + 1029..1030 + ); + assert!(index.chunks_overlapping(u64::MAX - 10..u64::MAX).is_empty()); + assert!(index.chunks_overlapping(5..5).is_empty()); + } + + #[test] + fn maps_with_larger_chunks_than_this_build_produces_are_indexed() { + let size = 2 * self_encryption::MAX_CHUNK_SIZE; + let infos: Vec<_> = (0..MIN_FILE_CHUNKS) + .map(|index| self_encryption::ChunkInfo { + index, + src_size: size, + src_hash: XorName([index as u8; 32]), + dst_hash: XorName([0xff - index as u8; 32]), + }) + .collect(); + let index = FileIndex::new(&DataMap::new(infos.clone())).unwrap(); + assert_eq!(index.size(), (MIN_FILE_CHUNKS * size) as u64); + assert_eq!(index.chunk_infos().collect::>(), infos); + assert_eq!( + index.record_addresses(), + infos.iter().map(|info| info.dst_hash).collect::>() + ); + } + + #[test] + fn windows_hold_whole_chunks_within_their_byte_budget() { + let size = self_encryption::MAX_CHUNK_SIZE; + let infos = (0..10) + .map(|index| self_encryption::ChunkInfo { + index, + src_size: size, + src_hash: XorName([1; 32]), + dst_hash: XorName([2; 32]), + }) + .collect(); + let index = FileIndex::new(&DataMap::new(infos)).unwrap(); + let chunk = size as u64; + assert_eq!(index.window_end(0, 10, 4 * chunk), 4); + assert_eq!(index.window_end(0, 10, 4 * chunk + 1), 4); + assert_eq!(index.window_end(2, 10, 4 * chunk - 1), 5); + // A window always makes progress, even past its budget. + assert_eq!(index.window_end(3, 10, 1), 4); + assert_eq!(index.window_end(8, 10, 4 * chunk), 10); + assert_eq!(index.window_end(0, 3, u64::MAX), 3); + } + + #[tokio::test] + async fn chunks_share_one_retry_pass_and_reach_the_sink_as_they_complete() { + let (content, map, records) = fixture(); + let index = FileIndex::new(&map).unwrap(); + let attempts = std::sync::Mutex::new(HashMap::<[u8; 32], usize>::new()); + let in_flight = std::cell::Cell::new(0); + let most_in_flight = std::cell::Cell::new(0); + let sleeps = std::sync::Mutex::new(Vec::new()); + // The first and last records are missing until their third attempt. + let missing = [ + index.record_addresses()[0].0, + index.record_addresses()[MIN_FILE_CHUNKS - 1].0, + ]; + let fetch = |address: [u8; 32]| { + let attempt = { + let mut attempts = attempts.lock().unwrap(); + let attempt = attempts.entry(address).or_default(); + *attempt += 1; + *attempt + }; + let bytes = records[&address].clone(); + let (in_flight, most_in_flight) = (&in_flight, &most_in_flight); + async move { + in_flight.set(in_flight.get() + 1); + most_in_flight.set(most_in_flight.get().max(in_flight.get())); + tokio::task::yield_now().await; + in_flight.set(in_flight.get() - 1); + if missing.contains(&address) && attempt < 3 { + Err("missing".to_string()) + } else { + Ok(bytes) + } + } + }; + let mut received = Vec::new(); + for_each_chunk( + &index, + 0..index.chunk_count(), + &fetch, + &|| MIN_FILE_CHUNKS, + &|delay| { + sleeps.lock().unwrap().push(delay.as_secs()); + async {} + }, + |_| true, + |chunk, plaintext| { + received.push((chunk, plaintext)); + Ok(()) + }, + ) + .await + .unwrap(); + assert_eq!(most_in_flight.get(), MIN_FILE_CHUNKS); + // Both missing records wait out the same 15-second round. + assert_eq!(*sleeps.lock().unwrap(), vec![15]); + // The available middle chunk is delivered without waiting for the others. + assert_eq!(received[0].0, 1); + let mut chunks: Vec<_> = received.iter().map(|(chunk, _)| *chunk).collect(); + chunks.sort_unstable(); + assert_eq!(chunks, (0..MIN_FILE_CHUNKS).collect::>()); + for (chunk, plaintext) in received { + let span = index.chunk_span(chunk); + assert_eq!( + plaintext, + content.slice(span.start as usize..span.end as usize) + ); + } + } + + #[test] + fn ordered_hash_matches_in_order_hash_for_any_completion_order() { + let chunks: Vec> = (0..5u8).map(|i| vec![i; 10 + i as usize]).collect(); + let expected = blake3::hash(&chunks.concat()).to_hex().to_string(); + // Only chunks that completed ahead of the frontier are read back. + for (order, expected_read_backs) in [ + ([0, 1, 2, 3, 4], vec![]), + ([4, 3, 2, 1, 0], vec![1, 2, 3, 4]), + ([2, 0, 4, 1, 3], vec![2, 4]), + ] { + let mut hasher = OrderedHasher::new(chunks.len()); + let mut read_backs = Vec::new(); + for chunk in order { + hasher + .complete(chunk, &chunks[chunk], |earlier| { + read_backs.push(earlier); + Ok(chunks[earlier].clone()) + }) + .unwrap(); + } + assert_eq!(hasher.finish().unwrap(), expected); + assert_eq!(read_backs, expected_read_backs); + } + let mut incomplete = OrderedHasher::new(chunks.len()); + incomplete + .complete(1, &chunks[1], |_| unreachable!("chunk 0 is missing")) + .unwrap(); + assert!(incomplete.finish().is_err()); + } + + #[tokio::test] + async fn sink_errors_stop_a_chunk_pass() { + let (_, map, records) = fixture(); + let index = FileIndex::new(&map).unwrap(); + let fetch = |address| { + let bytes = records[&address].clone(); + async move { Ok::<_, String>(bytes) } + }; + let mut delivered = 0; + let error = for_each_chunk( + &index, + 0..index.chunk_count(), + &fetch, + &|| 1, + &|_| async {}, + |_| true, + |_, _| { + delivered += 1; + Err("destination full".to_string()) + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Invalid(message) if message == "destination full")); + assert_eq!(delivered, 1); } #[tokio::test] diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index 5fbc9f02..695e4e90 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -344,7 +344,7 @@ impl Client { /// primitive symmetric with `data_upload`. /// /// Map resolution and network fetching are fully async and also work on a - /// current-thread runtime. The same workflow drives browser downloads. + /// current-thread runtime. /// /// # Errors /// Returns the underlying fetch error, or an encryption error for invalid @@ -359,45 +359,15 @@ impl Client { &self, data_map: &DataMap, concurrency: usize, - ) -> Result { - self.data_download_with_progress(data_map, concurrency, &|_, _| {}) - .await - } - - /// Internal observer for verified records; reconstruction still uses the shared engine. - pub(crate) async fn data_download_with_progress( - &self, - data_map: &DataMap, - concurrency: usize, - progress: &impl Fn(usize, usize), ) -> Result { if concurrency == 0 { return Err(Error::Config( "download concurrency must be positive".into(), )); } - let received = std::sync::Mutex::new(std::collections::HashSet::new()); - let total = data_map.infos().len(); - progress(0, total); crate::client_engine::files::download( data_map, - &|address| { - let received = &received; - async move { - let bytes = self.fetch_data_record(address).await?; - let mut received = received.lock().unwrap_or_else(|error| error.into_inner()); - if received.insert(address) { - let completed = data_map - .infos() - .iter() - .filter(|info| received.contains(&info.dst_hash.0)) - .count(); - drop(received); - progress(completed, total); - } - Ok(bytes) - } - }, + &|address| self.fetch_data_record(address), &|| self.controller().fetch.current().min(concurrency), &crate::runtime::sleep, retry_data_fetch, @@ -406,8 +376,8 @@ impl Client { .map_err(map_read_error) } - /// Download a plaintext byte range using the shared streaming reader. - /// Resolves child maps first and fetches only records overlapping the range. + /// Download a plaintext byte range. Resolves child maps first, then fetches + /// and decrypts only the records overlapping the range, one chunk at a time. /// Length is clamped at EOF; a start at or beyond EOF returns empty bytes. /// /// # Errors @@ -465,23 +435,45 @@ impl Client { .map_err(map_read_error) } - /// Reuse a validated index across browser seeks and sequential downloads. + /// Read a range through a validated index reused across browser seeks. #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] pub(crate) async fn data_download_indexed_range( &self, index: &crate::client_engine::files::FileIndex, start: u64, length: usize, - concurrency: usize, ) -> Result { crate::client_engine::files::read_indexed_range( index, start, length, &|address| self.fetch_data_record(address), + &|| self.controller().fetch.current(), + &crate::runtime::sleep, + retry_data_fetch, + ) + .await + .map_err(map_read_error) + } + + /// Fetch and decrypt whole chunks in one deferred retry pass, handing each + /// plaintext chunk to `sink` in completion order. + #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] + pub(crate) async fn data_download_indexed_chunks( + &self, + index: &crate::client_engine::files::FileIndex, + chunks: std::ops::Range, + concurrency: usize, + sink: impl FnMut(usize, Bytes) -> std::result::Result<(), String>, + ) -> Result<()> { + crate::client_engine::files::for_each_chunk( + index, + chunks, + &|address| self.fetch_data_record(address), &|| self.controller().fetch.current().min(concurrency), &crate::runtime::sleep, retry_data_fetch, + sink, ) .await .map_err(map_read_error) diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index 038f7e7b..b0a40a48 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -66,8 +66,10 @@ checks media ranges across chunk boundaries and EOF. `large-download.test.mjs` streams a native-encrypted 4,303,347,835-byte file through the real WASM decryptor and checks its complete BLAKE3 without retaining the plaintext. It also covers public/private seeks across 4 GiB, actual disk -writes, write backpressure, cancellation, writer errors, JS allocation failures, -memory budgets and invalid offsets. The compressed fixture is about 250 KiB; +writes, write backpressure, cancellation by `AbortSignal` and by closing the +reader (including during retry waits), destinations aborted for invalid calls, +a reader closed from the final progress callback, writer errors, JS allocation +failures, memory budgets, download options and progress, and invalid offsets. The compressed fixture is about 250 KiB; regenerate it from the repository root with `cargo run -p ant-core --release --example generate-browser-large-file`. Generation processes the entire plaintext using bounded buffers. The full-stream diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index ce5529c9..0020985b 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -75,13 +75,23 @@ test("a complete native >4 GiB file streams with bounded writes and matching BLA }); test("large-file memory budgets fail before content fetch, and allocation errors permit streaming", async () => { - const { client } = fixture(); + const { rtc, client } = fixture(); try { - await assert.rejects(client.downloadPublicFile(large.address, undefined, undefined, 64 * 1024 * 1024), /memory budget.*pipeTo/); - await assert.rejects(client.downloadPrivateFile({ data_map: map.content }, undefined, undefined, 64 * 1024 * 1024), /memory budget.*pipeTo/); + const maxMemoryBytes = 64 * 1024 * 1024; + await assert.rejects(client.downloadPublicFile(large.address, { maxMemoryBytes }), /memory budget.*pipeTo/); + await assert.rejects(client.downloadPrivateFile({ data_map: map.content }, { maxMemoryBytes }), /memory budget.*pipeTo/); + const gets = rtc.requests.filter(r => r.method === "get_chunk").length; for (const value of [-1, 1.5, Infinity, NaN, 2 ** 53]) { - await assert.rejects(client.downloadPublicFile(large.address, undefined, undefined, value), /safe integer/); + await assert.rejects(client.downloadPublicFile(large.address, { maxMemoryBytes: value }), /safe integer/); } + for (const concurrency of [0, -1, 1.5, NaN]) { + await assert.rejects(client.downloadPublicFile(large.address, { concurrency }), /positive integer/); + await assert.rejects(client.downloadPublicFile(large.address, concurrency), /positive integer/); + } + await assert.rejects(client.downloadPublicFile(large.address, { maxMemory: 1 }), /unknown option `maxMemory`/); + await assert.rejects(client.downloadPublicFile(large.address, { onProgress: 1 }), /onProgress must be a function/); + await assert.rejects(client.downloadPublicFile(large.address, { onProgress() {} }, () => {}), /not both/); + assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, gets, "invalid options fail before any fetch"); } finally { client.close(); } }); @@ -120,6 +130,97 @@ test("pipeTo writes ranges beyond 4 GiB to disk and honors write backpressure", } finally { reader?.close(); client.close(); await rm(directory, { recursive: true, force: true }); } }); +test("invalid pipeTo calls abort the destination they were given", async () => { + const { client } = fixture(); + try { + const reader = await client.openPublicFile(large.address); + for (const [options, expected, close] of [ + [{ end: large.size + 1 }, /outside the file/], + [{ start: 10, end: 5 }, /outside the file/], + [{ start: -1 }, /safe integer/], + [{ size: 1 }, /unknown option `size`/], + [{ onProgress: "no" }, /onProgress must be a function/], + [{ signal: {} }, /AbortSignal/], + [{}, /closed/, true], + ]) { + if (close) reader.close(); + let aborted, closed = false; + const sink = new WritableStream({ write() { assert.fail("nothing is written"); }, close() { closed = true; }, abort(reason) { aborted = reason; } }); + await assert.rejects(reader.pipeTo(sink, options), expected); + assert.match(String(aborted), expected); + assert.equal(closed, false); + assert.equal(sink.locked, false); + } + } finally { client.close(); } +}); + +test("an AbortSignal cancels one pipeTo and leaves its reader open", async () => { + const { client } = fixture(); + let reader; + try { + reader = await client.openPublicFile(large.address); + const controller = new AbortController(); + const reason = new Error("user cancelled"); + let writes = 0, aborted; + const sink = new WritableStream({ + write() { writes++; controller.abort(reason); }, + abort(error) { aborted = error; }, + }); + await assert.rejects(reader.pipeTo(sink, { start: 2 ** 32, signal: controller.signal }), error => error === reason); + assert.equal(writes, 1); + assert.equal(aborted, reason); + assert.equal(sink.locked, false); + + const early = new WritableStream({ write() { assert.fail("an aborted signal writes nothing"); } }); + await assert.rejects(reader.pipeTo(early, { signal: AbortSignal.abort(reason) }), error => error === reason); + assert.equal(early.locked, false); + assert.deepEqual(await reader.readRange(2 ** 32, 4), new Uint8Array(4).fill(large.byte)); + } finally { reader?.close(); client.close(); } +}); + +test("closing a reader from the final progress callback keeps the completed file", async () => { + const original = new TextEncoder().encode("Final progress callback.".repeat(200)); + const encrypted = encryptPublicFile(original); + const { client } = fixture(encrypted.records); + try { + const reader = await client.openPublicFile(encrypted.address); + const chunks = []; + let closed = false, aborted = false; + const sink = new WritableStream({ write(bytes) { chunks.push(bytes); }, close() { closed = true; }, abort() { aborted = true; } }); + const progress = []; + const result = await reader.pipeTo(sink, { onProgress(written, total) { + progress.push([written, total]); + if (written === total) reader.close(); + } }); + assert.equal(result.bytesWritten, original.length); + assert.deepEqual(progress.at(-1), [original.length, original.length]); + assert(closed); + assert(!aborted); + assert.deepEqual(Buffer.concat(chunks), Buffer.from(original)); + } finally { client.close(); } +}); + +test("cancelling during a missing-record retry does not wait out the retry rounds", async () => { + const original = new TextEncoder().encode("Retry cancellation.".repeat(300)); + const encrypted = encryptPublicFile(original); + const missing = encrypted.records.find(record => record.address !== encrypted.address); + const { client } = fixture(encrypted.records.filter(record => record !== missing)); + try { + for (const cancel of ["close", "signal"]) { + const reader = await client.openPublicFile(encrypted.address); + const controller = new AbortController(); + const started = Date.now(); + const pending = cancel === "close" + ? reader.readRange(0, original.length) + : reader.pipeTo(new WritableStream(), { signal: controller.signal }); + setTimeout(() => cancel === "close" ? reader.close() : controller.abort(new Error("stop")), 100); + await assert.rejects(pending, cancel === "close" ? /closed/ : /stop/); + assert(Date.now() - started < 5_000, `${cancel} took ${Date.now() - started} ms`); + reader.close(); + } + } finally { client.close(); } +}); + test("writer rejection and reader cancellation stop a stream and release its lock", async () => { const { client } = fixture(); try { @@ -150,8 +251,16 @@ test("in-memory downloads use one JS output buffer and return recoverable alloca const NativeUint8Array = globalThis.Uint8Array; let reader; try { - const result = await client.downloadPublicFile(encrypted.address, 2, undefined, original.length); + const messages = []; + const result = await client.downloadPublicFile(encrypted.address, + { concurrency: 2, maxMemoryBytes: original.length, onProgress: message => messages.push(message) }); assert.deepEqual(result.content, original); + assert.equal(result.file.chunks.length, 3); + // Probes time the first chunks by these messages. + const chunkProgress = messages.filter(message => message.startsWith("Downloaded chunk ")); + assert.deepEqual(chunkProgress, ["Downloaded chunk 0/3", "Downloaded chunk 1/3", "Downloaded chunk 2/3", "Downloaded chunk 3/3"]); + const legacy = await client.downloadPublicFile(encrypted.address, 2, message => messages.push(message)); + assert.equal(legacy.hash, result.hash); globalThis.Uint8Array = class extends NativeUint8Array { constructor(...args) { if (args.length === 1 && args[0] === original.length) throw new RangeError("allocation refused"); diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 92dcbba0..4a4ad5e7 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -664,7 +664,11 @@ The browser download limit is independent of upload limits. Root-map file sizes, seek-index offsets, and shared range arithmetic use `u64`; only chunk indices and individual buffer lengths use `usize`. The JavaScript facade accepts nonnegative safe-integer numbers, rejecting fractional, overflowing and imprecise positions. -Manifest validation no longer rejects files over 1 GB or over 1,024 chunks. +Manifest validation no longer rejects files over 1 GB or over 1,024 chunks. It +caps a file's chunk list at 262,144 entries (about 1 TiB at the native chunk +size), which bounds the work an untrusted manifest can cause. Chunk sizes come +from the DataMap rather than this build's `MAX_CHUNK_SIZE`, so maps produced +with another self-encryption chunk size remain readable. A reader retains a validated root-map index and uses binary searches to identify overlapping chunks. Decryption delegates to `self_encryption::decrypt_chunk` @@ -673,19 +677,37 @@ dependency's pointer-sized whole-file range arithmetic without changing its KDF, DataMap representation or stored ciphertext. Encrypted records and reconstructed plaintext chunks are verified against their respective hashes and sizes. -Complete downloads allocate their output as one JavaScript `Uint8Array` and fill -it through bounded WASM reads. The optional `max_memory_bytes` argument limits -that output allocation. Allocation failure rejects before file-content transfer -and directs the caller to streaming. Automatic free-memory detection and -implicit disk spill are not portable browser capabilities; applications own the -memory budget and selection of a storage destination. - -`BrowserFileReader.pipeTo(writable, options?, onProgress?)` streams the whole file -or a half-open range to a caller-owned `WritableStream`. It waits for each write -before fetching the next range, writes at most 4 MiB per call, computes BLAKE3 -incrementally, closes on success, aborts on failure, and releases the writer lock. -Closing the reader cancels at a read/write boundary. The core does not open file -pickers, own browser filesystem policy, or change the companion SDK. +Reads are chunk-aligned. A shared engine primitive fetches a set of chunks in +one deferred retry pass and hands each decrypted chunk on as it completes, so a +missing record is retried alongside the others instead of stalling the chunks +behind it, and no chunk is decrypted twice. + +Complete downloads allocate their output as one JavaScript `Uint8Array`, fetch +every chunk in a single pass at the requested concurrency, and copy each chunk +into the output as it is decrypted. WASM holds only the records in flight. The +whole-file BLAKE3 is computed in file order; a chunk that completes ahead of +that order is read back from the output when its turn comes. Progress is still +reported as `Downloaded chunk n/total`. Options are passed as +`{ concurrency?, maxMemoryBytes?, onProgress? }`, and the earlier +`(file, concurrency?, onProgress?)` form remains supported. `maxMemoryBytes` +limits the output allocation. Allocation failure rejects before file-content +transfer and directs the caller to streaming. Automatic free-memory detection +and implicit disk spill are not portable browser capabilities; applications own +the memory budget and selection of a storage destination. + +`BrowserFileReader.pipeTo(writable, { start?, end?, signal?, onProgress? })` +streams the whole file or a half-open range to a caller-owned `WritableStream`. +It fetches windows of whole chunks holding up to 32 MiB of plaintext, then +writes them in order, at most 4 MiB per write, and waits for every write before +fetching the next window. Retry waits overlap within a window; holding more +than one window would trade that bound on memory for overlap across windows. +It computes BLAKE3 incrementally, closes the destination on success, aborts it +on any failure including invalid options, and releases the writer lock. An +`AbortSignal` cancels one operation without closing the reader. Closing the +reader cancels its operations at once, including fetches waiting to be retried, +and evicts only that reader's records from the client's shared cache. The core +does not open file pickers, own browser filesystem policy, or change the +companion SDK. Removing only the 1 GB check was rejected because wasm32 offsets would still truncate around 4 GiB. Whole-file WASM buffering was rejected because it couples @@ -694,7 +716,7 @@ because small files and consumers needing bytes can use JavaScript memory. This changes the public Rust browser descriptor's `size` from `usize` to `u64` and Rust signatures of affected WASM exports. JavaScript reader sizes and offsets -remain numbers; the download memory budget and `pipeTo` are additive. Ordinary +remain numbers; the download options object and `pipeTo` are additive. Ordinary native `data_download_range` callers retain their API, with an additional `data_download_range_u64` entry point. Wire, storage, payment and node behavior are unchanged. Upload size limits are unchanged. @@ -707,7 +729,9 @@ claiming literally unlimited file sizes. Validation includes a complete 4,303,347,835-byte native-encrypted fixture streamed through generated WASM with a matching BLAKE3; public/private seeks beyond 4 GiB; -disk output; backpressure; cancellation; writer errors; allocation failures; -invalid offsets; manifest validation; and shared native range tests. The generated +disk output; backpressure; cancellation by `AbortSignal`, by closing the reader +and during retry waits; writer errors; destinations aborted for invalid calls; +allocation failures; invalid offsets and options; manifest validation; and +shared native range, single-pass retry and ordered-hash tests. The generated WASM tests use mocked WebRTC hosts with real Rust protocol and crypto processing. They do not establish real-browser or live-network compatibility by themselves. From 1d4cc697748d201f2e543569c7ca543816c2b552 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:19:28 +0200 Subject: [PATCH 04/15] fix(wasm): bound chunk decryption and stream pipeTo through one pipeline self_encryption::decrypt_chunk decompresses without an output limit, so a crafted public DataMap could make a one-byte read expand a record to gigabytes and abort the WASM instance. Chunks are now decrypted with the same KDF, pad, cipher and Brotli steps, stopping one byte past the declared size, and DataMaps may declare chunks of at most 16 MiB. A test pins the output to decrypt_chunk. pipeTo replaces per-window retry passes with an ordered pipeline: chunks are fetched concurrently and written in order, fetching continues during writes up to a 32 MiB buffer, and each missing record retries on its own schedule so waits overlap. - Poll cancellation before the operation so a closed reader or aborted signal never lets a dropped read finish or refill the cache. - Keep download options optional in the generated typings and restore `>>> 0` coercion for the legacy numeric concurrency argument. - Accept an AbortSignal for complete downloads, covering resolution. - Hash out-of-order chunks between event-loop turns, copy chunks into the output without an intermediate buffer, and pass settings as a struct. - Evict a closed reader's records by visiting only cached entries. - Share one MAX_SAFE_JS_INTEGER, rename the safe-integer helper, keep a single owner for the empty chunk list, and fix a stale doc comment. - Make the backpressure and memory-budget tests able to fail. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + README.md | 31 +- ant-core/Cargo.toml | 4 + ant-core/src/browser.rs | 6 +- ant-core/src/browser/manifest.rs | 21 +- ant-core/src/browser/wasm_transport.rs | 45 +- .../src/browser/wasm_transport/download.rs | 401 ++++++++------- ant-core/src/client_engine.rs | 1 + ant-core/src/client_engine/chunk_decrypt.rs | 151 ++++++ ant-core/src/client_engine/files.rs | 465 +++++++++++++++--- ant-core/src/data/client/cache.rs | 27 + ant-core/src/data/client/data.rs | 18 + ant-core/wasm-tests/README.md | 10 +- ant-core/wasm-tests/large-download.test.mjs | 125 ++++- .../ADR-0004-direct-browser-read-client.md | 48 +- 15 files changed, 1017 insertions(+), 338 deletions(-) create mode 100644 ant-core/src/client_engine/chunk_decrypt.rs diff --git a/Cargo.lock b/Cargo.lock index 9da2f580..cae0ac93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -850,7 +850,9 @@ dependencies = [ "axum", "base64", "blake3", + "brotli-decompressor", "bytes", + "chacha20poly1305", "console_error_panic_hook", "fips204", "flate2", diff --git a/README.md b/README.md index 4e58ee32..15687c2a 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,19 @@ use 64-bit arithmetic internally; the JavaScript API accepts exact, nonnegative `number` positions through `Number.MAX_SAFE_INTEGER` (just under 8 PiB). The resolved DataMap and seek index still occupy memory proportional to the number of chunks. Individual chunk buffers remain bounded, including for seeks past -4 GiB. Uploads retain their existing size limits. - -`downloadPublicFile(file, { concurrency?, maxMemoryBytes?, onProgress? })` and -its private-file counterpart allocate the output as a JavaScript `Uint8Array`. -They fetch every chunk in one deferred retry pass at the requested concurrency -and copy each chunk into the output as soon as it is decrypted, so WASM holds -only the records in flight, never the whole plaintext or ciphertext. Progress -is still reported as `Downloaded chunk n/total`. The older -`(file, concurrency?, onProgress?)` form remains supported. `maxMemoryBytes` -limits the output buffer, not total process memory. Without a budget, +4 GiB: a DataMap may declare chunks of up to 16 MiB, and decompression stops at +the declared size, so a crafted record cannot expand inside WASM. Uploads +retain their existing size limits. + +`downloadPublicFile(file, { concurrency?, maxMemoryBytes?, onProgress?, signal? })` +and its private-file counterpart allocate the output as a JavaScript +`Uint8Array`. They fetch every chunk in one deferred retry pass at the requested +concurrency and copy each chunk into the output as soon as it is decrypted, so +WASM holds only the records in flight, never the whole plaintext or ciphertext. +Progress is still reported as `Downloaded chunk n/total`, and an `AbortSignal` +cancels the download at any point. The older `(file, concurrency?, onProgress?)` +form remains supported, with the same coercion of the concurrency number as +before. `maxMemoryBytes` limits the output buffer, not total process memory. Without a budget, allocation is attempted up to the JavaScript engine's buffer limit. Allocation errors reject with guidance to use streaming; browsers do not expose a reliable portable amount of free memory. @@ -70,9 +73,11 @@ try { The same API works with `openPrivateFile({ data_map })`. `pipeTo(writable, { start?, end?, signal?, onProgress? })` optionally streams a half-open byte range and reports `(bytesWritten, totalBytes)` after each write. It fetches -windows of whole chunks, up to 32 MiB of plaintext, concurrently in one -deferred retry pass, then writes them in order, at most 4 MiB per write. It -waits for every write before fetching the next window. It closes the +chunks concurrently and writes them in order, at most 4 MiB per write, each +awaited before the next. Fetching continues while a write is pending until +32 MiB of chunks are in flight or waiting, so a slow destination holds memory +at that bound. Each missing record is retried on its own schedule while the +rest of the buffer keeps fetching, so retry waits overlap. It closes the destination on success, aborts it on any failure (including invalid options), and releases the writer lock. Its result contains the byte count and BLAKE3 of the written range. An `AbortSignal` cancels that one operation and leaves the diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 8abc470c..72b28849 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -25,6 +25,10 @@ serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" self_encryption = "0.36" +# The same versions self_encryption uses, for per-chunk decryption with a +# bound on decompressed output (client_engine/chunk_decrypt.rs). +brotli-decompressor = { version = "2.3", default-features = false, features = ["std"] } +chacha20poly1305 = "0.10" # Portable WebRTC Direct profile shared with ant-node. saorsa-transport = { version = "0.37.0", default-features = false, features = ["webrtc"] } thiserror = "2" diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 922890c1..39732dde 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -33,8 +33,10 @@ use std::collections::HashSet; /// Maximum file size accepted by the browser upload API (1 GB decimal). /// -/// The page upload path streams through a worker and browser storage. Complete -/// downloads use bounded ranges; the legacy encryption binding remains memory-bound. +/// The page upload path streams through a worker and browser storage; the +/// legacy encryption binding remains memory-bound. Downloads are not limited by +/// this constant: a complete download needs one buffer of the file's size, +/// while `pipeTo` and range reads hold a bounded amount of plaintext. pub const MAX_BROWSER_FILE_BYTES: usize = 1_000_000_000; /// One native self-encryption chunk descriptor exposed to the browser. diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs index 56b7a342..601ecf52 100644 --- a/ant-core/src/browser/manifest.rs +++ b/ant-core/src/browser/manifest.rs @@ -3,7 +3,7 @@ pub use super::protocol::BrowserPaymentNetwork; use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint}; use super::BrowserChunkInfo; -use crate::client_engine::files::MIN_FILE_CHUNKS; +use crate::client_engine::files::{MAX_CHUNK_PLAINTEXT_BYTES, MIN_FILE_CHUNKS}; use serde::{Deserialize, Serialize}; /// Current browser testnet manifest version. @@ -12,8 +12,9 @@ const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024; /// Bounds the work an untrusted manifest entry can cause before its chunk /// list is checked. At the native chunk size this admits files of about 1 TiB. const MAX_FILE_CHUNKS: usize = 1 << 18; -/// Largest exact byte position exposed through the JavaScript number API. -pub const MAX_BROWSER_FILE_POSITION: u64 = 9_007_199_254_740_991; +/// `Number.MAX_SAFE_INTEGER`: the largest integer JavaScript represents +/// exactly, which bounds chain IDs, file sizes and byte positions. +pub const MAX_SAFE_JS_INTEGER: u64 = 9_007_199_254_740_991; /// A validated WebRTC Direct bootstrap endpoint. pub type BrowserManifestEndpoint = BrowserEndpoint; @@ -104,7 +105,7 @@ pub fn validate_browser_payment_network( ) -> Result { // The JS SDK exposes chain IDs as numbers; reject identities that cannot // survive that boundary exactly. - if payment.chain_id > 9_007_199_254_740_991 { + if payment.chain_id > MAX_SAFE_JS_INTEGER { return Err(BrowserManifestError( "payment chain ID exceeds JavaScript's safe integer range".to_string(), )); @@ -155,9 +156,7 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest )); } file.address = normalize_hex(&file.address, 32).map_err(BrowserManifestError)?; - if !(self_encryption::MIN_ENCRYPTABLE_BYTES as u64..=MAX_BROWSER_FILE_POSITION) - .contains(&file.size) - { + if !(self_encryption::MIN_ENCRYPTABLE_BYTES as u64..=MAX_SAFE_JS_INTEGER).contains(&file.size) { return Err(BrowserManifestError(format!( "invalid public file size {}", file.size @@ -186,7 +185,7 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?; chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?; // Sizes come from the DataMap, which may use another chunk size. - if chunk.src_size == 0 { + if !(1..=MAX_CHUNK_PLAINTEXT_BYTES).contains(&chunk.src_size) { return Err(BrowserManifestError(format!( "invalid plaintext chunk size {}", chunk.src_size @@ -316,7 +315,7 @@ mod tests { normalize_file(&mut file).unwrap(); file.size += 1; assert!(normalize_file(&mut file).is_err()); - file.size = MAX_BROWSER_FILE_POSITION + 1; + file.size = MAX_SAFE_JS_INTEGER + 1; assert!(normalize_file(&mut file).is_err()); } @@ -342,6 +341,10 @@ mod tests { .collect(), }; normalize_file(&mut file).unwrap(); + file.chunks[1].src_size = MAX_CHUNK_PLAINTEXT_BYTES + 1; + file.size += (MAX_CHUNK_PLAINTEXT_BYTES + 1 - large_chunk) as u64; + let error = normalize_file(&mut file).unwrap_err(); + assert!(error.to_string().contains("chunk size")); // Rejected on length alone, before the list is sorted or decoded. file.chunks = (0..=MAX_FILE_CHUNKS) diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index f52ecfb3..32397253 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2404,15 +2404,17 @@ impl BrowserNetworkClient { } /// Download a public file into one JavaScript Uint8Array, using bounded - /// WASM buffers. `options` is `{ concurrency?, maxMemoryBytes?, onProgress? }`; - /// `maxMemoryBytes` limits the output allocation. A number is the legacy - /// concurrency argument, paired with a positional `onProgress`. For - /// disk-backed downloads use openPublicFile and reader.pipeTo(writable). + /// WASM buffers. `options` is + /// `{ concurrency?, maxMemoryBytes?, onProgress?, signal? }`: + /// `maxMemoryBytes` limits the output allocation and an AbortSignal + /// cancels the download. A number is the legacy concurrency argument, + /// paired with a positional `onProgress`. For disk-backed downloads use + /// openPublicFile and reader.pipeTo(writable). #[wasm_bindgen(js_name = downloadPublicFile)] pub async fn download_public_file( &self, file: JsValue, - options: JsValue, + options: Option, on_progress: Option, ) -> Result { let file: BrowserPublicFileInput = serde_wasm_bindgen::from_value(file) @@ -2428,7 +2430,7 @@ impl BrowserNetworkClient { pub async fn download_private_file( &self, file: JsValue, - options: JsValue, + options: Option, on_progress: Option, ) -> Result { let file: BrowserPrivateFileInput = serde_wasm_bindgen::from_value(file) @@ -2653,15 +2655,12 @@ impl BrowserNetworkClient { async fn download_file( &self, source: BrowserFileSource, - options: JsValue, + options: Option, on_progress: Option, ) -> Result { let settings = download::DownloadSettings::from_js(options, on_progress) .map_err(|error| JsValue::from_str(&error))?; - let result = self - .download_file_inner(source, settings) - .await - .map_err(|error| JsValue::from_str(&error))?; + let result = self.download_file_inner(source, settings).await?; serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) } @@ -2685,19 +2684,27 @@ impl BrowserNetworkClient { )) } + /// Errors are JavaScript values so an AbortSignal's reason reaches the caller. async fn download_file_inner( &self, source: BrowserFileSource, settings: download::DownloadSettings, - ) -> Result { - let progress = &settings.progress; - let resolved = self.resolve_file(source, progress).await?; - let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved.file, resolved.index); - let (content, hash) = reader - .collect(settings.concurrency, settings.memory_budget, progress) + ) -> Result { + // Validates the signal before any network work, and covers resolution. + let mut cancel = download::Cancellation::new(None, settings.signal.clone())?; + let resolved = cancel + .run(async { + self.resolve_file(source, &settings.progress) + .await + .map_err(|error| JsValue::from_str(&error)) + }) .await?; + let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved.file, resolved.index); + let (content, hash) = reader.collect(&settings, &mut cancel).await?; let file = reader.into_descriptor(hash.clone()); - progress.report(&format!("Verified complete {} as {hash}", file.name)); + settings + .progress + .report(&format!("Verified complete {} as {hash}", file.name)); Ok(BrowserDownloadResult { content, hash, @@ -2805,7 +2812,7 @@ impl BrowserNetworkClient { .map_err(|error| error.to_string())?; let index = crate::client_engine::files::FileIndex::new(&root_data_map)?; - if index.size() > super::manifest::MAX_BROWSER_FILE_POSITION { + if index.size() > super::manifest::MAX_SAFE_JS_INTEGER { return Err("file size exceeds JavaScript's exact integer range".into()); } diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index bed314a7..f9504e9c 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -1,32 +1,54 @@ //! Bounded plaintext reads with 64-bit positions and caller-owned destinations. use super::*; -use crate::client_engine::files::{FileIndex, OrderedHasher}; +use crate::browser::manifest::MAX_SAFE_JS_INTEGER; +use crate::client_engine::files::{ordered_pipeline, FileIndex, OrderedHasher}; use bytes::Bytes; use std::ops::Range; -/// Plaintext one `pipeTo` window may hold. A window's whole chunks are -/// fetched concurrently in one deferred retry pass, then written in order; -/// nothing more is fetched while those writes are pending. -const PIPE_WINDOW_BYTES: u64 = 32 * 1024 * 1024; +/// Plaintext a `pipeTo` holds at once: chunks in flight or fetched but not yet +/// written. Fetching continues while writes are pending until this is reached. +const PIPE_BUFFER_BYTES: u64 = 32 * 1024 * 1024; const STREAM_HINT: &str = "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; const CLOSED_READER: &str = "browser file reader is closed"; const ABORTED: &str = "download aborted"; const ABORT_EVENT: &str = "abort"; +const POSITIVE_CONCURRENCY: &str = "download concurrency must be a positive integer"; const PIPE_OPTION_NAMES: [&str; 4] = ["start", "end", "signal", "onProgress"]; -const DOWNLOAD_OPTION_NAMES: [&str; 3] = ["concurrency", "maxMemoryBytes", "onProgress"]; +const DOWNLOAD_OPTION_NAMES: [&str; 4] = ["concurrency", "maxMemoryBytes", "onProgress", "signal"]; +/// JavaScript's ToUint32 wraps numbers modulo 2^32. +const UINT32_RANGE: f64 = u32::MAX as f64 + 1.0; -fn file_position(value: f64, label: &str) -> Result { +/// A nonnegative integer that JavaScript represents exactly. +fn safe_integer(value: f64, label: &str) -> Result { if !value.is_finite() || value < 0.0 || value.fract() != 0.0 - || value > super::super::manifest::MAX_BROWSER_FILE_POSITION as f64 + || value > MAX_SAFE_JS_INTEGER as f64 { return Err(format!("{label} must be a nonnegative safe integer")); } Ok(value as u64) } +/// `value >>> 0`, which wasm-bindgen applied to the numeric concurrency +/// argument before downloads took an options object. +fn legacy_concurrency(value: &JsValue) -> usize { + if value.is_symbol() || value.is_bigint() { + return 0; + } + // Unary `+`, the ToNumber step of `>>>`; it throws only for the excluded types. + let number = value.unchecked_into_f64(); + if !number.is_finite() { + return 0; + } + number.trunc().rem_euclid(UINT32_RANGE) as usize +} + +fn js_string(error: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&error.to_string()) +} + fn method(object: &JsValue, name: &str) -> Result { js_sys::Reflect::get(object, &JsValue::from_str(name))? .dyn_into::() @@ -68,6 +90,11 @@ fn options_from_js( serde_wasm_bindgen::from_value(value).map_err(|error| error.to_string()) } +/// Let the page handle events between long stretches of synchronous work. +async fn yield_to_event_loop() { + TimeoutFuture::new(0).await; +} + /// Release the writer even if the Rust future is dropped before it completes. struct Writer(JsValue); impl Drop for Writer { @@ -101,7 +128,8 @@ struct PipeResult { hash: String, } -/// Complete-download options: `{ concurrency?, maxMemoryBytes?, onProgress? }`. +/// Complete-download options: +/// `{ concurrency?, maxMemoryBytes?, onProgress?, signal? }`. #[derive(Default, Deserialize)] #[serde(rename_all = "camelCase")] struct DownloadOptions { @@ -112,6 +140,9 @@ struct DownloadOptions { /// Called with progress messages. #[serde(default, with = "serde_wasm_bindgen::preserve")] on_progress: JsValue, + /// AbortSignal that cancels the download. + #[serde(default, with = "serde_wasm_bindgen::preserve")] + signal: JsValue, } /// Validated complete-download options. @@ -119,40 +150,40 @@ pub(super) struct DownloadSettings { pub(super) concurrency: usize, pub(super) memory_budget: Option, pub(super) progress: ProgressReporter, + /// AbortSignal, or `undefined` when the caller passed none. + pub(super) signal: JsValue, } impl DownloadSettings { - /// `options` is an options object. A number is the legacy concurrency - /// argument, which pairs with the positional `legacy_progress` callback. + /// `options` is an options object. Any other value is the legacy + /// concurrency argument, coerced as before, which pairs with the + /// positional `legacy_progress` callback. pub(super) fn from_js( - options: JsValue, + options: Option, legacy_progress: Option, ) -> Result { - let (options, progress) = match options.as_f64() { - Some(concurrency) => ( - DownloadOptions { - concurrency: Some(concurrency), - ..DownloadOptions::default() - }, - legacy_progress, - ), - None => { - let mut options: DownloadOptions = - options_from_js(options, &DOWNLOAD_OPTION_NAMES)?; - let progress = - optional_function(std::mem::take(&mut options.on_progress), "onProgress")?; - if progress.is_some() && legacy_progress.is_some() { - return Err("pass onProgress in the options or as an argument, not both".into()); - } - (options, progress.or(legacy_progress)) + let options = options.unwrap_or(JsValue::UNDEFINED); + if !options.is_undefined() && !options.is_object() { + let concurrency = legacy_concurrency(&options); + if concurrency == 0 { + return Err(POSITIVE_CONCURRENCY.into()); } - }; + return Ok(Self { + concurrency, + memory_budget: None, + progress: ProgressReporter::from_js(legacy_progress), + signal: JsValue::UNDEFINED, + }); + } + let options: DownloadOptions = options_from_js(options, &DOWNLOAD_OPTION_NAMES)?; + let progress = optional_function(options.on_progress, "onProgress")?; + if progress.is_some() && legacy_progress.is_some() { + return Err("pass onProgress in the options or as an argument, not both".into()); + } let concurrency = match options.concurrency { None => usize::MAX, - Some(value) => match file_position(value, "download concurrency") { - Ok(0) | Err(_) => { - return Err("download concurrency must be a positive integer".into()) - } + Some(value) => match safe_integer(value, "download concurrency") { + Ok(0) | Err(_) => return Err(POSITIVE_CONCURRENCY.into()), Ok(value) => usize::try_from(value).unwrap_or(usize::MAX), }, }; @@ -160,9 +191,10 @@ impl DownloadSettings { concurrency, memory_budget: options .max_memory_bytes - .map(|value| file_position(value, "memory budget")) + .map(|value| safe_integer(value, "memory budget")) .transpose()?, - progress: ProgressReporter::from_js(progress), + progress: ProgressReporter::from_js(progress.or(legacy_progress)), + signal: options.signal, }) } } @@ -222,14 +254,19 @@ impl Drop for AbortListener { } } -/// Stops one reader operation when its reader closes or its AbortSignal fires. -struct Cancellation { - closed: watch::Receiver, +/// Stops an operation when its AbortSignal fires or, for a reader +/// operation, when the reader closes. +pub(super) struct Cancellation { + closed: Option>, abort: Option, } impl Cancellation { - fn new(closed: watch::Receiver, signal: JsValue) -> Result { + /// `closed` follows the reader an operation belongs to, if any. + pub(super) fn new( + closed: Option>, + signal: JsValue, + ) -> Result { let abort = if signal.is_undefined() || signal.is_null() { None } else { @@ -243,22 +280,31 @@ impl Cancellation { if let Some(reason) = self.abort.as_ref().and_then(AbortListener::reason) { return Err(reason); } - if *self.closed.borrow() { + if self.closed.as_ref().is_some_and(|closed| *closed.borrow()) { return Err(JsValue::from_str(CLOSED_READER)); } Ok(()) } - /// Run `operation` unless it is cancelled first. Cancelling drops it, - /// which stops its in-flight fetches and retry waits at once. - async fn run( + /// Run `operation` unless it is cancelled first. Cancellation is polled + /// before the operation, so once the reader closes or the signal aborts + /// the operation never runs again: it is dropped, stopping its in-flight + /// fetches and retry waits, before it can finish or refill the cache. + pub(super) async fn run( &mut self, - operation: impl Future>, + operation: impl Future>, ) -> Result { self.check()?; let finished = { let Self { closed, abort } = &mut *self; - let closed = closed.wait_for(|closed| *closed); + let closed = async move { + match closed { + Some(closed) => { + let _ = closed.wait_for(|closed| *closed).await; + } + None => futures_util::future::pending::<()>().await, + } + }; let aborted = async move { match abort { Some(abort) => { @@ -268,18 +314,17 @@ impl Cancellation { } }; futures_util::pin_mut!(operation, closed, aborted); - match select(operation, select(closed, aborted)).await { - Either::Left((result, _)) => Some(result), - Either::Right(_) => None, + match select(select(closed, aborted), operation).await { + Either::Left(_) => None, + Either::Right((result, _)) => Some(result), } }; - match finished { - Some(result) => result.map_err(|error| JsValue::from_str(&error)), - None => Err(self + finished.unwrap_or_else(|| { + Err(self .check() .err() - .unwrap_or_else(|| JsValue::from_str(CLOSED_READER))), - } + .unwrap_or_else(|| JsValue::from_str(CLOSED_READER))) + }) } } @@ -317,22 +362,20 @@ impl BrowserFileReader { /// Closing the reader rejects a read in progress. #[wasm_bindgen(js_name = readRange)] pub async fn read_range(&self, start: f64, length: f64) -> Result { - let start = - file_position(start, "range start").map_err(|error| JsValue::from_str(&error))?; - let length = - file_position(length, "range length").map_err(|error| JsValue::from_str(&error))?; + let start = safe_integer(start, "range start").map_err(js_string)?; + let length = safe_integer(length, "range length").map_err(js_string)?; if length > MAX_BROWSER_RANGE_BYTES as u64 { return Err(JsValue::from_str(&format!( "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" ))); } - let mut cancel = Cancellation::new(self.closed.subscribe(), JsValue::UNDEFINED)?; + let mut cancel = Cancellation::new(Some(self.closed.subscribe()), JsValue::UNDEFINED)?; let bytes = cancel .run(async { self.shared .data_download_indexed_range(&self.index, start, length as usize) .await - .map_err(|error| error.to_string()) + .map_err(js_string) }) .await?; Ok(Uint8Array::from(bytes.as_ref())) @@ -343,8 +386,10 @@ impl BrowserFileReader { /// half-open byte range, an AbortSignal that cancels this operation, and /// a `(bytesWritten, totalBytes)` callback run after each write. /// - /// Whole chunks are fetched a window at a time, and every write is awaited - /// before the next window is fetched. Writes are at most 4 MiB. Closes the + /// Chunks are fetched concurrently and written in order, at most 4 MiB per + /// write, each awaited before the next. Fetching continues during writes + /// until 32 MiB of chunks are in flight or waiting, and each missing record + /// is retried on its own schedule while the others continue. Closes the /// destination on success, aborts it on any failure (including invalid /// options), and always releases the writer lock. Returns the BLAKE3 and /// byte count of the written range. Closing the reader also cancels. @@ -373,20 +418,19 @@ impl BrowserFileReader { /// AbortSignal to cancel a single pipeTo without closing the reader. pub fn close(&self) { self.closed.send_replace(true); - let cache = self.shared.chunk_cache(); - for address in self.index.record_addresses() { - cache.remove(&address.0); - } + let records = self.index.record_addresses(); + self.shared + .chunk_cache() + .remove_matching(|address| records.iter().any(|record| record.0 == *address)); } } impl BrowserFileReader { pub(super) fn new( shared: Rc, - mut file: PublicFileDescriptor, + file: PublicFileDescriptor, index: FileIndex, ) -> Self { - file.chunks = Vec::new(); Self { shared, file, @@ -408,61 +452,74 @@ impl BrowserFileReader { } async fn pipe_to_writer(&self, writer: &JsValue, options: JsValue) -> Result { - let options: PipeOptions = options_from_js(options, &PIPE_OPTION_NAMES) - .map_err(|error| JsValue::from_str(&error))?; - let progress = optional_function(options.on_progress, "onProgress") - .map_err(|error| JsValue::from_str(&error))?; - let mut cancel = Cancellation::new(self.closed.subscribe(), options.signal)?; + let options: PipeOptions = + options_from_js(options, &PIPE_OPTION_NAMES).map_err(js_string)?; + let progress = optional_function(options.on_progress, "onProgress").map_err(js_string)?; + let mut cancel = Cancellation::new(Some(self.closed.subscribe()), options.signal)?; cancel.check()?; let range = self .stream_range(options.start, options.end) - .map_err(|error| JsValue::from_str(&error))?; + .map_err(js_string)?; let write = method(writer, "write")?; + let (write, progress) = (&write, &progress); let total = range.end - range.start; - let mut written = 0u64; let mut hasher = blake3::Hasher::new(); - let chunks = self.index.chunks_overlapping(range.clone()); - let mut next = chunks.start; - while next < chunks.end { - let window = next..self.index.window_end(next, chunks.end, PIPE_WINDOW_BYTES); - for plaintext in self - .fetch_window(window.clone(), &range, &mut cancel) - .await? - { - for piece in plaintext.chunks(MAX_BROWSER_RANGE_BYTES) { - // Checked before each write, never after the last one: - // a finished destination is closed, not aborted. - cancel.check()?; - hasher.update(piece); - JsFuture::from(Promise::resolve( - &write.call1(writer, &Uint8Array::from(piece))?, - )) - .await?; - written += piece.len() as u64; - if let Some(callback) = &progress { - callback.call2( - &JsValue::NULL, - &JsValue::from_f64(written as f64), - &JsValue::from_f64(total as f64), - )?; + cancel + .run(ordered_pipeline( + self.index.chunks_overlapping(range.clone()), + |chunk| { + let span = self.index.chunk_span(chunk); + span.end - span.start + }, + PIPE_BUFFER_BYTES, + &|| self.shared.controller().fetch.current(), + |chunk| async move { + self.shared + .data_download_indexed_chunk(&self.index, chunk) + .await + .map_err(js_string) + }, + |chunk, plaintext: Bytes| { + let span = self.index.chunk_span(chunk); + let from = range.start.max(span.start); + let to = range.end.min(span.end); + let plaintext = + plaintext.slice((from - span.start) as usize..(to - span.start) as usize); + hasher.update(&plaintext); + let mut written = from - range.start; + async move { + for piece in plaintext.chunks(MAX_BROWSER_RANGE_BYTES) { + JsFuture::from(Promise::resolve( + &write.call1(writer, &Uint8Array::from(piece))?, + )) + .await?; + written += piece.len() as u64; + if let Some(callback) = progress { + callback.call2( + &JsValue::NULL, + &JsValue::from_f64(written as f64), + &JsValue::from_f64(total as f64), + )?; + } + } + Ok(()) } - } - } - next = window.end; - } + }, + )) + .await?; JsFuture::from(Promise::resolve(&method(writer, "close")?.call0(writer)?)).await?; serde_wasm_bindgen::to_value(&PipeResult { - bytes_written: written, + bytes_written: total, hash: hasher.finalize().to_hex().to_string(), }) - .map_err(|error| JsValue::from_str(&error.to_string())) + .map_err(js_string) } /// Validate a `pipeTo` range against the file. fn stream_range(&self, start: Option, end: Option) -> Result, String> { - let start = file_position(start.unwrap_or(0.0), "stream start")?; + let start = safe_integer(start.unwrap_or(0.0), "stream start")?; let end = end - .map(|end| file_position(end, "stream end")) + .map(|end| safe_integer(end, "stream end")) .transpose()? .unwrap_or(self.file.size); if start > end || end > self.file.size { @@ -471,44 +528,6 @@ impl BrowserFileReader { Ok(start..end) } - /// Fetch one window of whole chunks, returning each chunk's plaintext - /// trimmed to the `pipeTo` range, in file order. - async fn fetch_window( - &self, - window: Range, - range: &Range, - cancel: &mut Cancellation, - ) -> Result, JsValue> { - let mut plaintexts = vec![None; window.len()]; - cancel - .run(async { - self.shared - .data_download_indexed_chunks( - &self.index, - window.clone(), - usize::MAX, - |chunk, plaintext| { - plaintexts[chunk - window.start] = Some(plaintext); - Ok(()) - }, - ) - .await - .map_err(|error| error.to_string()) - }) - .await?; - window - .zip(plaintexts) - .map(|(chunk, plaintext)| { - let plaintext = - plaintext.ok_or_else(|| JsValue::from_str("window chunk missing"))?; - let span = self.index.chunk_span(chunk); - let from = range.start.max(span.start) - span.start; - let to = range.end.min(span.end) - span.start; - Ok(plaintext.slice(from as usize..to as usize)) - }) - .collect() - } - /// Allocate output in JS, then copy each decrypted chunk into it as it /// arrives. The whole file is fetched in one deferred retry pass at the /// requested concurrency; WASM holds only the records in flight. A caller @@ -516,63 +535,85 @@ impl BrowserFileReader { /// disk-backed path without downloading the file first. pub(super) async fn collect( &self, - concurrency: usize, - budget: Option, - progress: &ProgressReporter, - ) -> Result<(Uint8Array, String), String> { - if budget.is_some_and(|limit| self.file.size > limit) { - return Err(format!( + settings: &DownloadSettings, + cancel: &mut Cancellation, + ) -> Result<(Uint8Array, String), JsValue> { + let progress = &settings.progress; + if settings + .memory_budget + .is_some_and(|limit| self.file.size > limit) + { + return Err(js_string(format_args!( "file exceeds the download memory budget; {STREAM_HINT}" - )); + ))); } - let constructor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Uint8Array")) - .map_err(js_error_message)? - .dyn_into::() - .map_err(js_error_message)?; + let constructor = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Uint8Array"))? + .dyn_into::()?; let args = Array::new(); args.push(&JsValue::from_f64(self.size())); let output = js_sys::Reflect::construct(&constructor, &args) .map_err(|error| { - format!( + js_string(format_args!( "cannot allocate download buffer: {}; {STREAM_HINT}", js_error_message(error) - ) + )) })? .unchecked_into::(); // Reflect calls preserve Number offsets above u32::MAX and catch JS // allocation exceptions instead of turning them into WASM traps. - let set = method(output.as_ref(), "set").map_err(js_error_message)?; - let subarray = method(output.as_ref(), "subarray").map_err(js_error_message)?; - let read_back = |chunk: usize| -> Result, String> { + let subarray = method(output.as_ref(), "subarray")?; + let chunk_view = |chunk: usize| -> Result { let span = self.index.chunk_span(chunk); - subarray + Ok(subarray .call2( output.as_ref(), &JsValue::from_f64(span.start as f64), &JsValue::from_f64(span.end as f64), - ) - .map(|view| view.unchecked_into::().to_vec()) - .map_err(js_error_message) + )? + .unchecked_into()) }; let total = self.index.chunk_count(); let mut hasher = OrderedHasher::new(total); let mut completed = 0usize; progress.report(&format!("Downloaded chunk {completed}/{total}")); - self.shared - .data_download_indexed_chunks(&self.index, 0..total, concurrency, |chunk, plaintext| { - set.call2( - output.as_ref(), - &Uint8Array::from(plaintext.as_ref()), - &JsValue::from_f64(self.index.chunk_span(chunk).start as f64), - ) - .map_err(js_error_message)?; - hasher.complete(chunk, &plaintext, &read_back)?; - completed += 1; - progress.report(&format!("Downloaded chunk {completed}/{total}")); - Ok(()) + cancel + .run(async { + self.shared + .data_download_indexed_chunks( + &self.index, + 0..total, + settings.concurrency, + |chunk, plaintext| { + // Copies straight from WASM memory into the output. + chunk_view(chunk) + .map_err(js_error_message)? + .copy_from(&plaintext); + hasher.complete(chunk, &plaintext); + // At most one earlier chunk per arrival keeps each + // callback short. + if let Some(earlier) = hasher.pending_read_back() { + let view = chunk_view(earlier).map_err(js_error_message)?; + hasher.read_back(earlier, &view.to_vec())?; + } + completed += 1; + progress.report(&format!("Downloaded chunk {completed}/{total}")); + Ok(()) + }, + ) + .await + .map_err(js_string) }) - .await - .map_err(|error| error.to_string())?; - Ok((output, hasher.finish()?)) + .await?; + // Hash the chunks that arrived ahead of file order, yielding between + // them so a large backlog cannot freeze the page. + while let Some(chunk) = hasher.pending_read_back() { + cancel.check()?; + hasher + .read_back(chunk, &chunk_view(chunk)?.to_vec()) + .map_err(js_string)?; + yield_to_event_loop().await; + } + Ok((output, hasher.finish().map_err(js_string)?)) } } diff --git a/ant-core/src/client_engine.rs b/ant-core/src/client_engine.rs index dabe39dc..4abcd0fd 100644 --- a/ant-core/src/client_engine.rs +++ b/ant-core/src/client_engine.rs @@ -1,5 +1,6 @@ //! Runtime-neutral scheduling and session state shared by native and browser clients. +mod chunk_decrypt; pub(crate) mod files; pub(crate) mod read; #[cfg(any(test, all(target_arch = "wasm32", feature = "browser-wasm")))] diff --git a/ant-core/src/client_engine/chunk_decrypt.rs b/ant-core/src/client_engine/chunk_decrypt.rs new file mode 100644 index 00000000..d975418f --- /dev/null +++ b/ant-core/src/client_engine/chunk_decrypt.rs @@ -0,0 +1,151 @@ +//! Per-chunk self-encryption decryption with bounded decompression. +//! +//! `self_encryption::decrypt_chunk` decompresses without an output limit, so a +//! crafted record could expand far past the size its DataMap declares before +//! that size can be checked. This performs the same steps — the BLAKE3 KDF over +//! the chunk and its two predecessors, the XOR pad, ChaCha20-Poly1305 and +//! Brotli — and stops decompressing one byte past the declared size. Tests pin +//! the result to `decrypt_chunk`, so a KDF change in the dependency fails them. + +use super::files::MIN_FILE_CHUNKS; +use bytes::Bytes; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use self_encryption::XorName; +use std::io::Read; + +/// Domain separation context of self_encryption's chunk KDF. +const KDF_CONTEXT: &str = "self_encryption/chunk/v2"; +const HASH_SIZE: usize = 32; +const KEY_SIZE: usize = 32; +const NONCE_SIZE: usize = 12; +/// The KDF output left after the key and nonce, used as the XOR pad. +const PAD_SIZE: usize = 3 * HASH_SIZE - KEY_SIZE - NONCE_SIZE; +/// Three source hashes, the chunk index and the KDF level. +const KDF_INPUT_SIZE: usize = 3 * HASH_SIZE + 2 * std::mem::size_of::(); +/// Brotli decoder input buffer. +const DECOMPRESS_BUFFER_BYTES: usize = 4096; + +/// The per-chunk secrets self_encryption derives from the source hashes. +struct ChunkSecrets { + pad: [u8; PAD_SIZE], + key: [u8; KEY_SIZE], + nonce: [u8; NONCE_SIZE], +} + +/// Decrypt chunk `index` of a map with `src_hashes`, rejecting any record that +/// decompresses to anything other than `expected_len` bytes. At most +/// `expected_len + 1` bytes are ever produced. +pub(super) fn decrypt_chunk( + index: usize, + content: &[u8], + src_hashes: &[XorName], + kdf_level: usize, + expected_len: usize, +) -> Result { + let secrets = chunk_secrets(index, src_hashes, kdf_level)?; + let unpadded: Vec = content + .iter() + .zip(secrets.pad.iter().cycle()) + .map(|(byte, pad)| byte ^ pad) + .collect(); + let compressed = ChaCha20Poly1305::new(Key::from_slice(&secrets.key)) + .decrypt(Nonce::from_slice(&secrets.nonce), unpadded.as_slice()) + .map_err(|error| format!("chunk decryption failed: {error}"))?; + let mut plaintext = Vec::new(); + plaintext + .try_reserve_exact(expected_len) + .map_err(|error| format!("cannot allocate chunk: {error}"))?; + brotli_decompressor::Decompressor::new(compressed.as_slice(), DECOMPRESS_BUFFER_BYTES) + .take(expected_len as u64 + 1) + .read_to_end(&mut plaintext) + .map_err(|_| "chunk decompression failed".to_string())?; + if plaintext.len() != expected_len { + return Err("decrypted chunk size differs from DataMap".into()); + } + Ok(Bytes::from(plaintext)) +} + +/// self_encryption's KDF: each chunk's key depends on its own source hash and +/// those of the two chunks before it, wrapping around at the start. +fn chunk_secrets( + index: usize, + src_hashes: &[XorName], + kdf_level: usize, +) -> Result { + let total = src_hashes.len(); + if total < MIN_FILE_CHUNKS || index >= total { + return Err(format!("chunk {index} is outside a map of {total} chunks")); + } + let (previous, before_previous) = match index { + 0 => (total - 1, total - 2), + 1 => (0, total - 1), + _ => (index - 1, index - 2), + }; + let mut input = Vec::with_capacity(KDF_INPUT_SIZE); + input.extend_from_slice(&src_hashes[index].0); + input.extend_from_slice(&src_hashes[previous].0); + input.extend_from_slice(&src_hashes[before_previous].0); + input.extend_from_slice(&(index as u64).to_le_bytes()); + input.extend_from_slice(&(kdf_level as u64).to_le_bytes()); + let mut output = [0u8; PAD_SIZE + KEY_SIZE + NONCE_SIZE]; + let mut hasher = blake3::Hasher::new_derive_key(KDF_CONTEXT); + hasher.update(&input); + hasher.finalize_xof().fill(&mut output); + let mut secrets = ChunkSecrets { + pad: [0; PAD_SIZE], + key: [0; KEY_SIZE], + nonce: [0; NONCE_SIZE], + }; + secrets.pad.copy_from_slice(&output[..PAD_SIZE]); + secrets + .key + .copy_from_slice(&output[PAD_SIZE..PAD_SIZE + KEY_SIZE]); + secrets + .nonce + .copy_from_slice(&output[PAD_SIZE + KEY_SIZE..]); + Ok(secrets) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_self_encryption_for_every_chunk() { + let content = Bytes::from((0..18_000).map(|i| (i * 37) as u8).collect::>()); + let (map, chunks) = self_encryption::encrypt(content).unwrap(); + let src_hashes: Vec<_> = map.infos().iter().map(|info| info.src_hash).collect(); + for (index, info) in map.infos().iter().enumerate() { + let chunk = chunks + .iter() + .find(|chunk| *blake3::hash(&chunk.content).as_bytes() == info.dst_hash.0) + .unwrap(); + let expected = + self_encryption::decrypt_chunk(index, &chunk.content, &src_hashes, 0).unwrap(); + let actual = + decrypt_chunk(index, &chunk.content, &src_hashes, 0, info.src_size).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn records_expanding_past_their_declared_size_are_rejected() { + // Highly compressible chunks expand far beyond a falsely small size. + let content = Bytes::from(vec![0u8; 3 * 1024 * 1024]); + let (map, chunks) = self_encryption::encrypt(content).unwrap(); + let src_hashes: Vec<_> = map.infos().iter().map(|info| info.src_hash).collect(); + let info = &map.infos()[0]; + let chunk = chunks + .iter() + .find(|chunk| *blake3::hash(&chunk.content).as_bytes() == info.dst_hash.0) + .unwrap(); + assert!(chunk.content.len() < info.src_size); + for declared in [0, 1, info.src_size - 1, info.src_size + 1] { + let error = decrypt_chunk(0, &chunk.content, &src_hashes, 0, declared).unwrap_err(); + assert!(error.contains("differs"), "{declared}: {error}"); + } + assert!(decrypt_chunk(0, &chunk.content, &src_hashes, 1, info.src_size).is_err()); + assert!(decrypt_chunk(3, &chunk.content, &src_hashes, 0, info.src_size).is_err()); + } +} diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 540d4e62..7895cfcf 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -2,12 +2,20 @@ //! filesystem or JS dependencies. Cryptography and recursive map semantics stay //! in self_encryption, the same implementation used by the native client. use bytes::Bytes; +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +use futures_util::future::{select, Either}; +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +use futures_util::stream::FuturesUnordered; use futures_util::StreamExt; use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range, time::Duration}; /// self_encryption splits every encryptable file into at least three chunks. pub(crate) const MIN_FILE_CHUNKS: usize = 3; +/// Largest plaintext chunk a DataMap may declare: four times the native chunk +/// size. Maps from other chunk-size schemes stay readable, while a hostile map +/// cannot make one chunk reserve an arbitrarily large buffer. +pub(crate) const MAX_CHUNK_PLAINTEXT_BYTES: usize = 16 * 1024 * 1024; /// Content chunks of a resolved root DataMap are encrypted at KDF level zero. const CONTENT_CHUNK_KDF_LEVEL: usize = 0; /// Seconds to wait before each deferred fetch round: the first pass, one @@ -228,8 +236,8 @@ pub(crate) struct FileIndex { } impl FileIndex { - /// Chunk sizes are taken from the map, so maps produced with another - /// self-encryption chunk size remain readable. + /// Chunk sizes are taken from the map, up to [`MAX_CHUNK_PLAINTEXT_BYTES`], + /// so maps produced with another self-encryption chunk size remain readable. pub(crate) fn new(map: &DataMap) -> Result { if map.is_child() { return Err("range reads require a resolved root DataMap".into()); @@ -247,7 +255,7 @@ impl FileIndex { if info.index != index { return Err("DataMap chunk indices must be contiguous".into()); } - if info.src_size == 0 { + if !(1..=MAX_CHUNK_PLAINTEXT_BYTES).contains(&info.src_size) { return Err("invalid DataMap plaintext chunk size".into()); } let end = offsets[index] @@ -286,43 +294,32 @@ impl FileIndex { } /// Decrypt one verified record and check it against the map's plaintext - /// size and hash. + /// size and hash. Decompression stops at the size the map declares. fn decrypt(&self, chunk: usize, encrypted: &Bytes) -> Result { - // The dependency's get_range uses usize file offsets. Its public - // per-chunk primitive has no whole-file arithmetic and retains the - // canonical KDF (original chunk index and neighbouring hashes). - let plaintext = self_encryption::decrypt_chunk( + let span = self.chunk_span(chunk); + // Per-chunk decryption avoids self_encryption's pointer-sized + // whole-file range arithmetic and keeps the canonical KDF (original + // chunk index and neighbouring hashes). + let plaintext = super::chunk_decrypt::decrypt_chunk( chunk, encrypted, &self.src_hashes, CONTENT_CHUNK_KDF_LEVEL, - ) - .map_err(|e| e.to_string())?; - let span = self.chunk_span(chunk); - if plaintext.len() as u64 != span.end - span.start { - return Err("decrypted chunk size differs from DataMap".into()); - } + // Bounded by MAX_CHUNK_PLAINTEXT_BYTES when the index was built. + (span.end - span.start) as usize, + )?; crate::record::verify(&self.src_hashes[chunk].0, &plaintext)?; Ok(plaintext) } } -/// Whole-file and windowed browser reads. +/// Browser readers and complete downloads. #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] impl FileIndex { pub(crate) fn chunk_count(&self) -> usize { self.dst_hashes.len() } - /// End of a chunk window that starts at `first`, holds at most `max_bytes` - /// of plaintext and stops at `limit`. A window always holds one chunk, - /// however large it is. - pub(crate) fn window_end(&self, first: usize, limit: usize, max_bytes: u64) -> usize { - let budget_end = self.offsets[first].saturating_add(max_bytes); - let fitting = self.offsets.partition_point(|offset| *offset <= budget_end) - 1; - fitting.clamp(first + 1, limit) - } - /// Encrypted record addresses in chunk order. pub(crate) fn record_addresses(&self) -> &[XorName] { &self.dst_hashes @@ -336,7 +333,7 @@ impl FileIndex { index, dst_hash: self.dst_hashes[index], src_hash: self.src_hashes[index], - // Each size was a usize in the DataMap this index came from. + // Bounded by MAX_CHUNK_PLAINTEXT_BYTES when the index was built. src_size: (span.end - span.start) as usize, } }) @@ -382,10 +379,128 @@ where .await } -/// Whole-file BLAKE3 over chunks that complete out of order. Chunks are -/// hashed in file order; one that completes ahead of that frontier is read -/// back from the caller's output when the frontier reaches it, so no -/// plaintext is retained here. +/// Fetch, verify and decrypt one chunk, retrying a missing record on the +/// deferred schedule independently of every other chunk. +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +pub(crate) async fn fetch_chunk( + index: &FileIndex, + chunk: usize, + fetch: &F, + sleep: &S, + retryable: fn(&E) -> bool, +) -> Result> +where + F: Fn([u8; 32]) -> Fut, + Fut: Future>, + S: Fn(Duration) -> SF, + SF: Future, +{ + let address = index.dst_hashes[chunk].0; + let mut last_error = None; + for delay in DEFERRED_ROUND_DELAYS_SECS { + if delay > 0 { + sleep(Duration::from_secs(delay)).await; + } + match fetch(address).await { + Ok(encrypted) => { + crate::record::verify(&address, &encrypted).map_err(ReadError::Invalid)?; + return index.decrypt(chunk, &encrypted).map_err(ReadError::Invalid); + } + Err(error) if !retryable(&error) => return Err(ReadError::Fetch(error)), + Err(error) => last_error = Some(error), + } + } + Err(ReadError::Fetch( + last_error.expect("the deferred schedule makes at least one attempt"), + )) +} + +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +enum PipelineEvent { + Written(Result<(), X>), + Fetched(usize, Result), +} + +/// Fetch `items` concurrently and hand each result to `write` in item order. +/// Items launch in order while fewer than `cap()` are in flight and the cost +/// of launched-but-unwritten items stays within `budget`; one item can always +/// run. Fetching continues while a write is pending, so slow items and their +/// retry waits overlap with every other item the budget admits. +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +pub(crate) async fn ordered_pipeline( + items: Range, + cost: impl Fn(usize) -> u64, + budget: u64, + cap: &C, + fetch: F, + mut write: W, +) -> Result<(), X> +where + F: Fn(usize) -> FF, + FF: Future>, + W: FnMut(usize, T) -> WF, + WF: Future>, + C: Fn() -> usize, +{ + let fetch = &fetch; + let mut in_flight = FuturesUnordered::new(); + let mut ready = HashMap::new(); + let mut writing = None; + let (mut next_launch, mut next_write) = (items.start, items.start); + let mut outstanding = 0u64; + while next_write < items.end { + while next_launch < items.end + && in_flight.len() < cap().max(1) + && (outstanding == 0 || outstanding.saturating_add(cost(next_launch)) <= budget) + { + let item = next_launch; + in_flight.push(async move { (item, fetch(item).await) }); + outstanding = outstanding.saturating_add(cost(item)); + next_launch += 1; + } + if writing.is_none() { + if let Some(value) = ready.remove(&next_write) { + writing = Some(Box::pin(write(next_write, value))); + } + } + // `outstanding` covers the next item to write, so with nothing being + // written that item is in flight. + let event = match writing.as_mut() { + Some(pending) if in_flight.is_empty() => PipelineEvent::Written(pending.await), + Some(pending) => match select(pending, in_flight.next()).await { + Either::Left((written, _)) => PipelineEvent::Written(written), + Either::Right((fetched, _)) => { + let (item, value) = fetched.expect("items are in flight"); + PipelineEvent::Fetched(item, value) + } + }, + None => { + let (item, value) = in_flight + .next() + .await + .expect("the next item to write is in flight"); + PipelineEvent::Fetched(item, value) + } + }; + match event { + PipelineEvent::Written(written) => { + written?; + writing = None; + outstanding -= cost(next_write); + next_write += 1; + } + PipelineEvent::Fetched(item, value) => { + ready.insert(item, value?); + } + } + } + Ok(()) +} + +/// Whole-file BLAKE3 over chunks that complete out of order. Chunks are hashed +/// in file order. One that completes ahead of that order is read back from the +/// caller's output later, one chunk at a time, so no plaintext is retained +/// here and the caller decides when that work runs. #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] pub(crate) struct OrderedHasher { hasher: blake3::Hasher, @@ -403,23 +518,28 @@ impl OrderedHasher { } } - /// Record `chunk` as complete and hash every chunk the frontier can now - /// pass. `read_back` returns the plaintext of an earlier-completed chunk. - pub(crate) fn complete( - &mut self, - chunk: usize, - plaintext: &[u8], - mut read_back: impl FnMut(usize) -> Result, String>, - ) -> Result<(), String> { + /// Record `chunk` as complete, hashing it now if it is next in file order. + pub(crate) fn complete(&mut self, chunk: usize, plaintext: &[u8]) { self.completed[chunk] = true; - while self.completed.get(self.next) == Some(&true) { - if self.next == chunk { - self.hasher.update(plaintext); - } else { - self.hasher.update(&read_back(self.next)?); - } + if chunk == self.next { + self.hasher.update(plaintext); self.next += 1; } + } + + /// The next chunk in file order, if it completed earlier and must now be + /// read back to be hashed. + pub(crate) fn pending_read_back(&self) -> Option { + (self.completed.get(self.next) == Some(&true)).then_some(self.next) + } + + /// Hash the read-back plaintext of [`Self::pending_read_back`]. + pub(crate) fn read_back(&mut self, chunk: usize, plaintext: &[u8]) -> Result<(), String> { + if self.pending_read_back() != Some(chunk) { + return Err(format!("chunk {chunk} is not waiting to be hashed")); + } + self.hasher.update(plaintext); + self.next += 1; Ok(()) } @@ -942,28 +1062,224 @@ mod tests { index.record_addresses(), infos.iter().map(|info| info.dst_hash).collect::>() ); + let mut infos = infos; + infos[1].src_size = MAX_CHUNK_PLAINTEXT_BYTES + 1; + assert!(FileIndex::new(&DataMap::new(infos)).is_err()); } - #[test] - fn windows_hold_whole_chunks_within_their_byte_budget() { - let size = self_encryption::MAX_CHUNK_SIZE; - let infos = (0..10) - .map(|index| self_encryption::ChunkInfo { - index, - src_size: size, - src_hash: XorName([1; 32]), - dst_hash: XorName([2; 32]), - }) - .collect(); - let index = FileIndex::new(&DataMap::new(infos)).unwrap(); - let chunk = size as u64; - assert_eq!(index.window_end(0, 10, 4 * chunk), 4); - assert_eq!(index.window_end(0, 10, 4 * chunk + 1), 4); - assert_eq!(index.window_end(2, 10, 4 * chunk - 1), 5); - // A window always makes progress, even past its budget. - assert_eq!(index.window_end(3, 10, 1), 4); - assert_eq!(index.window_end(8, 10, 4 * chunk), 10); - assert_eq!(index.window_end(0, 3, u64::MAX), 3); + #[tokio::test] + async fn pipeline_writes_in_order_within_its_budget() { + const ITEMS: usize = 6; + const COST: u64 = 10; + const BUDGET: u64 = 3 * COST; + let launched = std::cell::Cell::new(0u64); + let most_outstanding = std::cell::Cell::new(0u64); + let mut written = Vec::new(); + ordered_pipeline( + 0..ITEMS, + |_| COST, + BUDGET, + &|| ITEMS, + |item| { + launched.set(launched.get() + 1); + async move { + // Later items finish first. + for _ in item..ITEMS { + tokio::task::yield_now().await; + } + Ok::<_, String>(item) + } + }, + |item, value| { + assert_eq!(item, value); + let outstanding = (launched.get() - written.len() as u64) * COST; + most_outstanding.set(most_outstanding.get().max(outstanding)); + written.push(item); + async { Ok(()) } + }, + ) + .await + .unwrap(); + assert_eq!(written, (0..ITEMS).collect::>()); + assert_eq!(most_outstanding.get(), BUDGET); + } + + #[tokio::test] + async fn pipeline_keeps_fetching_while_a_write_is_pending() { + // One fetch at a time: the first write only finishes once the last + // item has been fetched, so fetching must continue behind it. + let (fetched_last, unblock) = tokio::sync::oneshot::channel(); + let fetched_last = std::cell::RefCell::new(Some(fetched_last)); + let unblock = std::cell::RefCell::new(Some(unblock)); + let mut written = Vec::new(); + let pipeline = ordered_pipeline( + 0..4, + |_| 1, + u64::MAX, + &|| 1, + |item| { + if item == 3 { + let _ = fetched_last.borrow_mut().take().unwrap().send(()); + } + async move { Ok::<_, String>(item) } + }, + |item, _| { + let unblock = (item == 0).then(|| unblock.borrow_mut().take().unwrap()); + written.push(item); + async move { + if let Some(unblock) = unblock { + unblock.await.unwrap(); + } + Ok(()) + } + }, + ); + tokio::time::timeout(Duration::from_secs(5), pipeline) + .await + .expect("fetching stalled behind a pending write") + .unwrap(); + assert_eq!(written, vec![0, 1, 2, 3]); + } + + #[tokio::test] + async fn a_slow_item_does_not_hold_back_fetches_behind_it() { + // The first item stays in flight (as during a retry wait) until the + // third has been fetched. + let (fetched_third, released) = tokio::sync::oneshot::channel(); + let fetched_third = std::cell::RefCell::new(Some(fetched_third)); + let released = std::cell::RefCell::new(Some(released)); + let completed = std::cell::RefCell::new(Vec::new()); + let pipeline = ordered_pipeline( + 0..3, + |_| 1, + 3, + &|| 3, + |item| { + let released = (item == 0).then(|| released.borrow_mut().take().unwrap()); + let fetched_third = (item == 2).then(|| fetched_third.borrow_mut().take().unwrap()); + let completed = &completed; + async move { + if let Some(released) = released { + released.await.unwrap(); + } + completed.borrow_mut().push(item); + if let Some(fetched_third) = fetched_third { + let _ = fetched_third.send(()); + } + Ok::<_, String>(item) + } + }, + |_, _| async { Ok(()) }, + ); + tokio::time::timeout(Duration::from_secs(5), pipeline) + .await + .expect("a slow item held back the items behind it") + .unwrap(); + assert_eq!(*completed.borrow(), vec![1, 2, 0]); + } + + #[tokio::test] + async fn pipeline_admits_an_oversized_item_and_stops_at_the_first_error() { + let mut written = Vec::new(); + ordered_pipeline( + 0..2, + |_| 100, + 10, + &|| 4, + |item| async move { Ok::<_, String>(item) }, + |item, _| { + written.push(item); + async { Ok(()) } + }, + ) + .await + .unwrap(); + assert_eq!(written, vec![0, 1]); + + let error = ordered_pipeline( + 0..4, + |_| 1, + 4, + &|| 4, + |item| async move { + if item == 2 { + Err(format!("fetch {item}")) + } else { + Ok(item) + } + }, + |_, _| async { Ok(()) }, + ) + .await + .unwrap_err(); + assert_eq!(error, "fetch 2"); + let error = ordered_pipeline( + 0..4, + |_| 1, + 4, + &|| 4, + |item| async move { Ok::<_, String>(item) }, + |item, _| async move { + if item == 1 { + Err(format!("write {item}")) + } else { + Ok(()) + } + }, + ) + .await + .unwrap_err(); + assert_eq!(error, "write 1"); + } + + #[tokio::test] + async fn a_missing_chunk_is_retried_on_its_own_schedule() { + let (content, map, records) = fixture(); + let index = FileIndex::new(&map).unwrap(); + let attempts = std::cell::Cell::new(0); + let sleeps = std::cell::RefCell::new(Vec::new()); + let fetch_after = |available_from: usize| { + let (attempts, records) = (&attempts, &records); + move |address: [u8; 32]| { + attempts.set(attempts.get() + 1); + let found = attempts.get() >= available_from; + let bytes = records[&address].clone(); + async move { + if found { + Ok(bytes) + } else { + Err("missing".to_string()) + } + } + } + }; + let sleep = |delay: Duration| { + sleeps.borrow_mut().push(delay.as_secs()); + async {} + }; + let plaintext = fetch_chunk(&index, 1, &fetch_after(4), &sleep, |_| true) + .await + .unwrap(); + let span = index.chunk_span(1); + assert_eq!( + plaintext, + content.slice(span.start as usize..span.end as usize) + ); + assert_eq!(*sleeps.borrow(), vec![15, 45]); + + attempts.set(0); + let error = fetch_chunk(&index, 1, &fetch_after(5), &sleep, |_| true) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Fetch(message) if message == "missing")); + assert_eq!(attempts.get(), DEFERRED_ROUND_DELAYS_SECS.len()); + + attempts.set(0); + let error = fetch_chunk(&index, 1, &fetch_after(5), &sleep, |_| false) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Fetch(_))); + assert_eq!(attempts.get(), 1); } #[tokio::test] @@ -1047,21 +1363,26 @@ mod tests { ] { let mut hasher = OrderedHasher::new(chunks.len()); let mut read_backs = Vec::new(); + let mut read_back = |hasher: &mut OrderedHasher| { + let chunk = hasher.pending_read_back()?; + read_backs.push(chunk); + hasher.read_back(chunk, &chunks[chunk]).unwrap(); + Some(()) + }; + // As in a download: at most one read-back per completion, then + // the rest once every chunk has arrived. for chunk in order { - hasher - .complete(chunk, &chunks[chunk], |earlier| { - read_backs.push(earlier); - Ok(chunks[earlier].clone()) - }) - .unwrap(); + hasher.complete(chunk, &chunks[chunk]); + read_back(&mut hasher); } + while read_back(&mut hasher).is_some() {} assert_eq!(hasher.finish().unwrap(), expected); assert_eq!(read_backs, expected_read_backs); } let mut incomplete = OrderedHasher::new(chunks.len()); - incomplete - .complete(1, &chunks[1], |_| unreachable!("chunk 0 is missing")) - .unwrap(); + incomplete.complete(1, &chunks[1]); + assert_eq!(incomplete.pending_read_back(), None); + assert!(incomplete.read_back(1, &chunks[1]).is_err()); assert!(incomplete.finish().is_err()); } diff --git a/ant-core/src/data/client/cache.rs b/ant-core/src/data/client/cache.rs index 893d6c3d..9840fdf1 100644 --- a/ant-core/src/data/client/cache.rs +++ b/ant-core/src/data/client/cache.rs @@ -81,6 +81,21 @@ impl ChunkCache { cache.pop(address); } + /// Remove every cached chunk whose address matches. Visits only the + /// cached entries, however many addresses `matches` recognises. + #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] + pub(crate) fn remove_matching(&self, mut matches: impl FnMut(&XorName) -> bool) { + let mut cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); + let doomed: Vec = cache + .iter() + .map(|(address, _)| *address) + .filter(|address| matches(address)) + .collect(); + for address in doomed { + cache.pop(&address); + } + } + /// Clear all cached chunks. pub fn clear(&self) { let mut cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); @@ -212,4 +227,16 @@ mod tests { assert_eq!(cache.len(), 1); assert_eq!(cache.get(&addr), Some(Bytes::from_static(b"second"))); } + + #[test] + fn remove_matching_keeps_other_entries() { + let cache = ChunkCache::new(4); + for byte in 0..3u8 { + cache.put([byte; 32], Bytes::from(vec![byte])); + } + cache.remove_matching(|address| address[0] != 1); + assert!(cache.get(&[0; 32]).is_none()); + assert!(cache.get(&[1; 32]).is_some()); + assert!(cache.get(&[2; 32]).is_none()); + } } diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index 695e4e90..0f41529a 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -456,6 +456,24 @@ impl Client { .map_err(map_read_error) } + /// Fetch and decrypt one chunk, retrying it on its own deferred schedule. + #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] + pub(crate) async fn data_download_indexed_chunk( + &self, + index: &crate::client_engine::files::FileIndex, + chunk: usize, + ) -> Result { + crate::client_engine::files::fetch_chunk( + index, + chunk, + &|address| self.fetch_data_record(address), + &crate::runtime::sleep, + retry_data_fetch, + ) + .await + .map_err(map_read_error) + } + /// Fetch and decrypt whole chunks in one deferred retry pass, handing each /// plaintext chunk to `sink` in completion order. #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index b0a40a48..4e26bdb4 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -66,10 +66,12 @@ checks media ranges across chunk boundaries and EOF. `large-download.test.mjs` streams a native-encrypted 4,303,347,835-byte file through the real WASM decryptor and checks its complete BLAKE3 without retaining the plaintext. It also covers public/private seeks across 4 GiB, actual disk -writes, write backpressure, cancellation by `AbortSignal` and by closing the -reader (including during retry waits), destinations aborted for invalid calls, -a reader closed from the final progress callback, writer errors, JS allocation -failures, memory budgets, download options and progress, and invalid offsets. The compressed fixture is about 250 KiB; +writes, fetching bounded by the `pipeTo` buffer behind a blocked write, +cancellation of `pipeTo`, range reads and complete downloads by `AbortSignal` +and by closing the reader (including during retry waits), destinations aborted +for invalid calls, a reader closed from the final progress callback, writer +errors, JS allocation failures, memory budgets, download options (including the +legacy numeric form and optional typings) and progress, and invalid offsets. The compressed fixture is about 250 KiB; regenerate it from the repository root with `cargo run -p ant-core --release --example generate-browser-large-file`. Generation processes the entire plaintext using bounded buffers. The full-stream diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index 0020985b..f5d5c899 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -21,6 +21,19 @@ function fixture(source = records) { return { rtc, client: new BrowserNetworkClient(rtc.endpoints) }; } +const gets = rtc => rtc.requests.filter(r => r.method === "get_chunk").length; + +// Distinct, incompressible chunks, so every chunk is its own network GET. +function randomBytes(length) { + const bytes = new Uint8Array(length); + let state = 0x9e3779b9; + for (let i = 0; i < length; i++) { + state ^= state << 13; state ^= state >>> 17; state ^= state << 5; + bytes[i] = state; + } + return bytes; +} + test("native files larger than 4 GiB support exact public and private seeks", async () => { const { rtc, client } = fixture(); try { @@ -37,13 +50,13 @@ test("native files larger than 4 GiB support exact public and private seeks", as } assert.equal((await reader.readRange(large.size, 1)).length, 0); assert.equal((await reader.readRange(Number.MAX_SAFE_INTEGER, 1)).length, 0); - const gets = rtc.requests.filter(r => r.method === "get_chunk").length; + const before = gets(rtc); for (const value of [-1, 0.5, NaN, Infinity, 2 ** 53]) { await assert.rejects(reader.readRange(value, 1), /safe integer/); await assert.rejects(reader.readRange(0, value), /safe integer/); } await assert.rejects(reader.readRange(0, 2 ** 32 + 1), /range reads are limited/); - assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, gets); + assert.equal(gets(rtc), before); } finally { reader.close(); } } } finally { client.close(); } @@ -75,59 +88,123 @@ test("a complete native >4 GiB file streams with bounded writes and matching BLA }); test("large-file memory budgets fail before content fetch, and allocation errors permit streaming", async () => { + // A budget rejection makes exactly the requests that resolving the file does. + const opened = fixture(); + try { (await opened.client.openPublicFile(large.address)).close(); } finally { opened.client.close(); } + const maxMemoryBytes = 64 * 1024 * 1024; + for (const download of [ + client => client.downloadPublicFile(large.address, { maxMemoryBytes }), + client => client.downloadPrivateFile({ data_map: map.content }, { maxMemoryBytes }), + ]) { + const { rtc, client } = fixture(); + try { + await assert.rejects(download(client), /memory budget.*pipeTo/); + assert(gets(rtc) <= gets(opened.rtc), `${gets(rtc)} GETs for a budget rejection`); + } finally { client.close(); } + } + const { rtc, client } = fixture(); try { - const maxMemoryBytes = 64 * 1024 * 1024; - await assert.rejects(client.downloadPublicFile(large.address, { maxMemoryBytes }), /memory budget.*pipeTo/); - await assert.rejects(client.downloadPrivateFile({ data_map: map.content }, { maxMemoryBytes }), /memory budget.*pipeTo/); - const gets = rtc.requests.filter(r => r.method === "get_chunk").length; for (const value of [-1, 1.5, Infinity, NaN, 2 ** 53]) { await assert.rejects(client.downloadPublicFile(large.address, { maxMemoryBytes: value }), /safe integer/); } for (const concurrency of [0, -1, 1.5, NaN]) { await assert.rejects(client.downloadPublicFile(large.address, { concurrency }), /positive integer/); + } + // The legacy numeric argument keeps wasm-bindgen's `>>> 0` coercion. + for (const concurrency of [0, NaN, "none", 2 ** 32]) { await assert.rejects(client.downloadPublicFile(large.address, concurrency), /positive integer/); } await assert.rejects(client.downloadPublicFile(large.address, { maxMemory: 1 }), /unknown option `maxMemory`/); await assert.rejects(client.downloadPublicFile(large.address, { onProgress: 1 }), /onProgress must be a function/); await assert.rejects(client.downloadPublicFile(large.address, { onProgress() {} }, () => {}), /not both/); - assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, gets, "invalid options fail before any fetch"); + await assert.rejects(client.downloadPublicFile(large.address, { signal: {} }), /AbortSignal/); + assert.equal(gets(rtc), 0, "invalid options fail before any fetch"); } finally { client.close(); } }); -test("pipeTo writes ranges beyond 4 GiB to disk and honors write backpressure", async () => { - const { rtc, client } = fixture(); +test("download options stay optional in the generated typings", async () => { + const typings = await readFile(new URL("./pkg/ant_core.d.ts", import.meta.url), "utf8"); + for (const name of ["downloadPublicFile", "downloadPrivateFile"]) { + assert.match(typings, new RegExp(`${name}\\(file: any, options\\?: any`)); + } +}); + +test("an AbortSignal cancels a complete download, including during retry waits", async () => { + const original = new TextEncoder().encode("Download cancellation.".repeat(300)); + const encrypted = encryptPublicFile(original); + const missing = encrypted.records.find(record => record.address !== encrypted.address); + const { rtc, client } = fixture(encrypted.records.filter(record => record !== missing)); + try { + const reason = new Error("stop downloading"); + await assert.rejects(client.downloadPublicFile(encrypted.address, { signal: AbortSignal.abort(reason) }), + error => error === reason); + const controller = new AbortController(); + const started = Date.now(); + const pending = client.downloadPublicFile(encrypted.address, { signal: controller.signal }); + setTimeout(() => controller.abort(reason), 100); + await assert.rejects(pending, error => error === reason); + assert(Date.now() - started < 5_000, `abort took ${Date.now() - started} ms`); + rtc.stores[0].set(missing.address, missing.content); + const legacy = await client.downloadPublicFile(encrypted.address, 1.5); + assert.deepEqual(legacy.content, original); + assert.deepEqual((await client.downloadPublicFile(encrypted.address, "2")).content, original); + } finally { client.close(); } +}); + +test("pipeTo writes ranges beyond 4 GiB to disk", async () => { + const { client } = fixture(); const directory = await mkdtemp(join(tmpdir(), "ant-wasm-download-")); let reader; try { reader = await client.openPublicFile(large.address); + const path = join(directory, "tail.bin"); + const sink = Writable.toWeb(createWriteStream(path)); + const start = 2 ** 32 - 8; + const result = await reader.pipeTo(sink, { start }); + assert.equal(result.bytesWritten, large.size - start); + assert.deepEqual(await readFile(path), Buffer.alloc(large.size - start, large.byte)); + assert.equal(sink.locked, false); + } finally { reader?.close(); client.close(); await rm(directory, { recursive: true, force: true }); } +}); + +test("pipeTo keeps fetching behind a blocked write only up to its 32 MiB buffer", async () => { + // Twelve distinct chunks: about 50 MB, more than one buffer. + const original = randomBytes(12 * 4_190_208); + const encrypted = encryptPublicFile(original); + const { rtc, client } = fixture(encrypted.records); + let reader; + try { + reader = await client.openPublicFile(encrypted.address); let unblock, firstWrite; const entered = new Promise(resolve => { firstWrite = resolve; }); const gate = new Promise(resolve => { unblock = resolve; }); - const path = join(directory, "tail.bin"); - const disk = Writable.toWeb(createWriteStream(path)); - const writer = disk.getWriter(); + const chunks = []; let writes = 0; const sink = new WritableStream({ async write(bytes) { if (++writes === 1) { firstWrite(); await gate; } - await writer.write(bytes); + chunks.push(bytes); }, - async close() { await writer.close(); writer.releaseLock(); }, - async abort(error) { await writer.abort(error); writer.releaseLock(); }, }); - const start = 2 ** 32 - 8; - const completion = reader.pipeTo(sink, { start }); + const before = gets(rtc); + const completion = reader.pipeTo(sink); await entered; - const gets = rtc.requests.filter(r => r.method === "get_chunk").length; - await new Promise(resolve => setTimeout(resolve, 30)); - assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, gets); + // Let fetching run as far as the buffer allows, then check that it stopped. + let settled = gets(rtc); + for (let previous = -1; previous !== settled; settled = gets(rtc)) { + previous = settled; + await new Promise(resolve => setTimeout(resolve, 50)); + } + const buffered = settled - before; + assert(buffered > 1, "fetching continues behind a pending write"); + assert(buffered <= Math.floor(32 * 1024 * 1024 / 4_190_208), `${buffered} chunks fetched behind one blocked write`); unblock(); const result = await completion; - assert.equal(result.bytesWritten, large.size - start); - assert.deepEqual(await readFile(path), Buffer.alloc(large.size - start, large.byte)); - assert.equal(sink.locked, false); - } finally { reader?.close(); client.close(); await rm(directory, { recursive: true, force: true }); } + assert.equal(result.bytesWritten, original.length); + assert.deepEqual(Buffer.concat(chunks), Buffer.from(original)); + assert(chunks.every(chunk => chunk.length <= 4 * 1024 * 1024)); + } finally { reader?.close(); client.close(); } }); test("invalid pipeTo calls abort the destination they were given", async () => { diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 4a4ad5e7..067d588d 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -668,14 +668,22 @@ Manifest validation no longer rejects files over 1 GB or over 1,024 chunks. It caps a file's chunk list at 262,144 entries (about 1 TiB at the native chunk size), which bounds the work an untrusted manifest can cause. Chunk sizes come from the DataMap rather than this build's `MAX_CHUNK_SIZE`, so maps produced -with another self-encryption chunk size remain readable. +with another self-encryption chunk size remain readable, up to 16 MiB per chunk. A reader retains a validated root-map index and uses binary searches to identify -overlapping chunks. Decryption delegates to `self_encryption::decrypt_chunk` -with the original indices and neighbouring source hashes. This avoids the -dependency's pointer-sized whole-file range arithmetic without changing its KDF, -DataMap representation or stored ciphertext. Encrypted records and reconstructed -plaintext chunks are verified against their respective hashes and sizes. +overlapping chunks. Chunks are decrypted one at a time with the original +indices and neighbouring source hashes, which avoids the dependency's +pointer-sized whole-file range arithmetic without changing its KDF, DataMap +representation or stored ciphertext. `self_encryption::decrypt_chunk` +decompresses without an output limit, so a crafted record could expand to +gigabytes before its declared size is checked and abort the WASM instance. +`client_engine/chunk_decrypt.rs` therefore performs the same steps (BLAKE3 KDF, +XOR pad, ChaCha20-Poly1305, Brotli) and stops decompressing one byte past the +declared size. It uses the dependency's own `chacha20poly1305` and +`brotli-decompressor` versions, and a test pins its output to `decrypt_chunk` +so a change to the dependency's KDF fails it; a bounded primitive upstream +would replace it. Encrypted records and reconstructed plaintext chunks are +verified against their respective hashes and sizes. Reads are chunk-aligned. A shared engine primitive fetches a set of chunks in one deferred retry pass and hands each decrypted chunk on as it completes, so a @@ -687,9 +695,12 @@ every chunk in a single pass at the requested concurrency, and copy each chunk into the output as it is decrypted. WASM holds only the records in flight. The whole-file BLAKE3 is computed in file order; a chunk that completes ahead of that order is read back from the output when its turn comes. Progress is still -reported as `Downloaded chunk n/total`. Options are passed as -`{ concurrency?, maxMemoryBytes?, onProgress? }`, and the earlier -`(file, concurrency?, onProgress?)` form remains supported. `maxMemoryBytes` +reported as `Downloaded chunk n/total`; chunks that complete ahead of file +order are hashed between event-loop turns rather than in one blocking step. +Options are passed as `{ concurrency?, maxMemoryBytes?, onProgress?, signal? }`. +An `AbortSignal` cancels resolution and transfer alike. The earlier +`(file, concurrency?, onProgress?)` form remains supported, coercing the +concurrency number as wasm-bindgen did. `maxMemoryBytes` limits the output allocation. Allocation failure rejects before file-content transfer and directs the caller to streaming. Automatic free-memory detection and implicit disk spill are not portable browser capabilities; applications own @@ -697,15 +708,21 @@ the memory budget and selection of a storage destination. `BrowserFileReader.pipeTo(writable, { start?, end?, signal?, onProgress? })` streams the whole file or a half-open range to a caller-owned `WritableStream`. -It fetches windows of whole chunks holding up to 32 MiB of plaintext, then -writes them in order, at most 4 MiB per write, and waits for every write before -fetching the next window. Retry waits overlap within a window; holding more -than one window would trade that bound on memory for overlap across windows. +An ordered pipeline fetches chunks concurrently and writes them in file order, +at most 4 MiB per write, each awaited before the next. Chunks are launched while +those in flight or awaiting their turn total at most 32 MiB, so fetching +continues behind a pending write but a slow destination still bounds memory. +Each missing record is retried on its own deferred schedule while the rest of +the buffer keeps fetching, so retry waits overlap rather than adding up per +batch. Only a record that stays missing longer than the buffer takes to fill +holds the stream back. It computes BLAKE3 incrementally, closes the destination on success, aborts it on any failure including invalid options, and releases the writer lock. An `AbortSignal` cancels one operation without closing the reader. Closing the reader cancels its operations at once, including fetches waiting to be retried, -and evicts only that reader's records from the client's shared cache. The core +and evicts only that reader's records from the client's shared cache. +Cancellation is checked before an operation is resumed, so a cancelled +operation never completes or refills the cache afterwards. The core does not open file pickers, own browser filesystem policy, or change the companion SDK. @@ -732,6 +749,7 @@ through generated WASM with a matching BLAKE3; public/private seeks beyond 4 GiB disk output; backpressure; cancellation by `AbortSignal`, by closing the reader and during retry waits; writer errors; destinations aborted for invalid calls; allocation failures; invalid offsets and options; manifest validation; and -shared native range, single-pass retry and ordered-hash tests. The generated +shared native range, single-pass retry, ordered-pipeline, ordered-hash, bounded +decryption and decompression-bomb tests. The generated WASM tests use mocked WebRTC hosts with real Rust protocol and crypto processing. They do not establish real-browser or live-network compatibility by themselves. From 6ab4933e5a8f43933229f8f302c54b0927f5ab69 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:59:59 +0200 Subject: [PATCH 05/15] fix(wasm): bound nested DataMap resolution and settle pipeTo on cancel Nested DataMap records were still decrypted by self_encryption without an output limit, so a crafted public file could exhaust WASM memory while resolving. Resolution now decrypts each level through the bounded per-chunk path, and browsers cap each decoded level at 64 MiB. The 16 MiB chunk cap applies to browser reads only, so native range reads accept any chunk size again. - Do not await the destination's abort, so a stalled write cannot keep pipeTo from rejecting; let an AbortSignal cancel while the destination closes, but not closing the reader once every byte is written. - Count open readers per file: the last close evicts the file's content and DataMap records, looked up by address instead of scanning. - Content records are authenticated by decryption and their plaintext hash, so the engine no longer hashes them a second time; the per-chunk fetch reuses the shared deferred retry rounds. - Decrypt in place, and yield read-back hashing on a time budget. - Correct the docs' decompression claims. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 +- ant-core/src/browser.rs | 11 + ant-core/src/browser/manifest.rs | 9 +- ant-core/src/browser/wasm_transport.rs | 56 +++- .../src/browser/wasm_transport/download.rs | 114 ++++++- ant-core/src/client_engine/chunk_decrypt.rs | 9 +- ant-core/src/client_engine/files.rs | 309 ++++++++++++------ ant-core/src/data/client/data.rs | 3 +- ant-core/src/data/client/file/native.rs | 2 + ant-core/wasm-tests/README.md | 4 +- ant-core/wasm-tests/large-download.test.mjs | 59 ++++ .../ADR-0004-direct-browser-read-client.md | 42 ++- 12 files changed, 475 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 15687c2a..48b83cf7 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ use 64-bit arithmetic internally; the JavaScript API accepts exact, nonnegative `number` positions through `Number.MAX_SAFE_INTEGER` (just under 8 PiB). The resolved DataMap and seek index still occupy memory proportional to the number of chunks. Individual chunk buffers remain bounded, including for seeks past -4 GiB: a DataMap may declare chunks of up to 16 MiB, and decompression stops at -the declared size, so a crafted record cannot expand inside WASM. Uploads +4 GiB. In browsers a DataMap may declare chunks of up to 16 MiB and nested +DataMap levels of up to 64 MiB, and every record, including nested DataMap +records, is decompressed only up to its declared size, so a crafted record +cannot expand inside WASM. Native range reads accept any chunk size. Uploads retain their existing size limits. `downloadPublicFile(file, { concurrency?, maxMemoryBytes?, onProgress?, signal? })` @@ -79,11 +81,15 @@ awaited before the next. Fetching continues while a write is pending until at that bound. Each missing record is retried on its own schedule while the rest of the buffer keeps fetching, so retry waits overlap. It closes the destination on success, aborts it on any failure (including invalid options), -and releases the writer lock. Its result contains the byte count and BLAKE3 of -the written range. An `AbortSignal` cancels that one operation and leaves the -reader open. `reader.close()` cancels the reader's operations at once, including -fetches waiting to be retried, and evicts only that reader's records from the -client's shared cache. `readRange(start, length)` remains available for media +and releases the writer lock; a failure does not wait for a stalled destination +to finish aborting. Its result contains the byte count and BLAKE3 of the written +range. An `AbortSignal` cancels that one operation and leaves the reader open. +`reader.close()` cancels the reader's operations at once, including fetches +waiting to be retried, until every byte is written. After that only the signal +can cancel, and a destination that has started closing may already be +committed. Once no other reader of the same file is open, closing also evicts +the file's records, including the DataMap records cached while resolving it, +from the client's shared cache. `readRange(start, length)` remains available for media and custom streaming sinks, with a 4 MiB per-call limit. A sink must persist or consume the bytes instead of accumulating them to keep memory bounded. The application chooses a memory budget and supplies its disk destination; the core diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index 39732dde..fff38c15 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -39,6 +39,17 @@ use std::collections::HashSet; /// while `pipeTo` and range reads hold a bounded amount of plaintext. pub const MAX_BROWSER_FILE_BYTES: usize = 1_000_000_000; +/// Largest plaintext chunk a browser read accepts from a DataMap: four times +/// the native chunk size. Maps from other chunk-size schemes stay readable, +/// while a hostile map cannot make one chunk reserve an arbitrarily large +/// buffer in WASM memory. +pub(crate) const MAX_BROWSER_CHUNK_BYTES: usize = 16 * 1024 * 1024; + +/// Largest decoded nested DataMap level a browser read resolves. A root map +/// with 262,144 chunks (about 1 TiB) encodes to roughly 21 MB. +#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] +pub(crate) const MAX_BROWSER_NESTED_MAP_BYTES: usize = 64 * 1024 * 1024; + /// One native self-encryption chunk descriptor exposed to the browser. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BrowserChunkInfo { diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs index 601ecf52..3383f171 100644 --- a/ant-core/src/browser/manifest.rs +++ b/ant-core/src/browser/manifest.rs @@ -3,7 +3,8 @@ pub use super::protocol::BrowserPaymentNetwork; use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint}; use super::BrowserChunkInfo; -use crate::client_engine::files::{MAX_CHUNK_PLAINTEXT_BYTES, MIN_FILE_CHUNKS}; +use super::MAX_BROWSER_CHUNK_BYTES; +use crate::client_engine::files::MIN_FILE_CHUNKS; use serde::{Deserialize, Serialize}; /// Current browser testnet manifest version. @@ -185,7 +186,7 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?; chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?; // Sizes come from the DataMap, which may use another chunk size. - if !(1..=MAX_CHUNK_PLAINTEXT_BYTES).contains(&chunk.src_size) { + if !(1..=MAX_BROWSER_CHUNK_BYTES).contains(&chunk.src_size) { return Err(BrowserManifestError(format!( "invalid plaintext chunk size {}", chunk.src_size @@ -341,8 +342,8 @@ mod tests { .collect(), }; normalize_file(&mut file).unwrap(); - file.chunks[1].src_size = MAX_CHUNK_PLAINTEXT_BYTES + 1; - file.size += (MAX_CHUNK_PLAINTEXT_BYTES + 1 - large_chunk) as u64; + file.chunks[1].src_size = MAX_BROWSER_CHUNK_BYTES + 1; + file.size += (MAX_BROWSER_CHUNK_BYTES + 1 - large_chunk) as u64; let error = normalize_file(&mut file).unwrap_err(); assert!(error.to_string().contains("chunk size")); diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index 32397253..ffeb08b2 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2128,6 +2128,8 @@ struct ResolvedBrowserFile { file: PublicFileDescriptor, data_map_node: Option, index: crate::client_engine::files::FileIndex, + /// DataMap records fetched, and so cached, while resolving. + map_records: Vec<[u8; 32]>, } struct UploadRecord { @@ -2337,6 +2339,7 @@ pub struct BrowserNetworkClient { shared: Rc, adapter: Rc, read_ahead: Rc, + open_files: download::OpenFiles, } #[wasm_bindgen(js_class = BrowserNetworkClient)] @@ -2370,6 +2373,7 @@ impl BrowserNetworkClient { shared, adapter, read_ahead, + open_files: download::OpenFiles::default(), }) } @@ -2679,8 +2683,8 @@ impl BrowserNetworkClient { )); Ok(BrowserFileReader::new( Rc::clone(&self.shared), - resolved.file, - resolved.index, + resolved, + &self.open_files, )) } @@ -2699,7 +2703,8 @@ impl BrowserNetworkClient { .map_err(|error| JsValue::from_str(&error)) }) .await?; - let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved.file, resolved.index); + let data_map_node = resolved.data_map_node.clone(); + let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved, &self.open_files); let (content, hash) = reader.collect(&settings, &mut cancel).await?; let file = reader.into_descriptor(hash.clone()); settings @@ -2709,7 +2714,7 @@ impl BrowserNetworkClient { content, hash, file, - data_map_node: resolved.data_map_node, + data_map_node, }) } @@ -2750,6 +2755,9 @@ impl BrowserNetworkClient { let mut resolved = self .resolve_data_map(&encoded_data_map, descriptor, progress) .await?; + resolved + .map_records + .push(parse_lookup_key(&address, "record address")?); resolved.file.address = address; resolved.data_map_node = Some(data_map_node); Ok(resolved) @@ -2795,18 +2803,24 @@ impl BrowserNetworkClient { progress: &ProgressReporter, ) -> Result { let published_data_map = crate::client_engine::files::decode_map(encoded_data_map)?; + let map_records = RefCell::new(Vec::new()); let root_data_map = crate::client_engine::files::resolve( &published_data_map, - &|address| async move { - progress.report(&format!( - "Resolving nested DataMap record {}", - hex::encode(address) - )); - self.get_shared_chunk(&hex::encode(address), progress) - .await - .map(|(content, _)| bytes::Bytes::from(content)) + &|address| { + let map_records = &map_records; + async move { + progress.report(&format!( + "Resolving nested DataMap record {}", + hex::encode(address) + )); + map_records.borrow_mut().push(address); + self.get_shared_chunk(&hex::encode(address), progress) + .await + .map(|(content, _)| bytes::Bytes::from(content)) + } }, &|| self.shared.controller().fetch.current(), + super::MAX_BROWSER_NESTED_MAP_BYTES, ) .await .map_err(|error| error.to_string())?; @@ -2815,6 +2829,12 @@ impl BrowserNetworkClient { if index.size() > super::manifest::MAX_SAFE_JS_INTEGER { return Err("file size exceeds JavaScript's exact integer range".into()); } + if index.largest_chunk() > super::MAX_BROWSER_CHUNK_BYTES as u64 { + return Err(format!( + "DataMap chunks larger than {} bytes are not read in browsers", + super::MAX_BROWSER_CHUNK_BYTES + )); + } file.size = index.size(); file.chunks = Vec::new(); // Listed from the index once the file is read. @@ -2825,6 +2845,7 @@ impl BrowserNetworkClient { file, data_map_node: None, index, + map_records: map_records.into_inner(), }) } @@ -2914,9 +2935,14 @@ impl BrowserNetworkClient { }; let encoded_map = fetch(parse_lookup_key(&staged.address, "DataMap address")?).await?; let map = crate::client_engine::files::decode_map(&encoded_map)?; - let root = crate::client_engine::files::resolve(&map, &fetch, &|| 1) - .await - .map_err(|error| error.to_string())?; + let root = crate::client_engine::files::resolve( + &map, + &fetch, + &|| 1, + super::MAX_BROWSER_NESTED_MAP_BYTES, + ) + .await + .map_err(|error| error.to_string())?; staged.chunks = super::chunk_infos(&root); staged.size = staged.chunks.iter().try_fold(0usize, |size, chunk| { size.checked_add(chunk.src_size).ok_or("file size overflow") diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index f9504e9c..25174e42 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -4,6 +4,7 @@ use crate::browser::manifest::MAX_SAFE_JS_INTEGER; use crate::client_engine::files::{ordered_pipeline, FileIndex, OrderedHasher}; use bytes::Bytes; use std::ops::Range; +use web_time::Instant; /// Plaintext a `pipeTo` holds at once: chunks in flight or fetched but not yet /// written. Fetching continues while writes are pending until this is reached. @@ -18,6 +19,9 @@ const PIPE_OPTION_NAMES: [&str; 4] = ["start", "end", "signal", "onProgress"]; const DOWNLOAD_OPTION_NAMES: [&str; 4] = ["concurrency", "maxMemoryBytes", "onProgress", "signal"]; /// JavaScript's ToUint32 wraps numbers modulo 2^32. const UINT32_RANGE: f64 = u32::MAX as f64 + 1.0; +/// Longest stretch of read-back hashing between event-loop turns. Yielding +/// after every chunk would pay the browser's nested-timer clamp each time. +const HASH_SLICE: Duration = Duration::from_millis(20); /// A nonnegative integer that JavaScript represents exactly. fn safe_integer(value: f64, label: &str) -> Result { @@ -95,6 +99,51 @@ async fn yield_to_event_loop() { TimeoutFuture::new(0).await; } +/// Open readers per file key, so closing one reader keeps the cached records +/// another reader of the same file is still using. +pub(super) type OpenFiles = Rc>>; + +/// One reader's place in [`OpenFiles`], released on close or drop. +struct OpenFile { + files: OpenFiles, + key: String, + open: Cell, +} + +impl OpenFile { + fn register(files: &OpenFiles, key: String) -> Self { + *files.borrow_mut().entry(key.clone()).or_default() += 1; + Self { + files: Rc::clone(files), + key, + open: Cell::new(true), + } + } + + /// Release this reader's place. True when it was the file's last open reader. + fn release(&self) -> bool { + if !self.open.replace(false) { + return false; + } + let mut files = self.files.borrow_mut(); + let Some(count) = files.get_mut(&self.key) else { + return true; + }; + *count -= 1; + if *count > 0 { + return false; + } + files.remove(&self.key); + true + } +} + +impl Drop for OpenFile { + fn drop(&mut self) { + self.release(); + } +} + /// Release the writer even if the Rust future is dropped before it completes. struct Writer(JsValue); impl Drop for Writer { @@ -275,6 +324,11 @@ impl Cancellation { Ok(Self { closed, abort }) } + /// Stop following the reader; only the AbortSignal can cancel from here. + fn ignore_reader(&mut self) { + self.closed = None; + } + /// Fail with the abort reason, or the closed-reader error. fn check(&self) -> Result<(), JsValue> { if let Some(reason) = self.abort.as_ref().and_then(AbortListener::reason) { @@ -336,6 +390,9 @@ pub struct BrowserFileReader { /// Descriptor without its chunk list; the index holds the chunk metadata. file: PublicFileDescriptor, index: FileIndex, + /// DataMap records cached while the file was resolved. + map_records: Vec<[u8; 32]>, + open_file: OpenFile, closed: watch::Sender, } @@ -392,7 +449,9 @@ impl BrowserFileReader { /// is retried on its own schedule while the others continue. Closes the /// destination on success, aborts it on any failure (including invalid /// options), and always releases the writer lock. Returns the BLAKE3 and - /// byte count of the written range. Closing the reader also cancels. + /// byte count of the written range. Closing the reader also cancels until + /// every byte is written; after that only the AbortSignal can, and a + /// destination that has started closing may already be committed. #[wasm_bindgen(js_name = pipeTo)] pub async fn pipe_to( &self, @@ -406,35 +465,43 @@ impl BrowserFileReader { if let Err(error) = &result { if let Ok(abort) = method(&writer.0, "abort") { if let Ok(promise) = abort.call1(&writer.0, error) { - let _ = JsFuture::from(Promise::resolve(&promise)).await; + // Not awaited: an abort settles only once a stalled write + // does, and the rejection must not wait for that. + let settled = JsFuture::from(Promise::resolve(&promise)); + wasm_bindgen_futures::spawn_local(async move { + let _ = settled.await; + }); } } } result } - /// Close the reader: cancel its operations and drop its records from the - /// client's shared cache, leaving other readers' records in place. Use an - /// AbortSignal to cancel a single pipeTo without closing the reader. + /// Close the reader: cancel its operations and, once no other reader of + /// the same file is open, drop the file's records from the client's shared + /// cache. Use an AbortSignal to cancel a single pipeTo without closing. pub fn close(&self) { self.closed.send_replace(true); - let records = self.index.record_addresses(); - self.shared - .chunk_cache() - .remove_matching(|address| records.iter().any(|record| record.0 == *address)); + if self.open_file.release() { + self.shared.chunk_cache().remove_matching(|address| { + self.map_records.contains(address) || self.index.contains_record(address) + }); + } } } impl BrowserFileReader { pub(super) fn new( shared: Rc, - file: PublicFileDescriptor, - index: FileIndex, + resolved: ResolvedBrowserFile, + open_files: &OpenFiles, ) -> Self { Self { shared, - file, - index, + open_file: OpenFile::register(open_files, resolved.file.address.clone()), + file: resolved.file, + index: resolved.index, + map_records: resolved.map_records, closed: watch::Sender::new(false), } } @@ -507,7 +574,16 @@ impl BrowserFileReader { }, )) .await?; - JsFuture::from(Promise::resolve(&method(writer, "close")?.call0(writer)?)).await?; + // Every byte is written, so closing the reader no longer cancels: it + // may be closed from the final progress callback. + cancel.ignore_reader(); + cancel + .run(async { + JsFuture::from(Promise::resolve(&method(writer, "close")?.call0(writer)?)) + .await + .map(drop) + }) + .await?; serde_wasm_bindgen::to_value(&PipeResult { bytes_written: total, hash: hasher.finalize().to_hex().to_string(), @@ -605,14 +681,18 @@ impl BrowserFileReader { .map_err(js_string) }) .await?; - // Hash the chunks that arrived ahead of file order, yielding between - // them so a large backlog cannot freeze the page. + // Hash the chunks that arrived ahead of file order, yielding to the + // page between short slices so a large backlog cannot freeze it. + let mut slice_start = Instant::now(); while let Some(chunk) = hasher.pending_read_back() { cancel.check()?; hasher .read_back(chunk, &chunk_view(chunk)?.to_vec()) .map_err(js_string)?; - yield_to_event_loop().await; + if slice_start.elapsed() >= HASH_SLICE { + yield_to_event_loop().await; + slice_start = Instant::now(); + } } Ok((output, hasher.finish().map_err(js_string)?)) } diff --git a/ant-core/src/client_engine/chunk_decrypt.rs b/ant-core/src/client_engine/chunk_decrypt.rs index d975418f..023a5df7 100644 --- a/ant-core/src/client_engine/chunk_decrypt.rs +++ b/ant-core/src/client_engine/chunk_decrypt.rs @@ -9,7 +9,7 @@ use super::files::MIN_FILE_CHUNKS; use bytes::Bytes; -use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::aead::{AeadInPlace, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use self_encryption::XorName; use std::io::Read; @@ -44,13 +44,14 @@ pub(super) fn decrypt_chunk( expected_len: usize, ) -> Result { let secrets = chunk_secrets(index, src_hashes, kdf_level)?; - let unpadded: Vec = content + // Unpad, then decrypt in the same buffer; the tag is truncated off. + let mut compressed: Vec = content .iter() .zip(secrets.pad.iter().cycle()) .map(|(byte, pad)| byte ^ pad) .collect(); - let compressed = ChaCha20Poly1305::new(Key::from_slice(&secrets.key)) - .decrypt(Nonce::from_slice(&secrets.nonce), unpadded.as_slice()) + ChaCha20Poly1305::new(Key::from_slice(&secrets.key)) + .decrypt_in_place(Nonce::from_slice(&secrets.nonce), &[], &mut compressed) .map_err(|error| format!("chunk decryption failed: {error}"))?; let mut plaintext = Vec::new(); plaintext diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 7895cfcf..346782b3 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -1,6 +1,8 @@ //! Async data workflows over a verified record fetcher. No runtime, sockets, -//! filesystem or JS dependencies. Cryptography and recursive map semantics stay -//! in self_encryption, the same implementation used by the native client. +//! filesystem or JS dependencies. Whole-file decryption stays in +//! self_encryption, the same implementation used by the native client; nested +//! DataMaps and individual chunks use its KDF through `chunk_decrypt`, which +//! bounds decompression. use bytes::Bytes; #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] use futures_util::future::{select, Either}; @@ -8,14 +10,12 @@ use futures_util::future::{select, Either}; use futures_util::stream::FuturesUnordered; use futures_util::StreamExt; use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; -use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range, time::Duration}; +use std::{collections::HashMap, future::Future, ops::Range, time::Duration}; /// self_encryption splits every encryptable file into at least three chunks. pub(crate) const MIN_FILE_CHUNKS: usize = 3; -/// Largest plaintext chunk a DataMap may declare: four times the native chunk -/// size. Maps from other chunk-size schemes stay readable, while a hostile map -/// cannot make one chunk reserve an arbitrarily large buffer. -pub(crate) const MAX_CHUNK_PLAINTEXT_BYTES: usize = 16 * 1024 * 1024; +/// Deepest DataMap nesting resolved, as in self_encryption. +const MAX_DATA_MAP_DEPTH: usize = 100; /// Content chunks of a resolved root DataMap are encrypted at KDF level zero. const CONTENT_CHUNK_KDF_LEVEL: usize = 0; /// Seconds to wait before each deferred fetch round: the first pass, one @@ -50,11 +50,14 @@ pub(crate) fn decode_map(bytes: &[u8]) -> Result { rmp_serde::from_slice(bytes).map_err(|e| format!("Failed to deserialize DataMap: {e}")) } -pub(crate) async fn fetch_records( +/// Fetch and verify records once each, handing every record to `on_record` +/// as it arrives. +async fn fetch_records( requests: Vec, fetch: &F, cap: &C, -) -> Result, ReadError> + mut on_record: impl FnMut(usize, Bytes) -> Result<(), ReadError>, +) -> Result<(), ReadError> where F: Fn([u8; 32]) -> Fut, Fut: Future>, @@ -70,63 +73,79 @@ where cap, ); futures_util::pin_mut!(results); - let mut ordered = Vec::new(); while let Some(result) = results.next().await { - ordered.push(result?); + let (index, bytes) = result?; + on_record(index, bytes)?; } - ordered.sort_by_key(|(index, _)| *index); - Ok(ordered) + Ok(()) } -/// Drive the native recursive resolver with async batches. On a cache miss, -/// suspend the synchronous resolver, fetch the whole missing level, and replay -/// with verified cached bytes. This preserves child-level KDF and depth checks -/// in self_encryption without block_on/block_in_place or a second resolver. +/// Resolve a published, possibly nested DataMap to its root. Each level's +/// records are fetched together and decrypted with self_encryption's KDF at +/// that level, with decompression bounded by each chunk's declared size, so a +/// crafted record cannot expand. `max_map_bytes` bounds each decoded level. +/// Records are placed by position, not by the externally supplied chunk index, +/// so malformed duplicate indices cannot misassociate bytes. pub(crate) async fn resolve( map: &DataMap, fetch: &F, cap: &C, + max_map_bytes: usize, ) -> Result> where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, { - let mut cache = HashMap::<[u8; 32], Bytes>::new(); - loop { - let missing = RefCell::new(Vec::new()); - let result = self_encryption::get_root_data_map_parallel(map.clone(), &|batch| { - let requests: Vec<_> = batch - .iter() - .filter(|(_, hash)| !cache.contains_key(&hash.0)) - .map(|(index, hash)| (*index, hash.0)) - .collect(); - if !requests.is_empty() { - *missing.borrow_mut() = requests; - return Err(self_encryption::Error::Generic( - "async record fetch required".into(), - )); - } - // Preserve the resolver's positional batch contract, independent - // of network completion order and DataMap chunk indices. - Ok(batch - .iter() - .map(|(index, hash)| (*index, cache[&hash.0].clone())) - .collect()) - }); - let requests = missing.into_inner(); - if requests.is_empty() { - return result.map_err(|e| ReadError::Invalid(e.to_string())); - } - // Position, not the externally supplied chunk index, identifies a - // response so malformed duplicate indices cannot misassociate bytes. - let addresses: Vec<_> = requests.iter().map(|(_, address)| *address).collect(); - let records = - fetch_records(addresses.iter().copied().enumerate().collect(), fetch, cap).await?; - for (position, bytes) in records { - cache.insert(addresses[position], bytes); + let mut current = map.clone(); + for _ in 0..=MAX_DATA_MAP_DEPTH { + let Some(kdf_level) = current.child() else { + return Ok(current); + }; + let infos = current.infos(); + let mut offsets = Vec::with_capacity(infos.len() + 1); + offsets.push(0usize); + for info in infos { + let end = offsets[offsets.len() - 1] + .checked_add(info.src_size) + .filter(|end| *end <= max_map_bytes) + .ok_or_else(|| { + ReadError::Invalid(format!("nested DataMap exceeds {max_map_bytes} bytes")) + })?; + offsets.push(end); } + let mut encoded = Vec::new(); + encoded + .try_reserve_exact(offsets[infos.len()]) + .map_err(|e| ReadError::Invalid(format!("cannot allocate nested DataMap: {e}")))?; + encoded.resize(offsets[infos.len()], 0); + let src_hashes: Vec = infos.iter().map(|info| info.src_hash).collect(); + let requests = infos + .iter() + .map(|info| info.dst_hash.0) + .enumerate() + .collect(); + fetch_records(requests, fetch, cap, |position, record| { + let plaintext = super::chunk_decrypt::decrypt_chunk( + position, + &record, + &src_hashes, + kdf_level, + infos[position].src_size, + ) + .map_err(ReadError::Invalid)?; + crate::record::verify(&src_hashes[position].0, &plaintext) + .map_err(ReadError::Invalid)?; + encoded[offsets[position]..offsets[position + 1]].copy_from_slice(&plaintext); + Ok(()) + }) + .await?; + current = DataMap::from_bytes(&encoded) + .map_err(|e| ReadError::Invalid(format!("Failed to deserialize DataMap: {e}")))?; } + Err(ReadError::Invalid(format!( + "DataMap nesting exceeds {MAX_DATA_MAP_DEPTH} levels" + ))) } pub(crate) async fn download( @@ -143,15 +162,14 @@ where S: Fn(Duration) -> SF, SF: Future, { - let root = resolve(map, fetch, cap).await?; - let requests = root - .infos() - .iter() - .enumerate() - .map(|(index, info)| (index, info.dst_hash.0)) - .collect(); + // The native whole-file path keeps no limit on nested DataMap levels. + let root = resolve(map, fetch, cap, usize::MAX).await?; + let addresses: Vec<[u8; 32]> = root.infos().iter().map(|info| info.dst_hash.0).collect(); + let requests = addresses.iter().copied().enumerate().collect(); let mut records = Vec::new(); fetch_records_deferred(requests, fetch, cap, sleep, retryable, |index, content| { + // self_encryption decrypts these without authenticating each record. + crate::record::verify(&addresses[index], &content).map_err(ReadError::Invalid)?; records.push((index, content)); Ok(()) }) @@ -233,11 +251,14 @@ pub(crate) struct FileIndex { dst_hashes: Vec, /// Plaintext start of each chunk, followed by the file size. offsets: Vec, + /// Chunk positions ordered by record address, for address lookups. + #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] + records_by_address: Vec, } impl FileIndex { - /// Chunk sizes are taken from the map, up to [`MAX_CHUNK_PLAINTEXT_BYTES`], - /// so maps produced with another self-encryption chunk size remain readable. + /// Chunk sizes are taken from the map, so maps produced with another + /// self-encryption chunk size remain readable. pub(crate) fn new(map: &DataMap) -> Result { if map.is_child() { return Err("range reads require a resolved root DataMap".into()); @@ -255,7 +276,7 @@ impl FileIndex { if info.index != index { return Err("DataMap chunk indices must be contiguous".into()); } - if !(1..=MAX_CHUNK_PLAINTEXT_BYTES).contains(&info.src_size) { + if info.src_size == 0 { return Err("invalid DataMap plaintext chunk size".into()); } let end = offsets[index] @@ -263,10 +284,19 @@ impl FileIndex { .ok_or("DataMap plaintext size overflow")?; offsets.push(end); } + let dst_hashes: Vec = infos.iter().map(|info| info.dst_hash).collect(); + #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] + let records_by_address = { + let mut order: Vec = (0..dst_hashes.len()).collect(); + order.sort_unstable_by_key(|position| dst_hashes[*position].0); + order + }; Ok(Self { src_hashes: infos.iter().map(|info| info.src_hash).collect(), - dst_hashes: infos.iter().map(|info| info.dst_hash).collect(), + dst_hashes, offsets, + #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] + records_by_address, }) } @@ -293,8 +323,10 @@ impl FileIndex { first..last } - /// Decrypt one verified record and check it against the map's plaintext - /// size and hash. Decompression stops at the size the map declares. + /// Decrypt one record and check it against the map's plaintext size and + /// hash. Decompression stops at the size the map declares. The record is + /// not re-hashed: the fetcher verifies addresses, and an altered record + /// fails ChaCha20-Poly1305 authentication or the plaintext hash. fn decrypt(&self, chunk: usize, encrypted: &Bytes) -> Result { let span = self.chunk_span(chunk); // Per-chunk decryption avoids self_encryption's pointer-sized @@ -305,7 +337,7 @@ impl FileIndex { encrypted, &self.src_hashes, CONTENT_CHUNK_KDF_LEVEL, - // Bounded by MAX_CHUNK_PLAINTEXT_BYTES when the index was built. + // Each size was a usize in the DataMap this index came from. (span.end - span.start) as usize, )?; crate::record::verify(&self.src_hashes[chunk].0, &plaintext)?; @@ -320,9 +352,20 @@ impl FileIndex { self.dst_hashes.len() } - /// Encrypted record addresses in chunk order. - pub(crate) fn record_addresses(&self) -> &[XorName] { - &self.dst_hashes + /// Whether one of this file's chunks is stored under `address`. + pub(crate) fn contains_record(&self, address: &[u8; 32]) -> bool { + self.records_by_address + .binary_search_by(|position| self.dst_hashes[*position].0.cmp(address)) + .is_ok() + } + + /// The size of the largest chunk. + pub(crate) fn largest_chunk(&self) -> u64 { + self.offsets + .windows(2) + .map(|bounds| bounds[1] - bounds[0]) + .max() + .unwrap_or(0) } /// The self-encryption chunk metadata in chunk order. @@ -333,7 +376,7 @@ impl FileIndex { index, dst_hash: self.dst_hashes[index], src_hash: self.src_hashes[index], - // Bounded by MAX_CHUNK_PLAINTEXT_BYTES when the index was built. + // Each size was a usize in the DataMap this index came from. src_size: (span.end - span.start) as usize, } }) @@ -379,8 +422,8 @@ where .await } -/// Fetch, verify and decrypt one chunk, retrying a missing record on the -/// deferred schedule independently of every other chunk. +/// Fetch and decrypt one chunk, retrying a missing record on the deferred +/// schedule independently of every other chunk. #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] pub(crate) async fn fetch_chunk( index: &FileIndex, @@ -395,24 +438,25 @@ where S: Fn(Duration) -> SF, SF: Future, { - let address = index.dst_hashes[chunk].0; - let mut last_error = None; - for delay in DEFERRED_ROUND_DELAYS_SECS { - if delay > 0 { - sleep(Duration::from_secs(delay)).await; - } - match fetch(address).await { - Ok(encrypted) => { - crate::record::verify(&address, &encrypted).map_err(ReadError::Invalid)?; - return index.decrypt(chunk, &encrypted).map_err(ReadError::Invalid); - } - Err(error) if !retryable(&error) => return Err(ReadError::Fetch(error)), - Err(error) => last_error = Some(error), - } - } - Err(ReadError::Fetch( - last_error.expect("the deferred schedule makes at least one attempt"), - )) + let mut plaintext = None; + let request = vec![(chunk, index.dst_hashes[chunk].0)]; + fetch_records_deferred( + request, + fetch, + &|| 1, + sleep, + retryable, + |chunk, encrypted| { + plaintext = Some( + index + .decrypt(chunk, &encrypted) + .map_err(ReadError::Invalid)?, + ); + Ok(()) + }, + ) + .await?; + Ok(plaintext.expect("a completed pass delivers its one chunk")) } #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] @@ -700,6 +744,9 @@ where } } +/// Deferred retry rounds over records, preserving each record's last typed +/// fetch error. Records reach `on_record` unhashed: content chunks are +/// authenticated by decryption, and the whole-file path verifies them there. async fn fetch_records_deferred( requests: Vec, fetch: &F, @@ -723,10 +770,7 @@ where let errors = &errors; async move { match fetch(address).await { - Ok(bytes) => { - crate::record::verify(&address, &bytes).map_err(ReadError::Invalid)?; - Ok((index, Ok(bytes))) - } + Ok(bytes) => Ok((index, Ok(bytes))), Err(error) if !retryable(&error) => Err(ReadError::Fetch(error)), Err(error) => { errors @@ -795,7 +839,12 @@ mod tests { Ok::<_, String>(bytes) } }; - assert_eq!(resolve(&published, &fetch, &|| 3).await.unwrap(), native); + assert_eq!( + resolve(&published, &fetch, &|| 3, usize::MAX) + .await + .unwrap(), + native + ); assert_eq!( download(&published, &fetch, &|| 3, &|_| async {}, |_| true) .await @@ -949,9 +998,14 @@ mod tests { .unwrap_err(); assert!(matches!(error, ReadError::Fetch(42))); let child = DataMap::with_child(map.infos().to_vec(), 1); - let error = resolve(&child, &|_| async { Err::(23u8) }, &|| 2) - .await - .unwrap_err(); + let error = resolve( + &child, + &|_| async { Err::(23u8) }, + &|| 2, + usize::MAX, + ) + .await + .unwrap_err(); assert!(matches!(error, ReadError::Fetch(23))); } @@ -1059,12 +1113,65 @@ mod tests { assert_eq!(index.size(), (MIN_FILE_CHUNKS * size) as u64); assert_eq!(index.chunk_infos().collect::>(), infos); assert_eq!( - index.record_addresses(), + index.dst_hashes, infos.iter().map(|info| info.dst_hash).collect::>() ); + assert_eq!(index.largest_chunk(), size as u64); + for info in &infos { + assert!(index.contains_record(&info.dst_hash.0)); + } + assert!(!index.contains_record(&[0x55; 32])); + // Chunk sizes are not capped natively; browsers apply their own limit. let mut infos = infos; - infos[1].src_size = MAX_CHUNK_PLAINTEXT_BYTES + 1; - assert!(FileIndex::new(&DataMap::new(infos)).is_err()); + infos[1].src_size = 64 * size; + let index = FileIndex::new(&DataMap::new(infos)).unwrap(); + assert_eq!(index.largest_chunk(), 64 * size as u64); + } + + #[tokio::test] + async fn nested_maps_resolve_within_limits_and_reject_expanding_records() { + let content = Bytes::from(vec![17; 3 * self_encryption::MAX_CHUNK_SIZE + 1]); + let (map, chunks) = self_encryption::encrypt(content).unwrap(); + assert!(map.is_child()); + let records: HashMap<_, _> = chunks + .iter() + .map(|c| (*blake3::hash(&c.content).as_bytes(), c.content.clone())) + .collect(); + let fetch = |address| { + let bytes = records[&address].clone(); + async move { Ok::<_, String>(bytes) } + }; + let level_size: usize = map.infos().iter().map(|info| info.src_size).sum(); + let root = resolve(&map, &fetch, &|| 3, level_size).await.unwrap(); + assert!(!root.is_child()); + + let error = resolve(&map, &fetch, &|| 3, level_size - 1) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Invalid(message) if message.contains("exceeds"))); + + // A record that decompresses past its declared size is cut off there. + let mut infos = map.infos().to_vec(); + infos[0].src_size -= 1; + let understated = DataMap::with_child(infos, map.child().unwrap()); + let error = resolve(&understated, &fetch, &|| 3, usize::MAX) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Invalid(message) if message.contains("differs"))); + } + + #[tokio::test] + async fn altered_content_records_fail_decryption() { + let (_, map, records) = fixture(); + let fetch = |address| { + let mut bytes = records[&address].to_vec(); + *bytes.last_mut().unwrap() ^= 1; + async move { Ok::<_, String>(Bytes::from(bytes)) } + }; + let error = read_range(&map, 0, 1, &fetch, &|| 1, &|_| async {}, |_| true) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Invalid(message) if message.contains("decryption"))); } #[tokio::test] @@ -1292,8 +1399,8 @@ mod tests { let sleeps = std::sync::Mutex::new(Vec::new()); // The first and last records are missing until their third attempt. let missing = [ - index.record_addresses()[0].0, - index.record_addresses()[MIN_FILE_CHUNKS - 1].0, + index.dst_hashes[0].0, + index.dst_hashes[MIN_FILE_CHUNKS - 1].0, ]; let fetch = |address: [u8; 32]| { let attempt = { diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index 0f41529a..c36623d4 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -419,7 +419,8 @@ impl Client { ) -> Result { let fetch = |address| self.fetch_data_record(address); let cap = || self.controller().fetch.current(); - let root = crate::client_engine::files::resolve(data_map, &fetch, &cap) + // Native range reads keep no limit on nested DataMap levels. + let root = crate::client_engine::files::resolve(data_map, &fetch, &cap, usize::MAX) .await .map_err(map_read_error)?; crate::client_engine::files::read_range( diff --git a/ant-core/src/data/client/file/native.rs b/ant-core/src/data/client/file/native.rs index 2f3c18e8..3c1420e0 100644 --- a/ant-core/src/data/client/file/native.rs +++ b/ant-core/src/data/client/file/native.rs @@ -3336,6 +3336,8 @@ impl Client { } }, &|| self.controller().fetch.current(), + // Native downloads keep no limit on nested DataMap levels. + usize::MAX, ) .await .map_err(super::super::data::map_read_error)?; diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index 4e26bdb4..d7f4b85d 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -68,7 +68,9 @@ through the real WASM decryptor and checks its complete BLAKE3 without retaining the plaintext. It also covers public/private seeks across 4 GiB, actual disk writes, fetching bounded by the `pipeTo` buffer behind a blocked write, cancellation of `pipeTo`, range reads and complete downloads by `AbortSignal` -and by closing the reader (including during retry waits), destinations aborted +and by closing the reader (including during retry waits, a stalled destination +write and the destination's close), cache eviction with two readers of one +file, destinations aborted for invalid calls, a reader closed from the final progress callback, writer errors, JS allocation failures, memory budgets, download options (including the legacy numeric form and optional typings) and progress, and invalid offsets. The compressed fixture is about 250 KiB; diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index f5d5c899..243371e1 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -298,6 +298,65 @@ test("cancelling during a missing-record retry does not wait out the retry round } finally { client.close(); } }); +test("an AbortSignal settles pipeTo even while a destination write is stalled", async () => { + const { client } = fixture(); + let reader; + try { + reader = await client.openPublicFile(large.address); + const controller = new AbortController(); + const reason = new Error("stalled destination"); + let writing; + const stalled = new Promise(resolve => { writing = resolve; }); + const sink = new WritableStream({ write() { writing(); return new Promise(() => {}); } }); + const pending = reader.pipeTo(sink, { signal: controller.signal }); + await stalled; + const started = Date.now(); + controller.abort(reason); + await assert.rejects(pending, error => error === reason); + assert(Date.now() - started < 2_000, `abort took ${Date.now() - started} ms`); + } finally { reader?.close(); client.close(); } +}); + +test("an AbortSignal during the destination's close rejects pipeTo", async () => { + const original = new TextEncoder().encode("Abort while committing.".repeat(200)); + const encrypted = encryptPublicFile(original); + const { client } = fixture(encrypted.records); + let reader; + try { + reader = await client.openPublicFile(encrypted.address); + const controller = new AbortController(); + const reason = new Error("cancelled during commit"); + let closing; + const committing = new Promise(resolve => { closing = resolve; }); + const sink = new WritableStream({ close() { closing(); return new Promise(() => {}); } }); + const pending = reader.pipeTo(sink, { signal: controller.signal }); + await committing; + controller.abort(reason); + await assert.rejects(pending, error => error === reason); + } finally { reader?.close(); client.close(); } +}); + +test("closing one reader keeps the records another reader of the file uses", async () => { + const original = new TextEncoder().encode("Two readers, one file.".repeat(300)); + const encrypted = encryptPublicFile(original); + const { rtc, client } = fixture(encrypted.records); + try { + const first = await client.openPublicFile(encrypted.address); + const second = await client.openPublicFile(encrypted.address); + assert.deepEqual(await second.readRange(0, original.length), original); + first.close(); + const cached = gets(rtc); + assert.deepEqual(await second.readRange(0, original.length), original); + assert.equal(gets(rtc), cached, "the open reader's records stay cached"); + second.close(); + const third = await client.openPublicFile(encrypted.address); + const reopened = gets(rtc); + assert.deepEqual(await third.readRange(0, original.length), original); + assert(gets(rtc) > reopened, "the last reader's close released the file's records"); + third.close(); + } finally { client.close(); } +}); + test("writer rejection and reader cancellation stop a stream and release its lock", async () => { const { client } = fixture(); try { diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 067d588d..d5bfbbf9 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -668,7 +668,8 @@ Manifest validation no longer rejects files over 1 GB or over 1,024 chunks. It caps a file's chunk list at 262,144 entries (about 1 TiB at the native chunk size), which bounds the work an untrusted manifest can cause. Chunk sizes come from the DataMap rather than this build's `MAX_CHUNK_SIZE`, so maps produced -with another self-encryption chunk size remain readable, up to 16 MiB per chunk. +with another self-encryption chunk size remain readable. Browsers accept chunks +of up to 16 MiB; native range reads accept any size. A reader retains a validated root-map index and uses binary searches to identify overlapping chunks. Chunks are decrypted one at a time with the original @@ -679,11 +680,19 @@ decompresses without an output limit, so a crafted record could expand to gigabytes before its declared size is checked and abort the WASM instance. `client_engine/chunk_decrypt.rs` therefore performs the same steps (BLAKE3 KDF, XOR pad, ChaCha20-Poly1305, Brotli) and stops decompressing one byte past the -declared size. It uses the dependency's own `chacha20poly1305` and -`brotli-decompressor` versions, and a test pins its output to `decrypt_chunk` -so a change to the dependency's KDF fails it; a bounded primitive upstream -would replace it. Encrypted records and reconstructed plaintext chunks are -verified against their respective hashes and sizes. +declared size. Nested DataMap levels are resolved through it too, instead of +`get_root_data_map_parallel`, and browsers bound each decoded level at 64 MiB. +It uses the dependency's own `chacha20poly1305` and `brotli-decompressor` +versions, and tests pin its output to `decrypt_chunk` and to native nested-map +resolution, so a change to the dependency's KDF fails them; a bounded primitive +upstream would replace it. Native whole-file downloads still decrypt through +`self_encryption::decrypt`. + +The fetch layer verifies every record's address. The engine does not hash +content records again: ChaCha20-Poly1305 authentication and the plaintext hash +reject an altered record, and decompression stops at the declared size. The +native whole-file path keeps its own record check before handing records to +`self_encryption`. Reads are chunk-aligned. A shared engine primitive fetches a set of chunks in one deferred retry pass and hands each decrypted chunk on as it completes, so a @@ -717,10 +726,15 @@ the buffer keeps fetching, so retry waits overlap rather than adding up per batch. Only a record that stays missing longer than the buffer takes to fill holds the stream back. It computes BLAKE3 incrementally, closes the destination on success, aborts it -on any failure including invalid options, and releases the writer lock. An -`AbortSignal` cancels one operation without closing the reader. Closing the -reader cancels its operations at once, including fetches waiting to be retried, -and evicts only that reader's records from the client's shared cache. +on any failure including invalid options, and releases the writer lock without +waiting for a stalled destination to finish aborting. An `AbortSignal` cancels +one operation without closing the reader. Closing the reader cancels its +operations at once, including fetches waiting to be retried, until every byte +is written; it may then be closed from the final progress callback. After that +only the signal can cancel, and a destination that has started closing may +already be committed. Readers are counted per file, and the last reader of a +file to close evicts that file's content and DataMap records from the client's +shared cache, looked up by address in O(log chunks) per cached entry. Cancellation is checked before an operation is resumed, so a cancelled operation never completes or refills the cache afterwards. The core does not open file pickers, own browser filesystem policy, or change the @@ -746,10 +760,12 @@ claiming literally unlimited file sizes. Validation includes a complete 4,303,347,835-byte native-encrypted fixture streamed through generated WASM with a matching BLAKE3; public/private seeks beyond 4 GiB; -disk output; backpressure; cancellation by `AbortSignal`, by closing the reader -and during retry waits; writer errors; destinations aborted for invalid calls; +disk output; backpressure; cancellation by `AbortSignal`, by closing the reader, +during retry waits, during a stalled write and while the destination closes; +cache eviction with two readers of one file; writer errors; destinations +aborted for invalid calls; allocation failures; invalid offsets and options; manifest validation; and shared native range, single-pass retry, ordered-pipeline, ordered-hash, bounded -decryption and decompression-bomb tests. The generated +decryption, bounded nested-map and decompression-bomb tests. The generated WASM tests use mocked WebRTC hosts with real Rust protocol and crypto processing. They do not establish real-browser or live-network compatibility by themselves. From e1e67d813db68781c3bd21181194a947350de00a Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:08:44 +0200 Subject: [PATCH 06/15] fix(wasm): allocate nested maps from received data and drop cache eviction Nested DataMap resolution reserved and zero-filled the full size a level declared before fetching anything, so a crafted map declaring terabyte chunks could exhaust a native client's memory. Each chunk is now kept once decrypted and the level is joined after every chunk verifies, and chunk decompression grows with its actual output. Closing a reader no longer evicts records from the shared cache. The per-file reader count missed readers dropped without close() and evicted records still used by readers of the same content opened another way; the cache is bounded at 32 MiB and its LRU policy now releases records. - Return a complete download's own output error (such as a JS RangeError) instead of reporting it as an encryption error. - Derive Deserialize for the download result. - Correct the manifest cap and test README descriptions, and cover the legacy null concurrency argument. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- ant-core/src/browser/manifest.rs | 5 +- ant-core/src/browser/wasm_transport.rs | 45 +++----- .../src/browser/wasm_transport/download.rs | 108 ++++++------------ ant-core/src/client_engine/chunk_decrypt.rs | 10 +- ant-core/src/client_engine/files.rs | 84 +++++++------- ant-core/src/data/client/cache.rs | 27 ----- ant-core/wasm-tests/README.md | 16 +-- ant-core/wasm-tests/large-download.test.mjs | 19 ++- .../ADR-0004-direct-browser-read-client.md | 13 ++- 10 files changed, 126 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index 48b83cf7..fb3b07c9 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,8 @@ range. An `AbortSignal` cancels that one operation and leaves the reader open. `reader.close()` cancels the reader's operations at once, including fetches waiting to be retried, until every byte is written. After that only the signal can cancel, and a destination that has started closing may already be -committed. Once no other reader of the same file is open, closing also evicts -the file's records, including the DataMap records cached while resolving it, -from the client's shared cache. `readRange(start, length)` remains available for media +committed. Closing leaves records in the client's shared cache, which is +bounded (32 MiB) and may serve other readers. `readRange(start, length)` remains available for media and custom streaming sinks, with a 4 MiB per-call limit. A sink must persist or consume the bytes instead of accumulating them to keep memory bounded. The application chooses a memory budget and supplies its disk destination; the core diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs index 3383f171..26c15f17 100644 --- a/ant-core/src/browser/manifest.rs +++ b/ant-core/src/browser/manifest.rs @@ -10,8 +10,9 @@ use serde::{Deserialize, Serialize}; /// Current browser testnet manifest version. pub const BROWSER_MANIFEST_VERSION: u16 = 6; const MAX_DATA_MAP_BYTES: usize = 4 * 1024 * 1024; -/// Bounds the work an untrusted manifest entry can cause before its chunk -/// list is checked. At the native chunk size this admits files of about 1 TiB. +/// Bounds the sorting and hex normalisation of an untrusted entry's chunk list. +/// The manifest is already parsed by then, so its size is the caller's bound. +/// At the native chunk size this admits files of about 1 TiB. const MAX_FILE_CHUNKS: usize = 1 << 18; /// `Number.MAX_SAFE_INTEGER`: the largest integer JavaScript represents /// exactly, which bounds chain IDs, file sizes and byte positions. diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index ffeb08b2..bb256eae 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2026,14 +2026,18 @@ fn parse_lookup_key(value: &str, label: &str) -> Result { }) } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct BrowserDownloadResult { #[serde(with = "serde_wasm_bindgen::preserve")] content: Uint8Array, hash: String, file: PublicFileDescriptor, /// Node that served a public DataMap; absent for a caller-held private DataMap. - #[serde(rename = "dataMapNode", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "dataMapNode", + default, + skip_serializing_if = "Option::is_none" + )] data_map_node: Option, } @@ -2128,8 +2132,6 @@ struct ResolvedBrowserFile { file: PublicFileDescriptor, data_map_node: Option, index: crate::client_engine::files::FileIndex, - /// DataMap records fetched, and so cached, while resolving. - map_records: Vec<[u8; 32]>, } struct UploadRecord { @@ -2339,7 +2341,6 @@ pub struct BrowserNetworkClient { shared: Rc, adapter: Rc, read_ahead: Rc, - open_files: download::OpenFiles, } #[wasm_bindgen(js_class = BrowserNetworkClient)] @@ -2373,7 +2374,6 @@ impl BrowserNetworkClient { shared, adapter, read_ahead, - open_files: download::OpenFiles::default(), }) } @@ -2681,11 +2681,7 @@ impl BrowserNetworkClient { resolved.file.size, resolved.index.chunk_count() )); - Ok(BrowserFileReader::new( - Rc::clone(&self.shared), - resolved, - &self.open_files, - )) + Ok(BrowserFileReader::new(Rc::clone(&self.shared), resolved)) } /// Errors are JavaScript values so an AbortSignal's reason reaches the caller. @@ -2704,7 +2700,7 @@ impl BrowserNetworkClient { }) .await?; let data_map_node = resolved.data_map_node.clone(); - let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved, &self.open_files); + let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved); let (content, hash) = reader.collect(&settings, &mut cancel).await?; let file = reader.into_descriptor(hash.clone()); settings @@ -2755,9 +2751,6 @@ impl BrowserNetworkClient { let mut resolved = self .resolve_data_map(&encoded_data_map, descriptor, progress) .await?; - resolved - .map_records - .push(parse_lookup_key(&address, "record address")?); resolved.file.address = address; resolved.data_map_node = Some(data_map_node); Ok(resolved) @@ -2803,21 +2796,16 @@ impl BrowserNetworkClient { progress: &ProgressReporter, ) -> Result { let published_data_map = crate::client_engine::files::decode_map(encoded_data_map)?; - let map_records = RefCell::new(Vec::new()); let root_data_map = crate::client_engine::files::resolve( &published_data_map, - &|address| { - let map_records = &map_records; - async move { - progress.report(&format!( - "Resolving nested DataMap record {}", - hex::encode(address) - )); - map_records.borrow_mut().push(address); - self.get_shared_chunk(&hex::encode(address), progress) - .await - .map(|(content, _)| bytes::Bytes::from(content)) - } + &|address| async move { + progress.report(&format!( + "Resolving nested DataMap record {}", + hex::encode(address) + )); + self.get_shared_chunk(&hex::encode(address), progress) + .await + .map(|(content, _)| bytes::Bytes::from(content)) }, &|| self.shared.controller().fetch.current(), super::MAX_BROWSER_NESTED_MAP_BYTES, @@ -2845,7 +2833,6 @@ impl BrowserNetworkClient { file, data_map_node: None, index, - map_records: map_records.into_inner(), }) } diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index 25174e42..c3c86f8c 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -99,51 +99,6 @@ async fn yield_to_event_loop() { TimeoutFuture::new(0).await; } -/// Open readers per file key, so closing one reader keeps the cached records -/// another reader of the same file is still using. -pub(super) type OpenFiles = Rc>>; - -/// One reader's place in [`OpenFiles`], released on close or drop. -struct OpenFile { - files: OpenFiles, - key: String, - open: Cell, -} - -impl OpenFile { - fn register(files: &OpenFiles, key: String) -> Self { - *files.borrow_mut().entry(key.clone()).or_default() += 1; - Self { - files: Rc::clone(files), - key, - open: Cell::new(true), - } - } - - /// Release this reader's place. True when it was the file's last open reader. - fn release(&self) -> bool { - if !self.open.replace(false) { - return false; - } - let mut files = self.files.borrow_mut(); - let Some(count) = files.get_mut(&self.key) else { - return true; - }; - *count -= 1; - if *count > 0 { - return false; - } - files.remove(&self.key); - true - } -} - -impl Drop for OpenFile { - fn drop(&mut self) { - self.release(); - } -} - /// Release the writer even if the Rust future is dropped before it completes. struct Writer(JsValue); impl Drop for Writer { @@ -390,9 +345,6 @@ pub struct BrowserFileReader { /// Descriptor without its chunk list; the index holds the chunk metadata. file: PublicFileDescriptor, index: FileIndex, - /// DataMap records cached while the file was resolved. - map_records: Vec<[u8; 32]>, - open_file: OpenFile, closed: watch::Sender, } @@ -477,31 +429,20 @@ impl BrowserFileReader { result } - /// Close the reader: cancel its operations and, once no other reader of - /// the same file is open, drop the file's records from the client's shared - /// cache. Use an AbortSignal to cancel a single pipeTo without closing. + /// Close the reader and cancel its operations. Cached records stay in the + /// client's shared cache, which is bounded and may serve other readers. + /// Use an AbortSignal to cancel a single pipeTo without closing. pub fn close(&self) { self.closed.send_replace(true); - if self.open_file.release() { - self.shared.chunk_cache().remove_matching(|address| { - self.map_records.contains(address) || self.index.contains_record(address) - }); - } } } impl BrowserFileReader { - pub(super) fn new( - shared: Rc, - resolved: ResolvedBrowserFile, - open_files: &OpenFiles, - ) -> Self { + pub(super) fn new(shared: Rc, resolved: ResolvedBrowserFile) -> Self { Self { shared, - open_file: OpenFile::register(open_files, resolved.file.address.clone()), file: resolved.file, index: resolved.index, - map_records: resolved.map_records, closed: watch::Sender::new(false), } } @@ -653,7 +594,10 @@ impl BrowserFileReader { let mut hasher = OrderedHasher::new(total); let mut completed = 0usize; progress.report(&format!("Downloaded chunk {completed}/{total}")); - cancel + // The engine reports a sink failure as invalid data, so the output's + // own error is kept here and returned instead. + let mut output_error = None; + let pass = cancel .run(async { self.shared .data_download_indexed_chunks( @@ -661,17 +605,25 @@ impl BrowserFileReader { 0..total, settings.concurrency, |chunk, plaintext| { - // Copies straight from WASM memory into the output. - chunk_view(chunk) - .map_err(js_error_message)? - .copy_from(&plaintext); - hasher.complete(chunk, &plaintext); - // At most one earlier chunk per arrival keeps each - // callback short. - if let Some(earlier) = hasher.pending_read_back() { - let view = chunk_view(earlier).map_err(js_error_message)?; - hasher.read_back(earlier, &view.to_vec())?; - } + let mut place = || -> Result<(), JsValue> { + // Copies straight from WASM memory into the output. + chunk_view(chunk)?.copy_from(&plaintext); + hasher.complete(chunk, &plaintext); + // At most one earlier chunk per arrival keeps + // each callback short. + if let Some(earlier) = hasher.pending_read_back() { + let view = chunk_view(earlier)?; + hasher + .read_back(earlier, &view.to_vec()) + .map_err(js_string)?; + } + Ok(()) + }; + place().map_err(|error| { + let message = js_error_message(error.clone()); + output_error = Some(error); + message + })?; completed += 1; progress.report(&format!("Downloaded chunk {completed}/{total}")); Ok(()) @@ -680,7 +632,11 @@ impl BrowserFileReader { .await .map_err(js_string) }) - .await?; + .await; + if let Some(error) = output_error { + return Err(error); + } + pass?; // Hash the chunks that arrived ahead of file order, yielding to the // page between short slices so a large backlog cannot freeze it. let mut slice_start = Instant::now(); diff --git a/ant-core/src/client_engine/chunk_decrypt.rs b/ant-core/src/client_engine/chunk_decrypt.rs index 023a5df7..478a6352 100644 --- a/ant-core/src/client_engine/chunk_decrypt.rs +++ b/ant-core/src/client_engine/chunk_decrypt.rs @@ -34,8 +34,9 @@ struct ChunkSecrets { } /// Decrypt chunk `index` of a map with `src_hashes`, rejecting any record that -/// decompresses to anything other than `expected_len` bytes. At most -/// `expected_len + 1` bytes are ever produced. +/// decompresses to anything other than `expected_len` bytes. Memory grows with +/// the output actually produced, which stops at `expected_len + 1` bytes; a +/// large declared size alone allocates nothing. pub(super) fn decrypt_chunk( index: usize, content: &[u8], @@ -53,10 +54,7 @@ pub(super) fn decrypt_chunk( ChaCha20Poly1305::new(Key::from_slice(&secrets.key)) .decrypt_in_place(Nonce::from_slice(&secrets.nonce), &[], &mut compressed) .map_err(|error| format!("chunk decryption failed: {error}"))?; - let mut plaintext = Vec::new(); - plaintext - .try_reserve_exact(expected_len) - .map_err(|error| format!("cannot allocate chunk: {error}"))?; + let mut plaintext = Vec::with_capacity(expected_len.min(self_encryption::MAX_CHUNK_SIZE)); brotli_decompressor::Decompressor::new(compressed.as_slice(), DECOMPRESS_BUFFER_BYTES) .take(expected_len as u64 + 1) .read_to_end(&mut plaintext) diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 346782b3..50cc9d7d 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -83,9 +83,11 @@ where /// Resolve a published, possibly nested DataMap to its root. Each level's /// records are fetched together and decrypted with self_encryption's KDF at /// that level, with decompression bounded by each chunk's declared size, so a -/// crafted record cannot expand. `max_map_bytes` bounds each decoded level. -/// Records are placed by position, not by the externally supplied chunk index, -/// so malformed duplicate indices cannot misassociate bytes. +/// crafted record cannot expand. `max_map_bytes` bounds each decoded level's +/// declared size. Memory follows the data actually decrypted, never the sizes +/// a map declares. Records are placed by position, not by the externally +/// supplied chunk index, so malformed duplicate indices cannot misassociate +/// bytes. pub(crate) async fn resolve( map: &DataMap, fetch: &F, @@ -103,22 +105,14 @@ where return Ok(current); }; let infos = current.infos(); - let mut offsets = Vec::with_capacity(infos.len() + 1); - offsets.push(0usize); - for info in infos { - let end = offsets[offsets.len() - 1] - .checked_add(info.src_size) - .filter(|end| *end <= max_map_bytes) - .ok_or_else(|| { - ReadError::Invalid(format!("nested DataMap exceeds {max_map_bytes} bytes")) - })?; - offsets.push(end); - } - let mut encoded = Vec::new(); - encoded - .try_reserve_exact(offsets[infos.len()]) - .map_err(|e| ReadError::Invalid(format!("cannot allocate nested DataMap: {e}")))?; - encoded.resize(offsets[infos.len()], 0); + let declared = infos + .iter() + .try_fold(0usize, |total, info| total.checked_add(info.src_size)) + .filter(|total| *total <= max_map_bytes) + .ok_or_else(|| { + ReadError::Invalid(format!("nested DataMap exceeds {max_map_bytes} bytes")) + })?; + let mut chunks: Vec> = vec![None; infos.len()]; let src_hashes: Vec = infos.iter().map(|info| info.src_hash).collect(); let requests = infos .iter() @@ -136,10 +130,16 @@ where .map_err(ReadError::Invalid)?; crate::record::verify(&src_hashes[position].0, &plaintext) .map_err(ReadError::Invalid)?; - encoded[offsets[position]..offsets[position + 1]].copy_from_slice(&plaintext); + chunks[position] = Some(plaintext); Ok(()) }) .await?; + // Every chunk decrypted to exactly its declared size, so `declared` + // is now the size of data actually received. + let mut encoded = Vec::with_capacity(declared); + for chunk in chunks.into_iter().flatten() { + encoded.extend_from_slice(&chunk); + } current = DataMap::from_bytes(&encoded) .map_err(|e| ReadError::Invalid(format!("Failed to deserialize DataMap: {e}")))?; } @@ -251,9 +251,6 @@ pub(crate) struct FileIndex { dst_hashes: Vec, /// Plaintext start of each chunk, followed by the file size. offsets: Vec, - /// Chunk positions ordered by record address, for address lookups. - #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] - records_by_address: Vec, } impl FileIndex { @@ -284,19 +281,10 @@ impl FileIndex { .ok_or("DataMap plaintext size overflow")?; offsets.push(end); } - let dst_hashes: Vec = infos.iter().map(|info| info.dst_hash).collect(); - #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] - let records_by_address = { - let mut order: Vec = (0..dst_hashes.len()).collect(); - order.sort_unstable_by_key(|position| dst_hashes[*position].0); - order - }; Ok(Self { src_hashes: infos.iter().map(|info| info.src_hash).collect(), - dst_hashes, + dst_hashes: infos.iter().map(|info| info.dst_hash).collect(), offsets, - #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] - records_by_address, }) } @@ -352,13 +340,6 @@ impl FileIndex { self.dst_hashes.len() } - /// Whether one of this file's chunks is stored under `address`. - pub(crate) fn contains_record(&self, address: &[u8; 32]) -> bool { - self.records_by_address - .binary_search_by(|position| self.dst_hashes[*position].0.cmp(address)) - .is_ok() - } - /// The size of the largest chunk. pub(crate) fn largest_chunk(&self) -> u64 { self.offsets @@ -1117,10 +1098,6 @@ mod tests { infos.iter().map(|info| info.dst_hash).collect::>() ); assert_eq!(index.largest_chunk(), size as u64); - for info in &infos { - assert!(index.contains_record(&info.dst_hash.0)); - } - assert!(!index.contains_record(&[0x55; 32])); // Chunk sizes are not capped natively; browsers apply their own limit. let mut infos = infos; infos[1].src_size = 64 * size; @@ -1160,6 +1137,25 @@ mod tests { assert!(matches!(error, ReadError::Invalid(message) if message.contains("differs"))); } + #[tokio::test] + async fn declared_nested_sizes_allocate_nothing_before_data_arrives() { + const DECLARED_CHUNK: usize = 1 << 40; + let infos = (0..MIN_FILE_CHUNKS) + .map(|index| self_encryption::ChunkInfo { + index, + src_size: DECLARED_CHUNK, + src_hash: XorName([index as u8; 32]), + dst_hash: XorName([0xee - index as u8; 32]), + }) + .collect(); + let map = DataMap::with_child(infos, 1); + // Terabytes are declared, but the fetch fails before any data exists. + let error = resolve(&map, &|_| async { Err::(7u8) }, &|| 3, usize::MAX) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Fetch(7))); + } + #[tokio::test] async fn altered_content_records_fail_decryption() { let (_, map, records) = fixture(); diff --git a/ant-core/src/data/client/cache.rs b/ant-core/src/data/client/cache.rs index 9840fdf1..893d6c3d 100644 --- a/ant-core/src/data/client/cache.rs +++ b/ant-core/src/data/client/cache.rs @@ -81,21 +81,6 @@ impl ChunkCache { cache.pop(address); } - /// Remove every cached chunk whose address matches. Visits only the - /// cached entries, however many addresses `matches` recognises. - #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] - pub(crate) fn remove_matching(&self, mut matches: impl FnMut(&XorName) -> bool) { - let mut cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); - let doomed: Vec = cache - .iter() - .map(|(address, _)| *address) - .filter(|address| matches(address)) - .collect(); - for address in doomed { - cache.pop(&address); - } - } - /// Clear all cached chunks. pub fn clear(&self) { let mut cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); @@ -227,16 +212,4 @@ mod tests { assert_eq!(cache.len(), 1); assert_eq!(cache.get(&addr), Some(Bytes::from_static(b"second"))); } - - #[test] - fn remove_matching_keeps_other_entries() { - let cache = ChunkCache::new(4); - for byte in 0..3u8 { - cache.put([byte; 32], Bytes::from(vec![byte])); - } - cache.remove_matching(|address| address[0] != 1); - assert!(cache.get(&[0; 32]).is_none()); - assert!(cache.get(&[1; 32]).is_some()); - assert!(cache.get(&[2; 32]).is_none()); - } } diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index d7f4b85d..ec379f2f 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -67,13 +67,15 @@ checks media ranges across chunk boundaries and EOF. through the real WASM decryptor and checks its complete BLAKE3 without retaining the plaintext. It also covers public/private seeks across 4 GiB, actual disk writes, fetching bounded by the `pipeTo` buffer behind a blocked write, -cancellation of `pipeTo`, range reads and complete downloads by `AbortSignal` -and by closing the reader (including during retry waits, a stalled destination -write and the destination's close), cache eviction with two readers of one -file, destinations aborted -for invalid calls, a reader closed from the final progress callback, writer -errors, JS allocation failures, memory budgets, download options (including the -legacy numeric form and optional typings) and progress, and invalid offsets. The compressed fixture is about 250 KiB; +cancellation of `pipeTo` and complete downloads by `AbortSignal`, cancellation +of `pipeTo` and range reads by closing the reader (including during retry +waits), an abort during a stalled destination write and during the +destination's close, a closed reader leaving another reader's cached records +in place, destinations aborted for invalid calls, a reader closed from the +final progress callback, writer errors, JS allocation and output failures, +memory budgets, download options (including the legacy numeric and `null` +forms and optional typings) and progress, and invalid offsets. The compressed +fixture is about 250 KiB; regenerate it from the repository root with `cargo run -p ant-core --release --example generate-browser-large-file`. Generation processes the entire plaintext using bounded buffers. The full-stream diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index 243371e1..e0aeba44 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -336,7 +336,7 @@ test("an AbortSignal during the destination's close rejects pipeTo", async () => } finally { reader?.close(); client.close(); } }); -test("closing one reader keeps the records another reader of the file uses", async () => { +test("closing a reader leaves records another reader of the file uses cached", async () => { const original = new TextEncoder().encode("Two readers, one file.".repeat(300)); const encrypted = encryptPublicFile(original); const { rtc, client } = fixture(encrypted.records); @@ -349,11 +349,6 @@ test("closing one reader keeps the records another reader of the file uses", asy assert.deepEqual(await second.readRange(0, original.length), original); assert.equal(gets(rtc), cached, "the open reader's records stay cached"); second.close(); - const third = await client.openPublicFile(encrypted.address); - const reopened = gets(rtc); - assert.deepEqual(await third.readRange(0, original.length), original); - assert(gets(rtc) > reopened, "the last reader's close released the file's records"); - third.close(); } finally { client.close(); } }); @@ -397,6 +392,9 @@ test("in-memory downloads use one JS output buffer and return recoverable alloca assert.deepEqual(chunkProgress, ["Downloaded chunk 0/3", "Downloaded chunk 1/3", "Downloaded chunk 2/3", "Downloaded chunk 3/3"]); const legacy = await client.downloadPublicFile(encrypted.address, 2, message => messages.push(message)); assert.equal(legacy.hash, result.hash); + // `null` in the legacy concurrency position selects the default, as before. + const unset = await client.downloadPublicFile(encrypted.address, null, () => {}); + assert.equal(unset.hash, result.hash); globalThis.Uint8Array = class extends NativeUint8Array { constructor(...args) { if (args.length === 1 && args[0] === original.length) throw new RangeError("allocation refused"); @@ -404,6 +402,15 @@ test("in-memory downloads use one JS output buffer and return recoverable alloca } }; await assert.rejects(client.downloadPublicFile(encrypted.address), /cannot allocate download buffer.*pipeTo/); + // An output failure is returned as the output's own error, not as bad data. + const refused = new RangeError("output refused"); + globalThis.Uint8Array = class extends NativeUint8Array { + subarray(...args) { + if (this.length === original.length) throw refused; + return super.subarray(...args); + } + }; + await assert.rejects(client.downloadPublicFile(encrypted.address), error => error === refused); globalThis.Uint8Array = NativeUint8Array; reader = await client.openPublicFile(encrypted.address); const chunks = []; diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index d5bfbbf9..12f32010 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -682,6 +682,7 @@ gigabytes before its declared size is checked and abort the WASM instance. XOR pad, ChaCha20-Poly1305, Brotli) and stops decompressing one byte past the declared size. Nested DataMap levels are resolved through it too, instead of `get_root_data_map_parallel`, and browsers bound each decoded level at 64 MiB. +Memory follows the data actually decrypted, never a size a map declares. It uses the dependency's own `chacha20poly1305` and `brotli-decompressor` versions, and tests pin its output to `decrypt_chunk` and to native nested-map resolution, so a change to the dependency's KDF fails them; a bounded primitive @@ -732,10 +733,10 @@ one operation without closing the reader. Closing the reader cancels its operations at once, including fetches waiting to be retried, until every byte is written; it may then be closed from the final progress callback. After that only the signal can cancel, and a destination that has started closing may -already be committed. Readers are counted per file, and the last reader of a -file to close evicts that file's content and DataMap records from the client's -shared cache, looked up by address in O(log chunks) per cached entry. -Cancellation is checked before an operation is resumed, so a cancelled +already be committed. Closing a reader no longer clears the client's shared +cache: that cache is bounded (32 MiB) and may hold records other readers are +using, including readers of the same content opened another way, so its LRU +policy releases records instead. Cancellation is checked before an operation is resumed, so a cancelled operation never completes or refills the cache afterwards. The core does not open file pickers, own browser filesystem policy, or change the companion SDK. @@ -762,8 +763,8 @@ Validation includes a complete 4,303,347,835-byte native-encrypted fixture strea through generated WASM with a matching BLAKE3; public/private seeks beyond 4 GiB; disk output; backpressure; cancellation by `AbortSignal`, by closing the reader, during retry waits, during a stalled write and while the destination closes; -cache eviction with two readers of one file; writer errors; destinations -aborted for invalid calls; +a closed reader leaving another reader's cached records in place; writer and +output errors; destinations aborted for invalid calls; allocation failures; invalid offsets and options; manifest validation; and shared native range, single-pass retry, ordered-pipeline, ordered-hash, bounded decryption, bounded nested-map and decompression-bomb tests. The generated From e5e3fcf428162268084c964daf31b82654eebf71 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:57:15 +0200 Subject: [PATCH 07/15] refactor(wasm): tidy bounded decryption and correct its documentation - Reject zero-size nested chunks and saturate the decompression bound, so a crafted usize::MAX size cannot overflow in debug builds. - Stop re-hashing records the fetch layer verified: decryption authenticates content and nested records, and self_encryption matches whole-file records by content hash. Correct the comment that said otherwise. - Decode nested DataMap levels straight from their chunks with DataMap's bincode options, instead of joining them into a second copy. - Fetch a single pipeTo chunk through the shared chunk pass rather than a duplicate, and keep MIN_FILE_CHUNKS with the KDF that needs it. - Narrow the README/ADR claim: the legacy decryptPublicFile binding still decompresses without a limit. Fix the data_download_range doc. - Name the fixture generator's literals and close test readers in finally blocks. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 11 +- ant-core/Cargo.toml | 4 +- .../examples/generate-browser-large-file.rs | 14 +- ant-core/src/client_engine/chunk_decrypt.rs | 6 +- ant-core/src/client_engine/files.rs | 161 ++++++++++-------- ant-core/src/data/client/data.rs | 19 +-- ant-core/wasm-tests/large-download.test.mjs | 37 ++-- .../ADR-0004-direct-browser-read-client.md | 13 +- 9 files changed, 151 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cae0ac93..857cdfb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,6 +849,7 @@ dependencies = [ "async-trait", "axum", "base64", + "bincode", "blake3", "brotli-decompressor", "bytes", diff --git a/README.md b/README.md index fb3b07c9..c59ff88e 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,12 @@ use 64-bit arithmetic internally; the JavaScript API accepts exact, nonnegative `number` positions through `Number.MAX_SAFE_INTEGER` (just under 8 PiB). The resolved DataMap and seek index still occupy memory proportional to the number of chunks. Individual chunk buffers remain bounded, including for seeks past -4 GiB. In browsers a DataMap may declare chunks of up to 16 MiB and nested -DataMap levels of up to 64 MiB, and every record, including nested DataMap -records, is decompressed only up to its declared size, so a crafted record -cannot expand inside WASM. Native range reads accept any chunk size. Uploads -retain their existing size limits. +4 GiB. Browser readers, downloads and `pipeTo` accept chunks of up to 16 MiB +and nested DataMap levels of up to 64 MiB, and decompress every record, +including nested DataMap records, only up to its declared size, so a crafted +record cannot expand inside WASM. The legacy whole-buffer `decryptPublicFile` +binding still decrypts through self_encryption without that limit. Native range +reads accept any chunk size. Uploads retain their existing size limits. `downloadPublicFile(file, { concurrency?, maxMemoryBytes?, onProgress?, signal? })` and its private-file counterpart allocate the output as a JavaScript diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 72b28849..a60c257d 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -26,7 +26,9 @@ serde_bytes = "0.11" serde_json = "1" self_encryption = "0.36" # The same versions self_encryption uses, for per-chunk decryption with a -# bound on decompressed output (client_engine/chunk_decrypt.rs). +# bound on decompressed output (client_engine/chunk_decrypt.rs) and for +# decoding nested DataMaps from their chunks (client_engine/files.rs). +bincode = "1.3" brotli-decompressor = { version = "2.3", default-features = false, features = ["std"] } chacha20poly1305 = "0.10" # Portable WebRTC Direct profile shared with ant-node. diff --git a/ant-core/examples/generate-browser-large-file.rs b/ant-core/examples/generate-browser-large-file.rs index adf98413..746ec55a 100644 --- a/ant-core/examples/generate-browser-large-file.rs +++ b/ant-core/examples/generate-browser-large-file.rs @@ -6,9 +6,17 @@ use bytes::Bytes; use std::collections::HashMap; +/// Plaintext byte repeated through the file; the tests check it on every read. +const FILL_BYTE: u8 = 0x5a; +/// Whole chunks past the 4 GiB position, so reads and seeks cross it. +const CHUNKS_PAST_4_GIB: u64 = 2; +/// A short final chunk, so the file does not end on a chunk boundary. +const TAIL_BYTES: u64 = 123; + fn main() { - let size = (1u64 << 32) + 2 * self_encryption::MAX_CHUNK_SIZE as u64 + 123; - let block = Bytes::from(vec![0x5a; self_encryption::MAX_CHUNK_SIZE]); + let size = + (1u64 << 32) + CHUNKS_PAST_4_GIB * self_encryption::MAX_CHUNK_SIZE as u64 + TAIL_BYTES; + let block = Bytes::from(vec![FILL_BYTE; self_encryption::MAX_CHUNK_SIZE]); let mut remaining = size as usize; let mut hasher = blake3::Hasher::new(); let input = std::iter::from_fn(|| { @@ -36,7 +44,7 @@ fn main() { }).collect(); records.sort_by(|a, b| a["address"].as_str().cmp(&b["address"].as_str())); let fixture = serde_json::json!({ - "size": size, "byte": 0x5a, "hash": hasher.finalize().to_hex().to_string(), + "size": size, "byte": FILL_BYTE, "hash": hasher.finalize().to_hex().to_string(), "address": hex::encode(address), "records": records, }); let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/ant-core/src/client_engine/chunk_decrypt.rs b/ant-core/src/client_engine/chunk_decrypt.rs index 478a6352..503fdcfe 100644 --- a/ant-core/src/client_engine/chunk_decrypt.rs +++ b/ant-core/src/client_engine/chunk_decrypt.rs @@ -7,13 +7,15 @@ //! Brotli — and stops decompressing one byte past the declared size. Tests pin //! the result to `decrypt_chunk`, so a KDF change in the dependency fails them. -use super::files::MIN_FILE_CHUNKS; use bytes::Bytes; use chacha20poly1305::aead::{AeadInPlace, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use self_encryption::XorName; use std::io::Read; +/// self_encryption splits every encryptable file into at least three chunks: +/// each chunk's key depends on the two before it, wrapping around. +pub(crate) const MIN_FILE_CHUNKS: usize = 3; /// Domain separation context of self_encryption's chunk KDF. const KDF_CONTEXT: &str = "self_encryption/chunk/v2"; const HASH_SIZE: usize = 32; @@ -56,7 +58,7 @@ pub(super) fn decrypt_chunk( .map_err(|error| format!("chunk decryption failed: {error}"))?; let mut plaintext = Vec::with_capacity(expected_len.min(self_encryption::MAX_CHUNK_SIZE)); brotli_decompressor::Decompressor::new(compressed.as_slice(), DECOMPRESS_BUFFER_BYTES) - .take(expected_len as u64 + 1) + .take((expected_len as u64).saturating_add(1)) .read_to_end(&mut plaintext) .map_err(|_| "chunk decompression failed".to_string())?; if plaintext.len() != expected_len { diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 50cc9d7d..5f331d45 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -3,17 +3,16 @@ //! self_encryption, the same implementation used by the native client; nested //! DataMaps and individual chunks use its KDF through `chunk_decrypt`, which //! bounds decompression. -use bytes::Bytes; +pub(crate) use super::chunk_decrypt::MIN_FILE_CHUNKS; +use bytes::{Buf, Bytes}; #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] use futures_util::future::{select, Either}; #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] use futures_util::stream::FuturesUnordered; use futures_util::StreamExt; use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; -use std::{collections::HashMap, future::Future, ops::Range, time::Duration}; +use std::{collections::HashMap, future::Future, io::Read, ops::Range, time::Duration}; -/// self_encryption splits every encryptable file into at least three chunks. -pub(crate) const MIN_FILE_CHUNKS: usize = 3; /// Deepest DataMap nesting resolved, as in self_encryption. const MAX_DATA_MAP_DEPTH: usize = 100; /// Content chunks of a resolved root DataMap are encrypted at KDF level zero. @@ -50,8 +49,9 @@ pub(crate) fn decode_map(bytes: &[u8]) -> Result { rmp_serde::from_slice(bytes).map_err(|e| format!("Failed to deserialize DataMap: {e}")) } -/// Fetch and verify records once each, handing every record to `on_record` -/// as it arrives. +/// Fetch records once each, handing every record to `on_record` as it +/// arrives. Records are not re-hashed: the fetcher verifies addresses, and +/// decryption authenticates every record. async fn fetch_records( requests: Vec, fetch: &F, @@ -67,7 +67,6 @@ where requests, |(index, address)| async move { let bytes = fetch(address).await.map_err(ReadError::Fetch)?; - crate::record::verify(&address, &bytes).map_err(ReadError::Invalid)?; Ok((index, bytes)) }, cap, @@ -80,6 +79,26 @@ where Ok(()) } +/// The decrypted chunks of a nested DataMap level read as one stream. +struct ChunksReader { + remaining: std::vec::IntoIter, + current: Bytes, +} + +impl Read for ChunksReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + while !self.current.has_remaining() { + match self.remaining.next() { + Some(next) => self.current = next, + None => return Ok(0), + } + } + let length = buf.len().min(self.current.remaining()); + self.current.copy_to_slice(&mut buf[..length]); + Ok(length) + } +} + /// Resolve a published, possibly nested DataMap to its root. Each level's /// records are fetched together and decrypted with self_encryption's KDF at /// that level, with decompression bounded by each chunk's declared size, so a @@ -105,7 +124,12 @@ where return Ok(current); }; let infos = current.infos(); - let declared = infos + if infos.iter().any(|info| info.src_size == 0) { + return Err(ReadError::Invalid( + "invalid nested DataMap chunk size".into(), + )); + } + infos .iter() .try_fold(0usize, |total, info| total.checked_add(info.src_size)) .filter(|total| *total <= max_map_bytes) @@ -134,13 +158,13 @@ where Ok(()) }) .await?; - // Every chunk decrypted to exactly its declared size, so `declared` - // is now the size of data actually received. - let mut encoded = Vec::with_capacity(declared); - for chunk in chunks.into_iter().flatten() { - encoded.extend_from_slice(&chunk); - } - current = DataMap::from_bytes(&encoded) + // Decode straight from the chunks rather than joining them first. + // These are DataMap::from_bytes' bincode options, for a reader. + let chunks = ChunksReader { + remaining: chunks.into_iter().flatten().collect::>().into_iter(), + current: Bytes::new(), + }; + current = bincode::deserialize_from(chunks) .map_err(|e| ReadError::Invalid(format!("Failed to deserialize DataMap: {e}")))?; } Err(ReadError::Invalid(format!( @@ -164,12 +188,16 @@ where { // The native whole-file path keeps no limit on nested DataMap levels. let root = resolve(map, fetch, cap, usize::MAX).await?; - let addresses: Vec<[u8; 32]> = root.infos().iter().map(|info| info.dst_hash.0).collect(); - let requests = addresses.iter().copied().enumerate().collect(); + let requests = root + .infos() + .iter() + .enumerate() + .map(|(index, info)| (index, info.dst_hash.0)) + .collect(); let mut records = Vec::new(); + // self_encryption matches records to the map by content hash and + // authenticates each one, so they are not hashed here as well. fetch_records_deferred(requests, fetch, cap, sleep, retryable, |index, content| { - // self_encryption decrypts these without authenticating each record. - crate::record::verify(&addresses[index], &content).map_err(ReadError::Invalid)?; records.push((index, content)); Ok(()) }) @@ -403,43 +431,6 @@ where .await } -/// Fetch and decrypt one chunk, retrying a missing record on the deferred -/// schedule independently of every other chunk. -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -pub(crate) async fn fetch_chunk( - index: &FileIndex, - chunk: usize, - fetch: &F, - sleep: &S, - retryable: fn(&E) -> bool, -) -> Result> -where - F: Fn([u8; 32]) -> Fut, - Fut: Future>, - S: Fn(Duration) -> SF, - SF: Future, -{ - let mut plaintext = None; - let request = vec![(chunk, index.dst_hashes[chunk].0)]; - fetch_records_deferred( - request, - fetch, - &|| 1, - sleep, - retryable, - |chunk, encrypted| { - plaintext = Some( - index - .decrypt(chunk, &encrypted) - .map_err(ReadError::Invalid)?, - ); - Ok(()) - }, - ) - .await?; - Ok(plaintext.expect("a completed pass delivers its one chunk")) -} - #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] enum PipelineEvent { Written(Result<(), X>), @@ -726,8 +717,8 @@ where } /// Deferred retry rounds over records, preserving each record's last typed -/// fetch error. Records reach `on_record` unhashed: content chunks are -/// authenticated by decryption, and the whole-file path verifies them there. +/// fetch error. Records reach `on_record` unhashed: the fetcher verifies +/// addresses, and decryption authenticates every record. async fn fetch_records_deferred( requests: Vec, fetch: &F, @@ -965,9 +956,8 @@ mod tests { ) .await .unwrap_err(); - assert!( - matches!(error, ReadError::Invalid(message) if message.contains("BLAKE3 mismatch")) - ); + // self_encryption finds no record matching the map's addresses. + assert!(matches!(error, ReadError::Invalid(_))); let error = download( &map, &|_| async { Err::(42u8) }, @@ -1360,27 +1350,56 @@ mod tests { sleeps.borrow_mut().push(delay.as_secs()); async {} }; - let plaintext = fetch_chunk(&index, 1, &fetch_after(4), &sleep, |_| true) - .await - .unwrap(); + // One chunk on its own, as each pipeTo fetch is. + let mut plaintext = None; + for_each_chunk( + &index, + 1..2, + &fetch_after(4), + &|| 1, + &sleep, + |_| true, + |_, chunk| { + plaintext = Some(chunk); + Ok(()) + }, + ) + .await + .unwrap(); let span = index.chunk_span(1); assert_eq!( - plaintext, + plaintext.unwrap(), content.slice(span.start as usize..span.end as usize) ); assert_eq!(*sleeps.borrow(), vec![15, 45]); attempts.set(0); - let error = fetch_chunk(&index, 1, &fetch_after(5), &sleep, |_| true) - .await - .unwrap_err(); + let error = for_each_chunk( + &index, + 1..2, + &fetch_after(5), + &|| 1, + &sleep, + |_| true, + |_, _| Ok(()), + ) + .await + .unwrap_err(); assert!(matches!(error, ReadError::Fetch(message) if message == "missing")); assert_eq!(attempts.get(), DEFERRED_ROUND_DELAYS_SECS.len()); attempts.set(0); - let error = fetch_chunk(&index, 1, &fetch_after(5), &sleep, |_| false) - .await - .unwrap_err(); + let error = for_each_chunk( + &index, + 1..2, + &fetch_after(5), + &|| 1, + &sleep, + |_| false, + |_, _| Ok(()), + ) + .await + .unwrap_err(); assert!(matches!(error, ReadError::Fetch(_))); assert_eq!(attempts.get(), 1); } diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index c36623d4..e600e5d0 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -377,7 +377,8 @@ impl Client { } /// Download a plaintext byte range. Resolves child maps first, then fetches - /// and decrypts only the records overlapping the range, one chunk at a time. + /// only the records overlapping the range, concurrently, decrypting each + /// chunk as it completes. /// Length is clamped at EOF; a start at or beyond EOF returns empty bytes. /// /// # Errors @@ -464,15 +465,13 @@ impl Client { index: &crate::client_engine::files::FileIndex, chunk: usize, ) -> Result { - crate::client_engine::files::fetch_chunk( - index, - chunk, - &|address| self.fetch_data_record(address), - &crate::runtime::sleep, - retry_data_fetch, - ) - .await - .map_err(map_read_error) + let mut plaintext = None; + self.data_download_indexed_chunks(index, chunk..chunk + 1, 1, |_, content| { + plaintext = Some(content); + Ok(()) + }) + .await?; + Ok(plaintext.expect("a completed pass delivers its one chunk")) } /// Fetch and decrypt whole chunks in one deferred retry pass, handing each diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index e0aeba44..b52c9af2 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -209,8 +209,9 @@ test("pipeTo keeps fetching behind a blocked write only up to its 32 MiB buffer" test("invalid pipeTo calls abort the destination they were given", async () => { const { client } = fixture(); + let reader; try { - const reader = await client.openPublicFile(large.address); + reader = await client.openPublicFile(large.address); for (const [options, expected, close] of [ [{ end: large.size + 1 }, /outside the file/], [{ start: 10, end: 5 }, /outside the file/], @@ -228,7 +229,7 @@ test("invalid pipeTo calls abort the destination they were given", async () => { assert.equal(closed, false); assert.equal(sink.locked, false); } - } finally { client.close(); } + } finally { reader?.close(); client.close(); } }); test("an AbortSignal cancels one pipeTo and leaves its reader open", async () => { @@ -259,8 +260,9 @@ test("closing a reader from the final progress callback keeps the completed file const original = new TextEncoder().encode("Final progress callback.".repeat(200)); const encrypted = encryptPublicFile(original); const { client } = fixture(encrypted.records); + let reader; try { - const reader = await client.openPublicFile(encrypted.address); + reader = await client.openPublicFile(encrypted.address); const chunks = []; let closed = false, aborted = false; const sink = new WritableStream({ write(bytes) { chunks.push(bytes); }, close() { closed = true; }, abort() { aborted = true; } }); @@ -274,7 +276,7 @@ test("closing a reader from the final progress callback keeps the completed file assert(closed); assert(!aborted); assert.deepEqual(Buffer.concat(chunks), Buffer.from(original)); - } finally { client.close(); } + } finally { reader?.close(); client.close(); } }); test("cancelling during a missing-record retry does not wait out the retry rounds", async () => { @@ -285,15 +287,16 @@ test("cancelling during a missing-record retry does not wait out the retry round try { for (const cancel of ["close", "signal"]) { const reader = await client.openPublicFile(encrypted.address); - const controller = new AbortController(); - const started = Date.now(); - const pending = cancel === "close" - ? reader.readRange(0, original.length) - : reader.pipeTo(new WritableStream(), { signal: controller.signal }); - setTimeout(() => cancel === "close" ? reader.close() : controller.abort(new Error("stop")), 100); - await assert.rejects(pending, cancel === "close" ? /closed/ : /stop/); - assert(Date.now() - started < 5_000, `${cancel} took ${Date.now() - started} ms`); - reader.close(); + try { + const controller = new AbortController(); + const started = Date.now(); + const pending = cancel === "close" + ? reader.readRange(0, original.length) + : reader.pipeTo(new WritableStream(), { signal: controller.signal }); + setTimeout(() => cancel === "close" ? reader.close() : controller.abort(new Error("stop")), 100); + await assert.rejects(pending, cancel === "close" ? /closed/ : /stop/); + assert(Date.now() - started < 5_000, `${cancel} took ${Date.now() - started} ms`); + } finally { reader.close(); } } } finally { client.close(); } }); @@ -340,16 +343,16 @@ test("closing a reader leaves records another reader of the file uses cached", a const original = new TextEncoder().encode("Two readers, one file.".repeat(300)); const encrypted = encryptPublicFile(original); const { rtc, client } = fixture(encrypted.records); + let first, second; try { - const first = await client.openPublicFile(encrypted.address); - const second = await client.openPublicFile(encrypted.address); + first = await client.openPublicFile(encrypted.address); + second = await client.openPublicFile(encrypted.address); assert.deepEqual(await second.readRange(0, original.length), original); first.close(); const cached = gets(rtc); assert.deepEqual(await second.readRange(0, original.length), original); assert.equal(gets(rtc), cached, "the open reader's records stay cached"); - second.close(); - } finally { client.close(); } + } finally { first?.close(); second?.close(); client.close(); } }); test("writer rejection and reader cancellation stop a stream and release its lock", async () => { diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 12f32010..92125ea1 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -686,14 +686,15 @@ Memory follows the data actually decrypted, never a size a map declares. It uses the dependency's own `chacha20poly1305` and `brotli-decompressor` versions, and tests pin its output to `decrypt_chunk` and to native nested-map resolution, so a change to the dependency's KDF fails them; a bounded primitive -upstream would replace it. Native whole-file downloads still decrypt through -`self_encryption::decrypt`. +upstream would replace it. Native whole-file downloads and the legacy +whole-buffer `decryptPublicFile` binding still decrypt through `self_encryption`, +so neither bounds decompression. The fetch layer verifies every record's address. The engine does not hash -content records again: ChaCha20-Poly1305 authentication and the plaintext hash -reject an altered record, and decompression stops at the declared size. The -native whole-file path keeps its own record check before handing records to -`self_encryption`. +records again: ChaCha20-Poly1305 authentication and the plaintext hash reject +an altered content or nested DataMap record, and decompression stops at the +declared size. On the native whole-file path, `self_encryption` matches records +to the map by content hash and authenticates them itself. Reads are chunk-aligned. A shared engine primitive fetches a set of chunks in one deferred retry pass and hands each decrypted chunk on as it completes, so a From 6f7802a78d2d2637e2ac7f235b21728a0f05265b Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:11:30 +0200 Subject: [PATCH 08/15] feat(wasm): run read-ahead over the 64-bit file index Read-ahead from main planned its windows over a usize RecordLayout, which wraps at 4 GiB on wasm32. It now shares the reader's FileIndex, so windows, anchors and recent read ends use 64-bit positions, and the reader moved to download.rs takes reads through it again: readRange borrows records read-ahead holds and stops read-ahead when records fail to decrypt, and closing or dropping the reader releases them. openPublicFile/openPrivateFile keep their streaming option. A DataMap whose range reads reject, such as one with non-contiguous indices, now fails to open instead of opening with read-ahead off, because the reader validates its index when it opens; the test from main is updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- ant-core/src/browser/wasm_transport.rs | 13 ++- .../src/browser/wasm_transport/download.rs | 52 ++++++++--- .../src/browser/wasm_transport/read_ahead.rs | 40 +++++---- ant-core/src/client_engine/files.rs | 86 ++----------------- ant-core/src/client_engine/read_ahead.rs | 23 ++--- ant-core/src/data/client/data.rs | 23 ++++- ant-core/wasm-tests/download.test.mjs | 11 ++- .../ADR-0004-direct-browser-read-client.md | 16 +++- 9 files changed, 128 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index c59ff88e..20b80cae 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ destination on success, aborts it on any failure (including invalid options), and releases the writer lock; a failure does not wait for a stalled destination to finish aborting. Its result contains the byte count and BLAKE3 of the written range. An `AbortSignal` cancels that one operation and leaves the reader open. -`reader.close()` cancels the reader's operations at once, including fetches +`reader.close()` releases the records its read-ahead holds and cancels the +reader's operations at once, including fetches waiting to be retried, until every byte is written. After that only the signal can cancel, and a destination that has started closing may already be committed. Closing leaves records in the client's shared cache, which is diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index bb256eae..bff164ba 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2681,7 +2681,12 @@ impl BrowserNetworkClient { resolved.file.size, resolved.index.chunk_count() )); - Ok(BrowserFileReader::new(Rc::clone(&self.shared), resolved)) + Ok(BrowserFileReader::new( + Rc::clone(&self.shared), + resolved, + &self.read_ahead, + options.streaming, + )) } /// Errors are JavaScript values so an AbortSignal's reason reaches the caller. @@ -2700,9 +2705,11 @@ impl BrowserNetworkClient { }) .await?; let data_map_node = resolved.data_map_node.clone(); - let reader = BrowserFileReader::new(Rc::clone(&self.shared), resolved); + // A complete download reads no ranges, so its read-ahead stays idle. + let reader = + BrowserFileReader::new(Rc::clone(&self.shared), resolved, &self.read_ahead, false); let (content, hash) = reader.collect(&settings, &mut cancel).await?; - let file = reader.into_descriptor(hash.clone()); + let file = reader.descriptor(hash.clone()); settings .progress .report(&format!("Verified complete {} as {hash}", file.name)); diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index c3c86f8c..a9ed97cc 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -344,7 +344,9 @@ pub struct BrowserFileReader { shared: Rc, /// Descriptor without its chunk list; the index holds the chunk metadata. file: PublicFileDescriptor, - index: FileIndex, + index: Rc, + /// Fetches ahead of sequential range reads; see `read_ahead`. + pub(super) read_ahead: Rc, closed: watch::Sender, } @@ -379,12 +381,21 @@ impl BrowserFileReader { ))); } let mut cancel = Cancellation::new(Some(self.closed.subscribe()), JsValue::UNDEFINED)?; + let lease = self.read_ahead.begin_read(start, length as usize); let bytes = cancel .run(async { - self.shared - .data_download_indexed_range(&self.index, start, length as usize) - .await - .map_err(js_string) + let bytes = self + .shared + .data_download_indexed_range(&self.index, start, length as usize, |address| { + lease.record(address) + }) + .await; + if matches!(bytes, Err(crate::data::Error::Encryption(_))) { + // The records do not decrypt as the DataMap declares, so + // reading further ahead would only spend bandwidth and memory. + self.read_ahead.close(); + } + bytes.map_err(js_string) }) .await?; Ok(Uint8Array::from(bytes.as_ref())) @@ -429,27 +440,44 @@ impl BrowserFileReader { result } - /// Close the reader and cancel its operations. Cached records stay in the - /// client's shared cache, which is bounded and may serve other readers. - /// Use an AbortSignal to cancel a single pipeTo without closing. + /// Close the reader: cancel its operations and release the records its + /// read-ahead holds. Records in the client's shared cache stay; that cache + /// is bounded and may serve other readers. Use an AbortSignal to cancel a + /// single pipeTo without closing. pub fn close(&self) { self.closed.send_replace(true); + self.read_ahead.close(); + } +} + +impl Drop for BrowserFileReader { + fn drop(&mut self) { + self.read_ahead.close(); } } impl BrowserFileReader { - pub(super) fn new(shared: Rc, resolved: ResolvedBrowserFile) -> Self { + /// `streaming` treats every read as part of a sequential stream for + /// read-ahead, as media playback needs. + pub(super) fn new( + shared: Rc, + resolved: ResolvedBrowserFile, + read_ahead: &Rc, + streaming: bool, + ) -> Self { + let index = Rc::new(resolved.index); Self { shared, file: resolved.file, - index: resolved.index, + read_ahead: read_ahead::ReadAhead::new(read_ahead, Rc::clone(&index), streaming), + index, closed: watch::Sender::new(false), } } /// The descriptor of a completely read file, with its chunk list and hash. - pub(super) fn into_descriptor(self, hash: String) -> PublicFileDescriptor { - let mut file = self.file; + pub(super) fn descriptor(&self, hash: String) -> PublicFileDescriptor { + let mut file = self.file.clone(); file.chunks = self .index .chunk_infos() diff --git a/ant-core/src/browser/wasm_transport/read_ahead.rs b/ant-core/src/browser/wasm_transport/read_ahead.rs index a61ec3ee..5cedc7e0 100644 --- a/ant-core/src/browser/wasm_transport/read_ahead.rs +++ b/ant-core/src/browser/wasm_transport/read_ahead.rs @@ -15,13 +15,13 @@ //! read waited for inform the reads' adaptive concurrency, and read-ahead keeps //! its records out of the shared chunk cache. use super::{BrowserNetworkCore, SharedNetworkAdapter}; -use crate::client_engine::files::RecordLayout; +use crate::client_engine::files::FileIndex; use crate::client_engine::read_ahead::releasable; use crate::client_engine::read_budget::ReadBudget; use crate::data::{ChunkCache, Client, ClientConfig, Error, Network}; use bytes::Bytes; use futures::future::{AbortHandle, Abortable, FutureExt, LocalBoxFuture, Shared}; -use self_encryption::{DataMap, MAX_CHUNK_SIZE}; +use self_encryption::MAX_CHUNK_SIZE; use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::ops::Range; @@ -207,13 +207,14 @@ impl ReadAheadPool { /// Read-ahead of one file reader. pub(super) struct ReadAhead { pool: Rc, - layout: RecordLayout, + /// The reader's index of the file, shared with it. + layout: Rc, /// Every read starts a sequential stream, as media playback's reads do. streaming: bool, /// Ends of recent reads; a read starting at one continues it. - recent_ends: RefCell>, + recent_ends: RefCell>, /// Start of the latest sequential read; the read-ahead window starts here. - anchor: Cell>, + anchor: Cell>, /// Records of each read in progress, by read id. Like the window, they are /// never cancelled or released. reads: RefCell>>, @@ -228,14 +229,13 @@ pub(super) struct ReadAhead { } impl ReadAhead { - /// Read-ahead for one reader of `root`. A DataMap whose layout range reads - /// reject gets read-ahead that never fetches, so the reader still opens and - /// its reads report the error. - pub(super) fn new(pool: &Rc, root: &DataMap, streaming: bool) -> Rc { - let (layout, closed) = match RecordLayout::new(root) { - Ok(layout) => (layout, false), - Err(_) => (RecordLayout::default(), true), - }; + /// Read-ahead for one reader of the file `layout` indexes. Positions are + /// 64-bit, so read-ahead follows reads past 4 GiB on every platform. + pub(super) fn new( + pool: &Rc, + layout: Rc, + streaming: bool, + ) -> Rc { let reader = Rc::new(Self { pool: Rc::clone(pool), layout, @@ -247,7 +247,7 @@ impl ReadAhead { last_read: Cell::new(Instant::now()), slots: RefCell::default(), next_id: Cell::new(0), - closed: Cell::new(closed), + closed: Cell::new(false), }); pool.readers.borrow_mut().push(Rc::downgrade(&reader)); reader @@ -263,8 +263,10 @@ impl ReadAhead { /// the start of the file also fetches the last record, where containers such /// as MP4 and WebM often keep the index a player reads next. A read that /// needs no records, such as one of zero length, changes nothing. - pub(super) fn begin_read(self: &Rc, start: usize, length: usize) -> ReadLease { - let needed = self.layout.overlapping(start, length); + pub(super) fn begin_read(self: &Rc, start: u64, length: usize) -> ReadLease { + let needed = self + .layout + .chunks_overlapping(start..start.saturating_add(length as u64)); if self.closed.get() || needed.is_empty() { return ReadLease { read_ahead: Rc::clone(self), @@ -309,11 +311,11 @@ impl ReadAhead { } if sequential { self.fill(); - } else if needed.end < self.layout.len() { + } else if needed.end < self.layout.chunk_count() { self.prefetch(needed.end); } if self.streaming && start == 0 { - if let Some(last) = self.layout.len().checked_sub(1) { + if let Some(last) = self.layout.chunk_count().checked_sub(1) { self.prefetch(last); } } @@ -322,7 +324,7 @@ impl ReadAhead { if recent.len() == RECENT_READS { recent.pop_front(); } - recent.push_back(start.saturating_add(length)); + recent.push_back(start.saturating_add(length as u64)); } ReadLease { read_ahead: Rc::clone(self), diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 5f331d45..e102d216 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -210,64 +210,6 @@ where self_encryption::decrypt(&root, &chunks).map_err(|e| ReadError::Invalid(e.to_string())) } -/// Plaintext layout of a resolved root DataMap, in record order. Chunk sizes are -/// read from the native DataMap; checked sums avoid overflow on untrusted maps. -#[derive(Default)] -pub(crate) struct RecordLayout { - /// Plaintext end offset and content address of each record. - records: Vec<(usize, [u8; 32])>, -} - -impl RecordLayout { - pub(crate) fn new(map: &DataMap) -> Result { - if map.is_child() { - return Err("range reads require a resolved root DataMap".into()); - } - let mut infos = map.infos().to_vec(); - infos.sort_by_key(|info| info.index); - let mut end = 0usize; - let mut records = Vec::with_capacity(infos.len()); - for (index, info) in infos.into_iter().enumerate() { - if info.index != index { - return Err("DataMap chunk indices must be contiguous".into()); - } - end = end - .checked_add(info.src_size) - .ok_or("DataMap plaintext size overflow")?; - records.push((end, info.dst_hash.0)); - } - Ok(Self { records }) - } - - pub(crate) fn len(&self) -> usize { - self.records.len() - } - - /// Plaintext size of the file. - pub(crate) fn size(&self) -> usize { - self.records.last().map_or(0, |(end, _)| *end) - } - - pub(crate) fn address(&self, index: usize) -> [u8; 32] { - self.records[index].1 - } - - /// Index of the record holding plaintext byte `offset`; `len()` at or past EOF. - pub(crate) fn record_at(&self, offset: usize) -> usize { - self.records.partition_point(|(end, _)| *end <= offset) - } - - /// Records overlapping the plaintext range `[start, start + length)`. - pub(crate) fn overlapping(&self, start: usize, length: usize) -> Range { - let first = self.record_at(start); - if length == 0 { - return first..first; - } - let last = self.record_at(start.saturating_add(length - 1)); - first..(last + 1).min(self.len()) - } -} - /// Validated root-map index. File positions are independent of the platform's /// pointer width; only individual chunk buffers and indices use `usize`. /// Build once per reader so each seek is O(log chunks + overlapping chunks). @@ -368,6 +310,11 @@ impl FileIndex { self.dst_hashes.len() } + /// Encrypted record address of one chunk. + pub(crate) fn address(&self, chunk: usize) -> [u8; 32] { + self.dst_hashes[chunk].0 + } + /// The size of the largest chunk. pub(crate) fn largest_chunk(&self) -> u64 { self.offsets @@ -1000,28 +947,6 @@ mod tests { assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1); } - #[test] - fn layout_maps_plaintext_ranges_to_records() { - let infos = [10, 10, 10] - .into_iter() - .enumerate() - .map(|(index, src_size)| self_encryption::ChunkInfo { - index, - dst_hash: XorName([index as u8 + 1; 32]), - src_hash: XorName([0; 32]), - src_size, - }) - .collect(); - let layout = RecordLayout::new(&DataMap::new(infos)).unwrap(); - assert_eq!(layout.size(), 30); - assert_eq!(layout.overlapping(0, 10), 0..1); - assert_eq!(layout.overlapping(9, 2), 0..2); - assert_eq!(layout.overlapping(10, 25), 1..3); - assert_eq!(layout.overlapping(25, 0), 2..2); - assert_eq!(layout.overlapping(30, 10), 3..3); - assert_eq!(layout.address(1), [2; 32]); - } - #[test] fn malformed_range_maps_are_rejected() { let (_, map, _) = fixture(); @@ -1087,6 +1012,7 @@ mod tests { index.dst_hashes, infos.iter().map(|info| info.dst_hash).collect::>() ); + assert_eq!(index.address(2), infos[2].dst_hash.0); assert_eq!(index.largest_chunk(), size as u64); // Chunk sizes are not capped natively; browsers apply their own limit. let mut infos = infos; diff --git a/ant-core/src/client_engine/read_ahead.rs b/ant-core/src/client_engine/read_ahead.rs index 99e3054b..c72b0fb9 100644 --- a/ant-core/src/client_engine/read_ahead.rs +++ b/ant-core/src/client_engine/read_ahead.rs @@ -1,19 +1,19 @@ //! Read-ahead planning for random-access file readers. Transport-neutral: the //! adapter fetches the records a plan names and releases the ones it marks. //! DataMap sizes are untrusted, so plans bound records, never declared bytes. -use super::files::RecordLayout; +use super::files::FileIndex; #[cfg(test)] use self_encryption::{ChunkInfo, DataMap}; use std::ops::Range; #[cfg(test)] use xor_name::XorName; -impl RecordLayout { +impl FileIndex { /// The read-ahead window: records from the one holding `start` that overlap /// `length` plaintext bytes, at most `max_records`. Undersized records /// shorten the window rather than lengthen it. - pub(crate) fn window(&self, start: usize, length: usize, max_records: usize) -> Range { - let records = self.overlapping(start, length); + pub(crate) fn window(&self, start: u64, length: usize, max_records: usize) -> Range { + let records = self.chunks_overlapping(start..start.saturating_add(length as u64)); records.start..records.end.min(records.start.saturating_add(max_records)) } } @@ -44,7 +44,7 @@ pub(crate) fn releasable( mod tests { use super::*; - fn layout(sizes: &[usize]) -> RecordLayout { + fn layout(sizes: &[usize]) -> FileIndex { let infos = sizes .iter() .enumerate() @@ -55,16 +55,19 @@ mod tests { src_size, }) .collect(); - RecordLayout::new(&DataMap::new(infos)).unwrap() + FileIndex::new(&DataMap::new(infos)).unwrap() } #[test] fn undersized_records_shorten_the_window_instead_of_lengthening_it() { - let layout = layout(&[10; 40]); + let small = layout(&[10; 40]); // Every record overlaps the window's bytes; only the record cap is taken. - assert_eq!(layout.window(0, 1_000, 10), 0..10); - assert_eq!(layout.window(15, 25, 10), 1..4); - assert_eq!(layout.window(400, 10, 10), 40..40); + assert_eq!(small.window(0, 1_000, 10), 0..10); + assert_eq!(small.window(15, 25, 10), 1..4); + assert!(small.window(400, 10, 10).is_empty()); + // Positions past 4 GiB address records exactly on every platform. + let large = layout(&[1 << 30; 6]); + assert_eq!(large.window((1 << 32) + 1, 1, 10), 4..5); } #[test] diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index e600e5d0..9c232cd3 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -437,19 +437,34 @@ impl Client { .map_err(map_read_error) } - /// Read a range through a validated index reused across browser seeks. + /// Read a range through a validated index reused across browser seeks, + /// taking records from `held` and fetching those it resolves to `None`. A + /// record that `held` resolves to an error counts as a failed fetch + /// attempt, which the read retries by fetching. #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] - pub(crate) async fn data_download_indexed_range( + pub(crate) async fn data_download_indexed_range( &self, index: &crate::client_engine::files::FileIndex, start: u64, length: usize, - ) -> Result { + held: impl Fn([u8; 32]) -> H, + ) -> Result + where + H: std::future::Future>>, + { crate::client_engine::files::read_indexed_range( index, start, length, - &|address| self.fetch_data_record(address), + &|address| { + let held = held(address); + async move { + match held.await { + Some(result) => result, + None => self.fetch_data_record(address).await, + } + } + }, &|| self.controller().fetch.current(), &crate::runtime::sleep, retry_data_fetch, diff --git a/ant-core/wasm-tests/download.test.mjs b/ant-core/wasm-tests/download.test.mjs index 42cfa57b..ad492784 100644 --- a/ant-core/wasm-tests/download.test.mjs +++ b/ant-core/wasm-tests/download.test.mjs @@ -368,7 +368,7 @@ test("a read in progress keeps its records when another read needs room", async } finally { reader?.close(); client.close(); } }); -test("a DataMap whose records range reads reject still opens", async () => { +test("a DataMap whose records range reads reject fails to open", async () => { const records = [0, 1, 2].map(index => { const content = Uint8Array.from({ length: 1024 }, (_, byte) => (byte + index) & 0xff); return { address: contentAddress(content), content }; @@ -380,12 +380,11 @@ test("a DataMap whose records range reads reject still opens", async () => { const rtc = mockWebRtc([{}]); for (const record of [...records, map]) rtc.stores[0].set(record.address, record.content); const client = new BrowserNetworkClient(rtc.endpoints); - let reader; try { - reader = await client.openPublicFile(map.address, undefined, { streaming: true }); - assert.equal(reader.size, 3 * 1024); - await assert.rejects(reader.readRange(0, 10), /contiguous/); - } finally { reader?.close(); client.close(); } + // The reader's index is validated when it opens, before any read-ahead. + await assert.rejects(client.openPublicFile(map.address, undefined, { streaming: true }), /contiguous/); + assert.equal(rtc.requests.filter(r => r.method === "get_chunk").length, 1, "only the DataMap was fetched"); + } finally { client.close(); } }); test("read-ahead retries a failed record only after another read", async () => { diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 92125ea1..da45f19e 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -734,14 +734,22 @@ one operation without closing the reader. Closing the reader cancels its operations at once, including fetches waiting to be retried, until every byte is written; it may then be closed from the final progress callback. After that only the signal can cancel, and a destination that has started closing may -already be committed. Closing a reader no longer clears the client's shared -cache: that cache is bounded (32 MiB) and may hold records other readers are -using, including readers of the same content opened another way, so its LRU -policy releases records instead. Cancellation is checked before an operation is resumed, so a cancelled +already be committed. Closing a reader releases the records its read-ahead +holds but no longer clears the client's shared cache: that cache is bounded +(32 MiB) and may hold records other readers are using, including readers of the +same content opened another way, so its LRU policy releases records instead. +Cancellation is checked before an operation is resumed, so a cancelled operation never completes or refills the cache afterwards. The core does not open file pickers, own browser filesystem policy, or change the companion SDK. +Read-ahead, described under streaming reads, now plans its windows over the +reader's 64-bit index rather than a `usize` record layout, so it follows reads +past 4 GiB on wasm32. The reader validates that index when it opens. A DataMap +whose range reads reject, such as one with non-contiguous indices, therefore no +longer opens with read-ahead off: opening it fails with the same error, before +any content record is fetched. + Removing only the 1 GB check was rejected because wasm32 offsets would still truncate around 4 GiB. Whole-file WASM buffering was rejected because it couples supported file size to linear-memory capacity. Disk-only output was rejected From f0b1199fd7445493d304e3cdac35daa0ebe5ebe6 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:20:37 +0200 Subject: [PATCH 09/15] refactor(wasm): simplify large-file downloads to what V2-1398 needs A review for over-engineering found about a third of the branch was machinery beyond the ticket. This removes it: - Decrypt chunks with self_encryption::decrypt_chunk again and resolve nested DataMaps with main's resolver, dropping the bounded decryption copy, the bincode resolver, the browser chunk and nested-map caps, and the bincode, chacha20poly1305 and brotli-decompressor dependencies. Bounding crafted-record decompression is left to self_encryption. - Run complete downloads and pipeTo through one ordered stream: buffered fetches handed to the writer over a one-slot channel, holding at most 32 MiB. This replaces ordered_pipeline, OrderedHasher and its read-back. - Restore downloadPublicFile(file, concurrency?, onProgress?) with a trailing { maxMemoryBytes?, signal? }, removing the dual-type argument, its ToUint32 emulation and the unknown-option checks. - Cancel only through an AbortSignal; close() makes later calls fail. - Validate pipeTo options before taking the writer, so invalid calls leave the destination untouched, as ReadableStream.pipeTo does. - Keep data_download_range's u64 path internal and make the complete download a free function that registers no read-ahead. - Restore main's record verification and retry helpers unchanged. The WASM mock re-entered WASM before copying an outgoing message, so a large record store could grow memory and detach it; copy it first. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 - README.md | 62 +- ant-core/Cargo.toml | 6 - ant-core/src/browser.rs | 11 - ant-core/src/browser/manifest.rs | 7 +- ant-core/src/browser/wasm_transport.rs | 73 +- .../src/browser/wasm_transport/download.rs | 661 ++++++-------- ant-core/src/client_engine.rs | 1 - ant-core/src/client_engine/chunk_decrypt.rs | 152 ---- ant-core/src/client_engine/files.rs | 816 +++--------------- ant-core/src/data/client/data.rs | 78 +- ant-core/src/data/client/file/native.rs | 2 - ant-core/wasm-tests/README.md | 16 +- ant-core/wasm-tests/large-download.test.mjs | 146 +--- ant-core/wasm-tests/mock-webrtc.mjs | 6 +- .../ADR-0004-direct-browser-read-client.md | 146 ++-- 16 files changed, 594 insertions(+), 1592 deletions(-) delete mode 100644 ant-core/src/client_engine/chunk_decrypt.rs diff --git a/Cargo.lock b/Cargo.lock index 857cdfb2..9da2f580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,11 +849,8 @@ dependencies = [ "async-trait", "axum", "base64", - "bincode", "blake3", - "brotli-decompressor", "bytes", - "chacha20poly1305", "console_error_panic_hook", "fips204", "flate2", diff --git a/README.md b/README.md index 20b80cae..5c03a102 100644 --- a/README.md +++ b/README.md @@ -39,26 +39,17 @@ Browser downloads and range reads have no 1 GB file cap. File sizes and offsets use 64-bit arithmetic internally; the JavaScript API accepts exact, nonnegative `number` positions through `Number.MAX_SAFE_INTEGER` (just under 8 PiB). The resolved DataMap and seek index still occupy memory proportional to the number -of chunks. Individual chunk buffers remain bounded, including for seeks past -4 GiB. Browser readers, downloads and `pipeTo` accept chunks of up to 16 MiB -and nested DataMap levels of up to 64 MiB, and decompress every record, -including nested DataMap records, only up to its declared size, so a crafted -record cannot expand inside WASM. The legacy whole-buffer `decryptPublicFile` -binding still decrypts through self_encryption without that limit. Native range -reads accept any chunk size. Uploads retain their existing size limits. - -`downloadPublicFile(file, { concurrency?, maxMemoryBytes?, onProgress?, signal? })` -and its private-file counterpart allocate the output as a JavaScript -`Uint8Array`. They fetch every chunk in one deferred retry pass at the requested -concurrency and copy each chunk into the output as soon as it is decrypted, so -WASM holds only the records in flight, never the whole plaintext or ciphertext. -Progress is still reported as `Downloaded chunk n/total`, and an `AbortSignal` -cancels the download at any point. The older `(file, concurrency?, onProgress?)` -form remains supported, with the same coercion of the concurrency number as -before. `maxMemoryBytes` limits the output buffer, not total process memory. Without a budget, -allocation is attempted up to the JavaScript engine's buffer limit. Allocation -errors reject with guidance to use streaming; browsers do not expose a -reliable portable amount of free memory. +of chunks; content buffers stay bounded, including for seeks past 4 GiB. +Uploads retain their existing size limits. + +`downloadPublicFile(file, concurrency?, onProgress?, { maxMemoryBytes?, signal? })` +and its private-file counterpart allocate the output as one JavaScript +`Uint8Array` and copy each decrypted chunk into it, holding at most 32 MiB of +plaintext in WASM. Progress is reported as `Downloaded chunk n/total`, and an +`AbortSignal` cancels the download. `maxMemoryBytes` limits the output buffer, +not total process memory; without it, allocation is attempted up to the +JavaScript engine's buffer limit. Allocation errors reject with guidance to use +streaming, since browsers do not expose a reliable amount of free memory. For large downloads, pass a `WritableStream` to `BrowserFileReader.pipeTo`. For example, after the application obtains a file handle from a user gesture: @@ -75,26 +66,17 @@ try { The same API works with `openPrivateFile({ data_map })`. `pipeTo(writable, { start?, end?, signal?, onProgress? })` optionally streams a half-open byte -range and reports `(bytesWritten, totalBytes)` after each write. It fetches -chunks concurrently and writes them in order, at most 4 MiB per write, each -awaited before the next. Fetching continues while a write is pending until -32 MiB of chunks are in flight or waiting, so a slow destination holds memory -at that bound. Each missing record is retried on its own schedule while the -rest of the buffer keeps fetching, so retry waits overlap. It closes the -destination on success, aborts it on any failure (including invalid options), -and releases the writer lock; a failure does not wait for a stalled destination -to finish aborting. Its result contains the byte count and BLAKE3 of the written -range. An `AbortSignal` cancels that one operation and leaves the reader open. -`reader.close()` releases the records its read-ahead holds and cancels the -reader's operations at once, including fetches -waiting to be retried, until every byte is written. After that only the signal -can cancel, and a destination that has started closing may already be -committed. Closing leaves records in the client's shared cache, which is -bounded (32 MiB) and may serve other readers. `readRange(start, length)` remains available for media -and custom streaming sinks, with a 4 MiB per-call limit. A sink must persist or -consume the bytes instead of accumulating them to keep memory bounded. The -application chooses a memory budget and supplies its disk destination; the core -does not open file pickers or silently select a path. +range and reports `(bytesWritten, totalBytes)` after each write. Chunks are +fetched concurrently and written in order, at most 4 MiB per write; fetching +continues during writes until 32 MiB of plaintext is held. Invalid calls reject +without touching the destination; otherwise the destination is closed on +success, aborted on failure, and its writer lock released. The result contains +the byte count and BLAKE3 of the written range, and an `AbortSignal` cancels +the call. `reader.close()` releases the records read-ahead holds and makes later +calls fail. `readRange(start, length)` remains available for media and custom +sinks, with a 4 MiB per-call limit. The application chooses a memory budget and +supplies its disk destination; the core does not open file pickers or silently +select a path. The adapters supply QUIC or WebRTC discovery/GET requests, runtime timers, caches, progress callbacks, and output handling. Browser descriptors and wallet diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index a60c257d..8abc470c 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -25,12 +25,6 @@ serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" self_encryption = "0.36" -# The same versions self_encryption uses, for per-chunk decryption with a -# bound on decompressed output (client_engine/chunk_decrypt.rs) and for -# decoding nested DataMaps from their chunks (client_engine/files.rs). -bincode = "1.3" -brotli-decompressor = { version = "2.3", default-features = false, features = ["std"] } -chacha20poly1305 = "0.10" # Portable WebRTC Direct profile shared with ant-node. saorsa-transport = { version = "0.37.0", default-features = false, features = ["webrtc"] } thiserror = "2" diff --git a/ant-core/src/browser.rs b/ant-core/src/browser.rs index fff38c15..39732dde 100644 --- a/ant-core/src/browser.rs +++ b/ant-core/src/browser.rs @@ -39,17 +39,6 @@ use std::collections::HashSet; /// while `pipeTo` and range reads hold a bounded amount of plaintext. pub const MAX_BROWSER_FILE_BYTES: usize = 1_000_000_000; -/// Largest plaintext chunk a browser read accepts from a DataMap: four times -/// the native chunk size. Maps from other chunk-size schemes stay readable, -/// while a hostile map cannot make one chunk reserve an arbitrarily large -/// buffer in WASM memory. -pub(crate) const MAX_BROWSER_CHUNK_BYTES: usize = 16 * 1024 * 1024; - -/// Largest decoded nested DataMap level a browser read resolves. A root map -/// with 262,144 chunks (about 1 TiB) encodes to roughly 21 MB. -#[cfg(all(target_arch = "wasm32", feature = "browser-wasm"))] -pub(crate) const MAX_BROWSER_NESTED_MAP_BYTES: usize = 64 * 1024 * 1024; - /// One native self-encryption chunk descriptor exposed to the browser. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BrowserChunkInfo { diff --git a/ant-core/src/browser/manifest.rs b/ant-core/src/browser/manifest.rs index 26c15f17..f7f7a13b 100644 --- a/ant-core/src/browser/manifest.rs +++ b/ant-core/src/browser/manifest.rs @@ -3,7 +3,6 @@ pub use super::protocol::BrowserPaymentNetwork; use super::protocol::{normalize_hex, parse_webrtc_direct_multiaddr, BrowserEndpoint}; use super::BrowserChunkInfo; -use super::MAX_BROWSER_CHUNK_BYTES; use crate::client_engine::files::MIN_FILE_CHUNKS; use serde::{Deserialize, Serialize}; @@ -187,7 +186,7 @@ fn normalize_file(file: &mut PublicFileDescriptor) -> Result<(), BrowserManifest chunk.dst_hash = normalize_hex(&chunk.dst_hash, 32).map_err(BrowserManifestError)?; chunk.src_hash = normalize_hex(&chunk.src_hash, 32).map_err(BrowserManifestError)?; // Sizes come from the DataMap, which may use another chunk size. - if !(1..=MAX_BROWSER_CHUNK_BYTES).contains(&chunk.src_size) { + if chunk.src_size == 0 { return Err(BrowserManifestError(format!( "invalid plaintext chunk size {}", chunk.src_size @@ -343,10 +342,6 @@ mod tests { .collect(), }; normalize_file(&mut file).unwrap(); - file.chunks[1].src_size = MAX_BROWSER_CHUNK_BYTES + 1; - file.size += (MAX_BROWSER_CHUNK_BYTES + 1 - large_chunk) as u64; - let error = normalize_file(&mut file).unwrap_err(); - assert!(error.to_string().contains("chunk size")); // Rejected on length alone, before the list is sorted or decoded. file.chunks = (0..=MAX_FILE_CHUNKS) diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index bff164ba..3453d016 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2408,39 +2408,48 @@ impl BrowserNetworkClient { } /// Download a public file into one JavaScript Uint8Array, using bounded - /// WASM buffers. `options` is - /// `{ concurrency?, maxMemoryBytes?, onProgress?, signal? }`: - /// `maxMemoryBytes` limits the output allocation and an AbortSignal - /// cancels the download. A number is the legacy concurrency argument, - /// paired with a positional `onProgress`. For disk-backed downloads use - /// openPublicFile and reader.pipeTo(writable). + /// WASM buffers. `options` is `{ maxMemoryBytes?, signal? }`: the largest + /// output buffer to allocate, and an AbortSignal that cancels the download. + /// For disk-backed downloads use openPublicFile and reader.pipeTo(writable). #[wasm_bindgen(js_name = downloadPublicFile)] pub async fn download_public_file( &self, file: JsValue, - options: Option, + concurrency: Option, on_progress: Option, + options: Option, ) -> Result { let file: BrowserPublicFileInput = serde_wasm_bindgen::from_value(file) .map_err(|error| JsValue::from_str(&error.to_string()))?; - self.download_file(BrowserFileSource::Public(file), options, on_progress) - .await + self.download_file( + BrowserFileSource::Public(file), + concurrency, + on_progress, + options, + ) + .await } /// Download a private file into one JavaScript Uint8Array. Takes the same - /// options as downloadPublicFile; openPrivateFile and pipeTo support files - /// that cannot fit in one JavaScript buffer. + /// arguments as downloadPublicFile; openPrivateFile and pipeTo support + /// files that cannot fit in one JavaScript buffer. #[wasm_bindgen(js_name = downloadPrivateFile)] pub async fn download_private_file( &self, file: JsValue, - options: Option, + concurrency: Option, on_progress: Option, + options: Option, ) -> Result { let file: BrowserPrivateFileInput = serde_wasm_bindgen::from_value(file) .map_err(|error| JsValue::from_str(&error.to_string()))?; - self.download_file(BrowserFileSource::Private(file), options, on_progress) - .await + self.download_file( + BrowserFileSource::Private(file), + concurrency, + on_progress, + options, + ) + .await } /// Resolve and validate a public file for random-access range reads. @@ -2659,10 +2668,11 @@ impl BrowserNetworkClient { async fn download_file( &self, source: BrowserFileSource, - options: Option, + concurrency: Option, on_progress: Option, + options: Option, ) -> Result { - let settings = download::DownloadSettings::from_js(options, on_progress) + let settings = download::DownloadSettings::new(concurrency, on_progress, options) .map_err(|error| JsValue::from_str(&error))?; let result = self.download_file_inner(source, settings).await?; serde_wasm_bindgen::to_value(&result).map_err(|error| JsValue::from_str(&error.to_string())) @@ -2696,7 +2706,7 @@ impl BrowserNetworkClient { settings: download::DownloadSettings, ) -> Result { // Validates the signal before any network work, and covers resolution. - let mut cancel = download::Cancellation::new(None, settings.signal.clone())?; + let mut cancel = download::Cancellation::new(settings.signal.clone())?; let resolved = cancel .run(async { self.resolve_file(source, &settings.progress) @@ -2704,12 +2714,9 @@ impl BrowserNetworkClient { .map_err(|error| JsValue::from_str(&error)) }) .await?; - let data_map_node = resolved.data_map_node.clone(); - // A complete download reads no ranges, so its read-ahead stays idle. - let reader = - BrowserFileReader::new(Rc::clone(&self.shared), resolved, &self.read_ahead, false); - let (content, hash) = reader.collect(&settings, &mut cancel).await?; - let file = reader.descriptor(hash.clone()); + let (content, hash) = + download::collect(&self.shared, &resolved.index, &settings, &mut cancel).await?; + let file = download::completed_descriptor(resolved.file, &resolved.index, hash.clone()); settings .progress .report(&format!("Verified complete {} as {hash}", file.name)); @@ -2717,7 +2724,7 @@ impl BrowserNetworkClient { content, hash, file, - data_map_node, + data_map_node: resolved.data_map_node, }) } @@ -2815,7 +2822,6 @@ impl BrowserNetworkClient { .map(|(content, _)| bytes::Bytes::from(content)) }, &|| self.shared.controller().fetch.current(), - super::MAX_BROWSER_NESTED_MAP_BYTES, ) .await .map_err(|error| error.to_string())?; @@ -2824,12 +2830,6 @@ impl BrowserNetworkClient { if index.size() > super::manifest::MAX_SAFE_JS_INTEGER { return Err("file size exceeds JavaScript's exact integer range".into()); } - if index.largest_chunk() > super::MAX_BROWSER_CHUNK_BYTES as u64 { - return Err(format!( - "DataMap chunks larger than {} bytes are not read in browsers", - super::MAX_BROWSER_CHUNK_BYTES - )); - } file.size = index.size(); file.chunks = Vec::new(); // Listed from the index once the file is read. @@ -2929,14 +2929,9 @@ impl BrowserNetworkClient { }; let encoded_map = fetch(parse_lookup_key(&staged.address, "DataMap address")?).await?; let map = crate::client_engine::files::decode_map(&encoded_map)?; - let root = crate::client_engine::files::resolve( - &map, - &fetch, - &|| 1, - super::MAX_BROWSER_NESTED_MAP_BYTES, - ) - .await - .map_err(|error| error.to_string())?; + let root = crate::client_engine::files::resolve(&map, &fetch, &|| 1) + .await + .map_err(|error| error.to_string())?; staged.chunks = super::chunk_infos(&root); staged.size = staged.chunks.iter().try_fold(0usize, |size, chunk| { size.checked_add(chunk.src_size).ok_or("file size overflow") diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index a9ed97cc..8b51ada1 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -1,27 +1,24 @@ //! Bounded plaintext reads with 64-bit positions and caller-owned destinations. use super::*; use crate::browser::manifest::MAX_SAFE_JS_INTEGER; -use crate::client_engine::files::{ordered_pipeline, FileIndex, OrderedHasher}; +use crate::client_engine::files::FileIndex; use bytes::Bytes; +use futures_channel::mpsc; +use futures_util::{stream, SinkExt, StreamExt}; use std::ops::Range; -use web_time::Instant; -/// Plaintext a `pipeTo` holds at once: chunks in flight or fetched but not yet -/// written. Fetching continues while writes are pending until this is reached. -const PIPE_BUFFER_BYTES: u64 = 32 * 1024 * 1024; +/// Plaintext a stream holds at once: chunks in flight, fetched, handed to the +/// writer or being written. Fetching continues during writes up to this bound. +const STREAM_BUFFER_BYTES: u64 = 32 * 1024 * 1024; +/// Chunks a stream holds outside its fetch buffer: one handed to the writer +/// and one being written. +const CHUNKS_OUTSIDE_FETCH_BUFFER: u64 = 2; const STREAM_HINT: &str = "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; const CLOSED_READER: &str = "browser file reader is closed"; const ABORTED: &str = "download aborted"; const ABORT_EVENT: &str = "abort"; const POSITIVE_CONCURRENCY: &str = "download concurrency must be a positive integer"; -const PIPE_OPTION_NAMES: [&str; 4] = ["start", "end", "signal", "onProgress"]; -const DOWNLOAD_OPTION_NAMES: [&str; 4] = ["concurrency", "maxMemoryBytes", "onProgress", "signal"]; -/// JavaScript's ToUint32 wraps numbers modulo 2^32. -const UINT32_RANGE: f64 = u32::MAX as f64 + 1.0; -/// Longest stretch of read-back hashing between event-loop turns. Yielding -/// after every chunk would pay the browser's nested-timer clamp each time. -const HASH_SLICE: Duration = Duration::from_millis(20); /// A nonnegative integer that JavaScript represents exactly. fn safe_integer(value: f64, label: &str) -> Result { @@ -35,20 +32,6 @@ fn safe_integer(value: f64, label: &str) -> Result { Ok(value as u64) } -/// `value >>> 0`, which wasm-bindgen applied to the numeric concurrency -/// argument before downloads took an options object. -fn legacy_concurrency(value: &JsValue) -> usize { - if value.is_symbol() || value.is_bigint() { - return 0; - } - // Unary `+`, the ToNumber step of `>>>`; it throws only for the excluded types. - let number = value.unchecked_into_f64(); - if !number.is_finite() { - return 0; - } - number.trunc().rem_euclid(UINT32_RANGE) as usize -} - fn js_string(error: impl std::fmt::Display) -> JsValue { JsValue::from_str(&error.to_string()) } @@ -59,44 +42,16 @@ fn method(object: &JsValue, name: &str) -> Result { .map_err(|_| JsValue::from_str(&format!("destination has no {name} method"))) } -/// An optional callback option: absent, or a function. -fn optional_function(value: JsValue, label: &str) -> Result, String> { - if value.is_undefined() || value.is_null() { - return Ok(None); - } - value - .dyn_into::() - .map(Some) - .map_err(|_| format!("{label} must be a function")) -} - -/// Deserialize an optional options object; `undefined` and `null` select -/// defaults. serde_wasm_bindgen reads only declared fields, so a misspelt -/// option is rejected here instead of being silently ignored. +/// An optional options object; `undefined` and `null` select the defaults. fn options_from_js( - value: JsValue, - known: &[&str], + value: Option, ) -> Result { - if value.is_undefined() || value.is_null() { - return Ok(T::default()); - } - if let Some(object) = value.dyn_ref::() { - for key in js_sys::Object::keys(object) { - let key = key.as_string().unwrap_or_default(); - if !known.contains(&key.as_str()) { - return Err(format!( - "unknown option `{key}`, expected one of {}", - known.join(", ") - )); - } + match value { + Some(value) if !value.is_undefined() && !value.is_null() => { + serde_wasm_bindgen::from_value(value).map_err(|error| error.to_string()) } + _ => Ok(T::default()), } - serde_wasm_bindgen::from_value(value).map_err(|error| error.to_string()) -} - -/// Let the page handle events between long stretches of synchronous work. -async fn yield_to_event_loop() { - TimeoutFuture::new(0).await; } /// Release the writer even if the Rust future is dropped before it completes. @@ -117,7 +72,7 @@ struct PipeOptions { start: Option, /// Exclusive end of the written range; the file size by default. end: Option, - /// AbortSignal that cancels this operation without closing the reader. + /// AbortSignal that cancels this operation. #[serde(default, with = "serde_wasm_bindgen::preserve")] signal: JsValue, /// Called with `(bytesWritten, totalBytes)` after each write. @@ -132,72 +87,45 @@ struct PipeResult { hash: String, } -/// Complete-download options: -/// `{ concurrency?, maxMemoryBytes?, onProgress?, signal? }`. +/// Complete-download options: `{ maxMemoryBytes?, signal? }`. #[derive(Default, Deserialize)] #[serde(rename_all = "camelCase")] struct DownloadOptions { - /// Upper bound on concurrent record fetches. - concurrency: Option, /// Largest output buffer to allocate; larger files fail before transfer. max_memory_bytes: Option, - /// Called with progress messages. - #[serde(default, with = "serde_wasm_bindgen::preserve")] - on_progress: JsValue, /// AbortSignal that cancels the download. #[serde(default, with = "serde_wasm_bindgen::preserve")] signal: JsValue, } -/// Validated complete-download options. +/// Validated complete-download arguments. pub(super) struct DownloadSettings { - pub(super) concurrency: usize, - pub(super) memory_budget: Option, + concurrency: usize, + memory_budget: Option, pub(super) progress: ProgressReporter, /// AbortSignal, or `undefined` when the caller passed none. pub(super) signal: JsValue, } impl DownloadSettings { - /// `options` is an options object. Any other value is the legacy - /// concurrency argument, coerced as before, which pairs with the - /// positional `legacy_progress` callback. - pub(super) fn from_js( + pub(super) fn new( + concurrency: Option, + on_progress: Option, options: Option, - legacy_progress: Option, ) -> Result { - let options = options.unwrap_or(JsValue::UNDEFINED); - if !options.is_undefined() && !options.is_object() { - let concurrency = legacy_concurrency(&options); - if concurrency == 0 { - return Err(POSITIVE_CONCURRENCY.into()); - } - return Ok(Self { - concurrency, - memory_budget: None, - progress: ProgressReporter::from_js(legacy_progress), - signal: JsValue::UNDEFINED, - }); - } - let options: DownloadOptions = options_from_js(options, &DOWNLOAD_OPTION_NAMES)?; - let progress = optional_function(options.on_progress, "onProgress")?; - if progress.is_some() && legacy_progress.is_some() { - return Err("pass onProgress in the options or as an argument, not both".into()); - } - let concurrency = match options.concurrency { + let concurrency = match concurrency { None => usize::MAX, - Some(value) => match safe_integer(value, "download concurrency") { - Ok(0) | Err(_) => return Err(POSITIVE_CONCURRENCY.into()), - Ok(value) => usize::try_from(value).unwrap_or(usize::MAX), - }, + Some(0) => return Err(POSITIVE_CONCURRENCY.into()), + Some(concurrency) => concurrency, }; + let options: DownloadOptions = options_from_js(options)?; Ok(Self { concurrency, memory_budget: options .max_memory_bytes .map(|value| safe_integer(value, "memory budget")) .transpose()?, - progress: ProgressReporter::from_js(progress.or(legacy_progress)), + progress: ProgressReporter::from_js(on_progress), signal: options.signal, }) } @@ -258,72 +186,39 @@ impl Drop for AbortListener { } } -/// Stops an operation when its AbortSignal fires or, for a reader -/// operation, when the reader closes. -pub(super) struct Cancellation { - closed: Option>, - abort: Option, -} +/// Stops an operation when its optional AbortSignal fires. +pub(super) struct Cancellation(Option); impl Cancellation { - /// `closed` follows the reader an operation belongs to, if any. - pub(super) fn new( - closed: Option>, - signal: JsValue, - ) -> Result { - let abort = if signal.is_undefined() || signal.is_null() { - None - } else { - Some(AbortListener::new(signal)?) - }; - Ok(Self { closed, abort }) - } - - /// Stop following the reader; only the AbortSignal can cancel from here. - fn ignore_reader(&mut self) { - self.closed = None; + pub(super) fn new(signal: JsValue) -> Result { + if signal.is_undefined() || signal.is_null() { + return Ok(Self(None)); + } + AbortListener::new(signal).map(|listener| Self(Some(listener))) } - /// Fail with the abort reason, or the closed-reader error. + /// Fail with the abort reason once the signal has aborted. fn check(&self) -> Result<(), JsValue> { - if let Some(reason) = self.abort.as_ref().and_then(AbortListener::reason) { - return Err(reason); - } - if self.closed.as_ref().is_some_and(|closed| *closed.borrow()) { - return Err(JsValue::from_str(CLOSED_READER)); + match self.0.as_ref().and_then(AbortListener::reason) { + Some(reason) => Err(reason), + None => Ok(()), } - Ok(()) } - /// Run `operation` unless it is cancelled first. Cancellation is polled - /// before the operation, so once the reader closes or the signal aborts - /// the operation never runs again: it is dropped, stopping its in-flight - /// fetches and retry waits, before it can finish or refill the cache. + /// Run `operation` unless the signal aborts first. The signal is polled + /// before the operation, so an aborted operation is dropped, stopping its + /// fetches and retry waits, without running again. pub(super) async fn run( &mut self, operation: impl Future>, ) -> Result { self.check()?; + let Some(abort) = self.0.as_mut() else { + return operation.await; + }; let finished = { - let Self { closed, abort } = &mut *self; - let closed = async move { - match closed { - Some(closed) => { - let _ = closed.wait_for(|closed| *closed).await; - } - None => futures_util::future::pending::<()>().await, - } - }; - let aborted = async move { - match abort { - Some(abort) => { - let _ = (&mut abort.fired).await; - } - None => futures_util::future::pending::<()>().await, - } - }; - futures_util::pin_mut!(operation, closed, aborted); - match select(select(closed, aborted), operation).await { + futures_util::pin_mut!(operation); + match select(&mut abort.fired, operation).await { Either::Left(_) => None, Either::Right((result, _)) => Some(result), } @@ -332,11 +227,149 @@ impl Cancellation { Err(self .check() .err() - .unwrap_or_else(|| JsValue::from_str(CLOSED_READER))) + .unwrap_or_else(|| JsValue::from_str(ABORTED))) }) } } +/// Fetch and decrypt `chunks` and hand each to `write` in file order. Fetching +/// continues while a write is pending, and the chunks in flight, fetched, +/// handed over and being written together hold at most `STREAM_BUFFER_BYTES` +/// of plaintext. Each missing record is retried on its own schedule, so retry +/// waits overlap with the other fetches. +async fn stream_chunks( + shared: &crate::data::Client, + index: &FileIndex, + chunks: Range, + concurrency: usize, + mut write: W, +) -> Result<(), JsValue> +where + W: FnMut(usize, Bytes) -> WF, + WF: Future>, +{ + let buffered = (STREAM_BUFFER_BYTES / index.largest_chunk().max(1)) + .saturating_sub(CHUNKS_OUTSIDE_FETCH_BUFFER) + .max(1); + let buffered = usize::try_from(buffered) + .unwrap_or(usize::MAX) + .min(concurrency); + // A zero-capacity channel holds one chunk per sender. + let (mut handed, mut received) = mpsc::channel(0); + let fetch = async move { + let mut fetched = stream::iter(chunks) + .map(|chunk| async move { + let plaintext = shared.data_download_indexed_chunk(index, chunk).await; + (chunk, plaintext) + }) + .buffered(buffered); + while let Some(fetched) = fetched.next().await { + if handed.send(fetched).await.is_err() { + return; + } + } + }; + let deliver = async { + while let Some((chunk, plaintext)) = received.next().await { + write(chunk, plaintext.map_err(js_string)?).await?; + } + Ok(()) + }; + futures_util::pin_mut!(fetch, deliver); + match select(fetch, deliver).await { + // Every chunk has been handed over; finish writing them. + Either::Left(((), deliver)) => deliver.await, + Either::Right((delivered, _)) => delivered, + } +} + +/// Download a whole file into one JavaScript `Uint8Array`, writing each chunk +/// straight into it from WASM memory. A caller can impose its own memory +/// budget; allocation failures recommend the disk-backed path without +/// downloading the file first. +pub(super) async fn collect( + shared: &crate::data::Client, + index: &FileIndex, + settings: &DownloadSettings, + cancel: &mut Cancellation, +) -> Result<(Uint8Array, String), JsValue> { + let size = index.size(); + if settings.memory_budget.is_some_and(|limit| size > limit) { + return Err(js_string(format_args!( + "file exceeds the download memory budget; {STREAM_HINT}" + ))); + } + let constructor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Uint8Array"))? + .dyn_into::()?; + let args = Array::new(); + args.push(&JsValue::from_f64(size as f64)); + let output = js_sys::Reflect::construct(&constructor, &args) + .map_err(|error| { + js_string(format_args!( + "cannot allocate download buffer: {}; {STREAM_HINT}", + js_error_message(error) + )) + })? + .unchecked_into::(); + // Reflect calls preserve Number offsets above u32::MAX and catch JS + // allocation exceptions instead of turning them into WASM traps. + let subarray = method(output.as_ref(), "subarray")?; + let total = index.chunk_count(); + let mut written = 0usize; + let mut hasher = blake3::Hasher::new(); + settings + .progress + .report(&format!("Downloaded chunk {written}/{total}")); + cancel + .run(stream_chunks( + shared, + index, + 0..total, + settings.concurrency, + |chunk, plaintext| { + let span = index.chunk_span(chunk); + let placed = subarray + .call2( + output.as_ref(), + &JsValue::from_f64(span.start as f64), + &JsValue::from_f64(span.end as f64), + ) + .map(|view| { + view.unchecked_into::().copy_from(&plaintext); + hasher.update(&plaintext); + written += 1; + settings + .progress + .report(&format!("Downloaded chunk {written}/{total}")); + }); + std::future::ready(placed) + }, + )) + .await?; + Ok((output, hasher.finalize().to_hex().to_string())) +} + +/// The descriptor of a completely read file, with its chunk list and hash. +pub(super) fn completed_descriptor( + mut file: PublicFileDescriptor, + index: &FileIndex, + hash: String, +) -> PublicFileDescriptor { + file.chunks = index + .chunk_infos() + .map(|info| super::super::browser_chunk_info(&info)) + .collect(); + file.blake3 = hash; + file +} + +/// A validated `pipeTo` call. +struct PipePlan { + range: Range, + progress: Option, + cancel: Cancellation, +} + /// Random-access file reader. Content memory is bounded independently of file /// size. The resolved chunk map and seek index remain resident in memory. #[wasm_bindgen(js_name = BrowserFileReader)] @@ -347,7 +380,7 @@ pub struct BrowserFileReader { index: Rc, /// Fetches ahead of sequential range reads; see `read_ahead`. pub(super) read_ahead: Rc, - closed: watch::Sender, + closed: Cell, } #[wasm_bindgen(js_class = BrowserFileReader)] @@ -370,9 +403,11 @@ impl BrowserFileReader { /// Read up to 4 MiB. Positions above 4 GiB are supported without truncation. /// Fractions, negatives, and integers not exactly representable in JS fail. - /// Closing the reader rejects a read in progress. #[wasm_bindgen(js_name = readRange)] pub async fn read_range(&self, start: f64, length: f64) -> Result { + if self.closed.get() { + return Err(JsValue::from_str(CLOSED_READER)); + } let start = safe_integer(start, "range start").map_err(js_string)?; let length = safe_integer(length, "range length").map_err(js_string)?; if length > MAX_BROWSER_RANGE_BYTES as u64 { @@ -380,51 +415,42 @@ impl BrowserFileReader { "browser range reads are limited to {MAX_BROWSER_RANGE_BYTES} bytes" ))); } - let mut cancel = Cancellation::new(Some(self.closed.subscribe()), JsValue::UNDEFINED)?; let lease = self.read_ahead.begin_read(start, length as usize); - let bytes = cancel - .run(async { - let bytes = self - .shared - .data_download_indexed_range(&self.index, start, length as usize, |address| { - lease.record(address) - }) - .await; - if matches!(bytes, Err(crate::data::Error::Encryption(_))) { - // The records do not decrypt as the DataMap declares, so - // reading further ahead would only spend bandwidth and memory. - self.read_ahead.close(); - } - bytes.map_err(js_string) + let bytes = self + .shared + .data_download_indexed_range(&self.index, start, length as usize, |address| { + lease.record(address) }) - .await?; + .await; + if matches!(bytes, Err(crate::data::Error::Encryption(_))) { + // The records do not decrypt as the DataMap declares, so reading + // further ahead would only spend bandwidth and memory. + self.read_ahead.close(); + } + let bytes = bytes.map_err(js_string)?; Ok(Uint8Array::from(bytes.as_ref())) } /// Write the file to a WritableStream (including a file-system writable). /// `options` is `{ start?, end?, signal?, onProgress? }`: an optional - /// half-open byte range, an AbortSignal that cancels this operation, and - /// a `(bytesWritten, totalBytes)` callback run after each write. + /// half-open byte range, an AbortSignal that cancels the operation, and a + /// `(bytesWritten, totalBytes)` callback run after each write. /// /// Chunks are fetched concurrently and written in order, at most 4 MiB per - /// write, each awaited before the next. Fetching continues during writes - /// until 32 MiB of chunks are in flight or waiting, and each missing record - /// is retried on its own schedule while the others continue. Closes the - /// destination on success, aborts it on any failure (including invalid - /// options), and always releases the writer lock. Returns the BLAKE3 and - /// byte count of the written range. Closing the reader also cancels until - /// every byte is written; after that only the AbortSignal can, and a - /// destination that has started closing may already be committed. + /// write, each awaited before the next; fetching continues during writes + /// until 32 MiB of plaintext is held. Invalid calls reject without touching + /// the destination. Otherwise the destination is closed on success and + /// aborted on failure, and the writer lock is always released. Returns the + /// BLAKE3 and byte count of the written range. #[wasm_bindgen(js_name = pipeTo)] pub async fn pipe_to( &self, writable: JsValue, options: Option, ) -> Result { + let plan = self.plan_pipe(options)?; let writer = Writer(method(&writable, "getWriter")?.call0(&writable)?); - let result = self - .pipe_to_writer(&writer.0, options.unwrap_or(JsValue::UNDEFINED)) - .await; + let result = self.pipe(&writer.0, plan).await; if let Err(error) = &result { if let Ok(abort) = method(&writer.0, "abort") { if let Ok(promise) = abort.call1(&writer.0, error) { @@ -440,12 +466,12 @@ impl BrowserFileReader { result } - /// Close the reader: cancel its operations and release the records its - /// read-ahead holds. Records in the client's shared cache stay; that cache - /// is bounded and may serve other readers. Use an AbortSignal to cancel a - /// single pipeTo without closing. + /// Close the reader and release the records its read-ahead holds. Later + /// calls fail; use an AbortSignal to cancel a pipeTo in progress. Records + /// in the client's shared cache stay; that cache is bounded and may serve + /// other readers. pub fn close(&self) { - self.closed.send_replace(true); + self.closed.set(true); self.read_ahead.close(); } } @@ -471,83 +497,89 @@ impl BrowserFileReader { file: resolved.file, read_ahead: read_ahead::ReadAhead::new(read_ahead, Rc::clone(&index), streaming), index, - closed: watch::Sender::new(false), + closed: Cell::new(false), } } - /// The descriptor of a completely read file, with its chunk list and hash. - pub(super) fn descriptor(&self, hash: String) -> PublicFileDescriptor { - let mut file = self.file.clone(); - file.chunks = self - .index - .chunk_infos() - .map(|info| super::super::browser_chunk_info(&info)) - .collect(); - file.blake3 = hash; - file + /// Validate a `pipeTo` call before it touches the destination. + fn plan_pipe(&self, options: Option) -> Result { + if self.closed.get() { + return Err(JsValue::from_str(CLOSED_READER)); + } + let options: PipeOptions = options_from_js(options).map_err(js_string)?; + let progress = if options.on_progress.is_undefined() || options.on_progress.is_null() { + None + } else { + Some( + options + .on_progress + .dyn_into::() + .map_err(|_| JsValue::from_str("onProgress must be a function"))?, + ) + }; + let start = + safe_integer(options.start.unwrap_or(0.0), "stream start").map_err(js_string)?; + let end = options + .end + .map(|end| safe_integer(end, "stream end")) + .transpose() + .map_err(js_string)? + .unwrap_or(self.file.size); + if start > end || end > self.file.size { + return Err(JsValue::from_str("stream range is outside the file")); + } + Ok(PipePlan { + range: start..end, + progress, + cancel: Cancellation::new(options.signal)?, + }) } - async fn pipe_to_writer(&self, writer: &JsValue, options: JsValue) -> Result { - let options: PipeOptions = - options_from_js(options, &PIPE_OPTION_NAMES).map_err(js_string)?; - let progress = optional_function(options.on_progress, "onProgress").map_err(js_string)?; - let mut cancel = Cancellation::new(Some(self.closed.subscribe()), options.signal)?; - cancel.check()?; - let range = self - .stream_range(options.start, options.end) - .map_err(js_string)?; + async fn pipe(&self, writer: &JsValue, plan: PipePlan) -> Result { + let PipePlan { + range, + progress, + mut cancel, + } = plan; let write = method(writer, "write")?; let (write, progress) = (&write, &progress); let total = range.end - range.start; let mut hasher = blake3::Hasher::new(); cancel - .run(ordered_pipeline( - self.index.chunks_overlapping(range.clone()), - |chunk| { - let span = self.index.chunk_span(chunk); - span.end - span.start - }, - PIPE_BUFFER_BYTES, - &|| self.shared.controller().fetch.current(), - |chunk| async move { - self.shared - .data_download_indexed_chunk(&self.index, chunk) - .await - .map_err(js_string) - }, - |chunk, plaintext: Bytes| { - let span = self.index.chunk_span(chunk); - let from = range.start.max(span.start); - let to = range.end.min(span.end); - let plaintext = - plaintext.slice((from - span.start) as usize..(to - span.start) as usize); - hasher.update(&plaintext); - let mut written = from - range.start; - async move { - for piece in plaintext.chunks(MAX_BROWSER_RANGE_BYTES) { - JsFuture::from(Promise::resolve( - &write.call1(writer, &Uint8Array::from(piece))?, - )) - .await?; - written += piece.len() as u64; - if let Some(callback) = progress { - callback.call2( - &JsValue::NULL, - &JsValue::from_f64(written as f64), - &JsValue::from_f64(total as f64), - )?; + .run(async { + stream_chunks( + &self.shared, + &self.index, + self.index.chunks_overlapping(range.clone()), + usize::MAX, + |chunk, plaintext| { + let span = self.index.chunk_span(chunk); + let from = range.start.max(span.start); + let to = range.end.min(span.end); + let plaintext = plaintext + .slice((from - span.start) as usize..(to - span.start) as usize); + hasher.update(&plaintext); + let mut written = from - range.start; + async move { + for piece in plaintext.chunks(MAX_BROWSER_RANGE_BYTES) { + JsFuture::from(Promise::resolve( + &write.call1(writer, &Uint8Array::from(piece))?, + )) + .await?; + written += piece.len() as u64; + if let Some(callback) = progress { + callback.call2( + &JsValue::NULL, + &JsValue::from_f64(written as f64), + &JsValue::from_f64(total as f64), + )?; + } } + Ok(()) } - Ok(()) - } - }, - )) - .await?; - // Every byte is written, so closing the reader no longer cancels: it - // may be closed from the final progress callback. - cancel.ignore_reader(); - cancel - .run(async { + }, + ) + .await?; JsFuture::from(Promise::resolve(&method(writer, "close")?.call0(writer)?)) .await .map(drop) @@ -559,125 +591,4 @@ impl BrowserFileReader { }) .map_err(js_string) } - - /// Validate a `pipeTo` range against the file. - fn stream_range(&self, start: Option, end: Option) -> Result, String> { - let start = safe_integer(start.unwrap_or(0.0), "stream start")?; - let end = end - .map(|end| safe_integer(end, "stream end")) - .transpose()? - .unwrap_or(self.file.size); - if start > end || end > self.file.size { - return Err("stream range is outside the file".into()); - } - Ok(start..end) - } - - /// Allocate output in JS, then copy each decrypted chunk into it as it - /// arrives. The whole file is fetched in one deferred retry pass at the - /// requested concurrency; WASM holds only the records in flight. A caller - /// can impose its own memory budget; allocation failures recommend the - /// disk-backed path without downloading the file first. - pub(super) async fn collect( - &self, - settings: &DownloadSettings, - cancel: &mut Cancellation, - ) -> Result<(Uint8Array, String), JsValue> { - let progress = &settings.progress; - if settings - .memory_budget - .is_some_and(|limit| self.file.size > limit) - { - return Err(js_string(format_args!( - "file exceeds the download memory budget; {STREAM_HINT}" - ))); - } - let constructor = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Uint8Array"))? - .dyn_into::()?; - let args = Array::new(); - args.push(&JsValue::from_f64(self.size())); - let output = js_sys::Reflect::construct(&constructor, &args) - .map_err(|error| { - js_string(format_args!( - "cannot allocate download buffer: {}; {STREAM_HINT}", - js_error_message(error) - )) - })? - .unchecked_into::(); - // Reflect calls preserve Number offsets above u32::MAX and catch JS - // allocation exceptions instead of turning them into WASM traps. - let subarray = method(output.as_ref(), "subarray")?; - let chunk_view = |chunk: usize| -> Result { - let span = self.index.chunk_span(chunk); - Ok(subarray - .call2( - output.as_ref(), - &JsValue::from_f64(span.start as f64), - &JsValue::from_f64(span.end as f64), - )? - .unchecked_into()) - }; - let total = self.index.chunk_count(); - let mut hasher = OrderedHasher::new(total); - let mut completed = 0usize; - progress.report(&format!("Downloaded chunk {completed}/{total}")); - // The engine reports a sink failure as invalid data, so the output's - // own error is kept here and returned instead. - let mut output_error = None; - let pass = cancel - .run(async { - self.shared - .data_download_indexed_chunks( - &self.index, - 0..total, - settings.concurrency, - |chunk, plaintext| { - let mut place = || -> Result<(), JsValue> { - // Copies straight from WASM memory into the output. - chunk_view(chunk)?.copy_from(&plaintext); - hasher.complete(chunk, &plaintext); - // At most one earlier chunk per arrival keeps - // each callback short. - if let Some(earlier) = hasher.pending_read_back() { - let view = chunk_view(earlier)?; - hasher - .read_back(earlier, &view.to_vec()) - .map_err(js_string)?; - } - Ok(()) - }; - place().map_err(|error| { - let message = js_error_message(error.clone()); - output_error = Some(error); - message - })?; - completed += 1; - progress.report(&format!("Downloaded chunk {completed}/{total}")); - Ok(()) - }, - ) - .await - .map_err(js_string) - }) - .await; - if let Some(error) = output_error { - return Err(error); - } - pass?; - // Hash the chunks that arrived ahead of file order, yielding to the - // page between short slices so a large backlog cannot freeze it. - let mut slice_start = Instant::now(); - while let Some(chunk) = hasher.pending_read_back() { - cancel.check()?; - hasher - .read_back(chunk, &chunk_view(chunk)?.to_vec()) - .map_err(js_string)?; - if slice_start.elapsed() >= HASH_SLICE { - yield_to_event_loop().await; - slice_start = Instant::now(); - } - } - Ok((output, hasher.finish().map_err(js_string)?)) - } } diff --git a/ant-core/src/client_engine.rs b/ant-core/src/client_engine.rs index 4abcd0fd..dabe39dc 100644 --- a/ant-core/src/client_engine.rs +++ b/ant-core/src/client_engine.rs @@ -1,6 +1,5 @@ //! Runtime-neutral scheduling and session state shared by native and browser clients. -mod chunk_decrypt; pub(crate) mod files; pub(crate) mod read; #[cfg(any(test, all(target_arch = "wasm32", feature = "browser-wasm")))] diff --git a/ant-core/src/client_engine/chunk_decrypt.rs b/ant-core/src/client_engine/chunk_decrypt.rs deleted file mode 100644 index 503fdcfe..00000000 --- a/ant-core/src/client_engine/chunk_decrypt.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Per-chunk self-encryption decryption with bounded decompression. -//! -//! `self_encryption::decrypt_chunk` decompresses without an output limit, so a -//! crafted record could expand far past the size its DataMap declares before -//! that size can be checked. This performs the same steps — the BLAKE3 KDF over -//! the chunk and its two predecessors, the XOR pad, ChaCha20-Poly1305 and -//! Brotli — and stops decompressing one byte past the declared size. Tests pin -//! the result to `decrypt_chunk`, so a KDF change in the dependency fails them. - -use bytes::Bytes; -use chacha20poly1305::aead::{AeadInPlace, KeyInit}; -use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; -use self_encryption::XorName; -use std::io::Read; - -/// self_encryption splits every encryptable file into at least three chunks: -/// each chunk's key depends on the two before it, wrapping around. -pub(crate) const MIN_FILE_CHUNKS: usize = 3; -/// Domain separation context of self_encryption's chunk KDF. -const KDF_CONTEXT: &str = "self_encryption/chunk/v2"; -const HASH_SIZE: usize = 32; -const KEY_SIZE: usize = 32; -const NONCE_SIZE: usize = 12; -/// The KDF output left after the key and nonce, used as the XOR pad. -const PAD_SIZE: usize = 3 * HASH_SIZE - KEY_SIZE - NONCE_SIZE; -/// Three source hashes, the chunk index and the KDF level. -const KDF_INPUT_SIZE: usize = 3 * HASH_SIZE + 2 * std::mem::size_of::(); -/// Brotli decoder input buffer. -const DECOMPRESS_BUFFER_BYTES: usize = 4096; - -/// The per-chunk secrets self_encryption derives from the source hashes. -struct ChunkSecrets { - pad: [u8; PAD_SIZE], - key: [u8; KEY_SIZE], - nonce: [u8; NONCE_SIZE], -} - -/// Decrypt chunk `index` of a map with `src_hashes`, rejecting any record that -/// decompresses to anything other than `expected_len` bytes. Memory grows with -/// the output actually produced, which stops at `expected_len + 1` bytes; a -/// large declared size alone allocates nothing. -pub(super) fn decrypt_chunk( - index: usize, - content: &[u8], - src_hashes: &[XorName], - kdf_level: usize, - expected_len: usize, -) -> Result { - let secrets = chunk_secrets(index, src_hashes, kdf_level)?; - // Unpad, then decrypt in the same buffer; the tag is truncated off. - let mut compressed: Vec = content - .iter() - .zip(secrets.pad.iter().cycle()) - .map(|(byte, pad)| byte ^ pad) - .collect(); - ChaCha20Poly1305::new(Key::from_slice(&secrets.key)) - .decrypt_in_place(Nonce::from_slice(&secrets.nonce), &[], &mut compressed) - .map_err(|error| format!("chunk decryption failed: {error}"))?; - let mut plaintext = Vec::with_capacity(expected_len.min(self_encryption::MAX_CHUNK_SIZE)); - brotli_decompressor::Decompressor::new(compressed.as_slice(), DECOMPRESS_BUFFER_BYTES) - .take((expected_len as u64).saturating_add(1)) - .read_to_end(&mut plaintext) - .map_err(|_| "chunk decompression failed".to_string())?; - if plaintext.len() != expected_len { - return Err("decrypted chunk size differs from DataMap".into()); - } - Ok(Bytes::from(plaintext)) -} - -/// self_encryption's KDF: each chunk's key depends on its own source hash and -/// those of the two chunks before it, wrapping around at the start. -fn chunk_secrets( - index: usize, - src_hashes: &[XorName], - kdf_level: usize, -) -> Result { - let total = src_hashes.len(); - if total < MIN_FILE_CHUNKS || index >= total { - return Err(format!("chunk {index} is outside a map of {total} chunks")); - } - let (previous, before_previous) = match index { - 0 => (total - 1, total - 2), - 1 => (0, total - 1), - _ => (index - 1, index - 2), - }; - let mut input = Vec::with_capacity(KDF_INPUT_SIZE); - input.extend_from_slice(&src_hashes[index].0); - input.extend_from_slice(&src_hashes[previous].0); - input.extend_from_slice(&src_hashes[before_previous].0); - input.extend_from_slice(&(index as u64).to_le_bytes()); - input.extend_from_slice(&(kdf_level as u64).to_le_bytes()); - let mut output = [0u8; PAD_SIZE + KEY_SIZE + NONCE_SIZE]; - let mut hasher = blake3::Hasher::new_derive_key(KDF_CONTEXT); - hasher.update(&input); - hasher.finalize_xof().fill(&mut output); - let mut secrets = ChunkSecrets { - pad: [0; PAD_SIZE], - key: [0; KEY_SIZE], - nonce: [0; NONCE_SIZE], - }; - secrets.pad.copy_from_slice(&output[..PAD_SIZE]); - secrets - .key - .copy_from_slice(&output[PAD_SIZE..PAD_SIZE + KEY_SIZE]); - secrets - .nonce - .copy_from_slice(&output[PAD_SIZE + KEY_SIZE..]); - Ok(secrets) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn matches_self_encryption_for_every_chunk() { - let content = Bytes::from((0..18_000).map(|i| (i * 37) as u8).collect::>()); - let (map, chunks) = self_encryption::encrypt(content).unwrap(); - let src_hashes: Vec<_> = map.infos().iter().map(|info| info.src_hash).collect(); - for (index, info) in map.infos().iter().enumerate() { - let chunk = chunks - .iter() - .find(|chunk| *blake3::hash(&chunk.content).as_bytes() == info.dst_hash.0) - .unwrap(); - let expected = - self_encryption::decrypt_chunk(index, &chunk.content, &src_hashes, 0).unwrap(); - let actual = - decrypt_chunk(index, &chunk.content, &src_hashes, 0, info.src_size).unwrap(); - assert_eq!(actual, expected); - } - } - - #[test] - fn records_expanding_past_their_declared_size_are_rejected() { - // Highly compressible chunks expand far beyond a falsely small size. - let content = Bytes::from(vec![0u8; 3 * 1024 * 1024]); - let (map, chunks) = self_encryption::encrypt(content).unwrap(); - let src_hashes: Vec<_> = map.infos().iter().map(|info| info.src_hash).collect(); - let info = &map.infos()[0]; - let chunk = chunks - .iter() - .find(|chunk| *blake3::hash(&chunk.content).as_bytes() == info.dst_hash.0) - .unwrap(); - assert!(chunk.content.len() < info.src_size); - for declared in [0, 1, info.src_size - 1, info.src_size + 1] { - let error = decrypt_chunk(0, &chunk.content, &src_hashes, 0, declared).unwrap_err(); - assert!(error.contains("differs"), "{declared}: {error}"); - } - assert!(decrypt_chunk(0, &chunk.content, &src_hashes, 1, info.src_size).is_err()); - assert!(decrypt_chunk(3, &chunk.content, &src_hashes, 0, info.src_size).is_err()); - } -} diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index e102d216..00b09385 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -1,25 +1,15 @@ //! Async data workflows over a verified record fetcher. No runtime, sockets, -//! filesystem or JS dependencies. Whole-file decryption stays in -//! self_encryption, the same implementation used by the native client; nested -//! DataMaps and individual chunks use its KDF through `chunk_decrypt`, which -//! bounds decompression. -pub(crate) use super::chunk_decrypt::MIN_FILE_CHUNKS; -use bytes::{Buf, Bytes}; -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -use futures_util::future::{select, Either}; -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -use futures_util::stream::FuturesUnordered; +//! filesystem or JS dependencies. Cryptography and recursive map semantics stay +//! in self_encryption, the same implementation used by the native client. +use bytes::Bytes; use futures_util::StreamExt; use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; -use std::{collections::HashMap, future::Future, io::Read, ops::Range, time::Duration}; +use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range}; -/// Deepest DataMap nesting resolved, as in self_encryption. -const MAX_DATA_MAP_DEPTH: usize = 100; +/// self_encryption splits every encryptable file into at least three chunks. +pub(crate) const MIN_FILE_CHUNKS: usize = 3; /// Content chunks of a resolved root DataMap are encrypted at KDF level zero. const CONTENT_CHUNK_KDF_LEVEL: usize = 0; -/// Seconds to wait before each deferred fetch round: the first pass, one -/// immediate retry, then retries after 15 and 45 seconds. -const DEFERRED_ROUND_DELAYS_SECS: [u64; 4] = [0, 0, 15, 45]; pub(crate) type RecordRequest = (usize, [u8; 32]); @@ -49,15 +39,11 @@ pub(crate) fn decode_map(bytes: &[u8]) -> Result { rmp_serde::from_slice(bytes).map_err(|e| format!("Failed to deserialize DataMap: {e}")) } -/// Fetch records once each, handing every record to `on_record` as it -/// arrives. Records are not re-hashed: the fetcher verifies addresses, and -/// decryption authenticates every record. -async fn fetch_records( +pub(crate) async fn fetch_records( requests: Vec, fetch: &F, cap: &C, - mut on_record: impl FnMut(usize, Bytes) -> Result<(), ReadError>, -) -> Result<(), ReadError> +) -> Result, ReadError> where F: Fn([u8; 32]) -> Fut, Fut: Future>, @@ -67,109 +53,69 @@ where requests, |(index, address)| async move { let bytes = fetch(address).await.map_err(ReadError::Fetch)?; + crate::record::verify(&address, &bytes).map_err(ReadError::Invalid)?; Ok((index, bytes)) }, cap, ); futures_util::pin_mut!(results); + let mut ordered = Vec::new(); while let Some(result) = results.next().await { - let (index, bytes) = result?; - on_record(index, bytes)?; + ordered.push(result?); } - Ok(()) -} - -/// The decrypted chunks of a nested DataMap level read as one stream. -struct ChunksReader { - remaining: std::vec::IntoIter, - current: Bytes, + ordered.sort_by_key(|(index, _)| *index); + Ok(ordered) } -impl Read for ChunksReader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - while !self.current.has_remaining() { - match self.remaining.next() { - Some(next) => self.current = next, - None => return Ok(0), - } - } - let length = buf.len().min(self.current.remaining()); - self.current.copy_to_slice(&mut buf[..length]); - Ok(length) - } -} - -/// Resolve a published, possibly nested DataMap to its root. Each level's -/// records are fetched together and decrypted with self_encryption's KDF at -/// that level, with decompression bounded by each chunk's declared size, so a -/// crafted record cannot expand. `max_map_bytes` bounds each decoded level's -/// declared size. Memory follows the data actually decrypted, never the sizes -/// a map declares. Records are placed by position, not by the externally -/// supplied chunk index, so malformed duplicate indices cannot misassociate -/// bytes. +/// Drive the native recursive resolver with async batches. On a cache miss, +/// suspend the synchronous resolver, fetch the whole missing level, and replay +/// with verified cached bytes. This preserves child-level KDF and depth checks +/// in self_encryption without block_on/block_in_place or a second resolver. pub(crate) async fn resolve( map: &DataMap, fetch: &F, cap: &C, - max_map_bytes: usize, ) -> Result> where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, { - let mut current = map.clone(); - for _ in 0..=MAX_DATA_MAP_DEPTH { - let Some(kdf_level) = current.child() else { - return Ok(current); - }; - let infos = current.infos(); - if infos.iter().any(|info| info.src_size == 0) { - return Err(ReadError::Invalid( - "invalid nested DataMap chunk size".into(), - )); + let mut cache = HashMap::<[u8; 32], Bytes>::new(); + loop { + let missing = RefCell::new(Vec::new()); + let result = self_encryption::get_root_data_map_parallel(map.clone(), &|batch| { + let requests: Vec<_> = batch + .iter() + .filter(|(_, hash)| !cache.contains_key(&hash.0)) + .map(|(index, hash)| (*index, hash.0)) + .collect(); + if !requests.is_empty() { + *missing.borrow_mut() = requests; + return Err(self_encryption::Error::Generic( + "async record fetch required".into(), + )); + } + // Preserve the resolver's positional batch contract, independent + // of network completion order and DataMap chunk indices. + Ok(batch + .iter() + .map(|(index, hash)| (*index, cache[&hash.0].clone())) + .collect()) + }); + let requests = missing.into_inner(); + if requests.is_empty() { + return result.map_err(|e| ReadError::Invalid(e.to_string())); + } + // Position, not the externally supplied chunk index, identifies a + // response so malformed duplicate indices cannot misassociate bytes. + let addresses: Vec<_> = requests.iter().map(|(_, address)| *address).collect(); + let records = + fetch_records(addresses.iter().copied().enumerate().collect(), fetch, cap).await?; + for (position, bytes) in records { + cache.insert(addresses[position], bytes); } - infos - .iter() - .try_fold(0usize, |total, info| total.checked_add(info.src_size)) - .filter(|total| *total <= max_map_bytes) - .ok_or_else(|| { - ReadError::Invalid(format!("nested DataMap exceeds {max_map_bytes} bytes")) - })?; - let mut chunks: Vec> = vec![None; infos.len()]; - let src_hashes: Vec = infos.iter().map(|info| info.src_hash).collect(); - let requests = infos - .iter() - .map(|info| info.dst_hash.0) - .enumerate() - .collect(); - fetch_records(requests, fetch, cap, |position, record| { - let plaintext = super::chunk_decrypt::decrypt_chunk( - position, - &record, - &src_hashes, - kdf_level, - infos[position].src_size, - ) - .map_err(ReadError::Invalid)?; - crate::record::verify(&src_hashes[position].0, &plaintext) - .map_err(ReadError::Invalid)?; - chunks[position] = Some(plaintext); - Ok(()) - }) - .await?; - // Decode straight from the chunks rather than joining them first. - // These are DataMap::from_bytes' bincode options, for a reader. - let chunks = ChunksReader { - remaining: chunks.into_iter().flatten().collect::>().into_iter(), - current: Bytes::new(), - }; - current = bincode::deserialize_from(chunks) - .map_err(|e| ReadError::Invalid(format!("Failed to deserialize DataMap: {e}")))?; } - Err(ReadError::Invalid(format!( - "DataMap nesting exceeds {MAX_DATA_MAP_DEPTH} levels" - ))) } pub(crate) async fn download( @@ -183,27 +129,18 @@ where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(Duration) -> SF, + S: Fn(std::time::Duration) -> SF, SF: Future, { - // The native whole-file path keeps no limit on nested DataMap levels. - let root = resolve(map, fetch, cap, usize::MAX).await?; + let root = resolve(map, fetch, cap).await?; let requests = root .infos() .iter() .enumerate() .map(|(index, info)| (index, info.dst_hash.0)) .collect(); - let mut records = Vec::new(); - // self_encryption matches records to the map by content hash and - // authenticates each one, so they are not hashed here as well. - fetch_records_deferred(requests, fetch, cap, sleep, retryable, |index, content| { - records.push((index, content)); - Ok(()) - }) - .await?; - records.sort_by_key(|(index, _)| *index); - let chunks = records + let chunks = fetch_records_deferred(requests, fetch, cap, sleep, retryable) + .await? .into_iter() .map(|(_, content)| EncryptedChunk { content }) .collect::>(); @@ -281,23 +218,23 @@ impl FileIndex { first..last } - /// Decrypt one record and check it against the map's plaintext size and - /// hash. Decompression stops at the size the map declares. The record is - /// not re-hashed: the fetcher verifies addresses, and an altered record - /// fails ChaCha20-Poly1305 authentication or the plaintext hash. + /// Decrypt one verified record and check it against the map's plaintext + /// size and hash. fn decrypt(&self, chunk: usize, encrypted: &Bytes) -> Result { - let span = self.chunk_span(chunk); - // Per-chunk decryption avoids self_encryption's pointer-sized - // whole-file range arithmetic and keeps the canonical KDF (original - // chunk index and neighbouring hashes). - let plaintext = super::chunk_decrypt::decrypt_chunk( + // The dependency's get_range uses usize file offsets. Its public + // per-chunk primitive has no whole-file arithmetic and retains the + // canonical KDF (original chunk index and neighbouring hashes). + let plaintext = self_encryption::decrypt_chunk( chunk, encrypted, &self.src_hashes, CONTENT_CHUNK_KDF_LEVEL, - // Each size was a usize in the DataMap this index came from. - (span.end - span.start) as usize, - )?; + ) + .map_err(|e| e.to_string())?; + let span = self.chunk_span(chunk); + if plaintext.len() as u64 != span.end - span.start { + return Err("decrypted chunk size differs from DataMap".into()); + } crate::record::verify(&self.src_hashes[chunk].0, &plaintext)?; Ok(plaintext) } @@ -339,184 +276,36 @@ impl FileIndex { } } -/// Fetch, verify and decrypt `chunks` in one deferred retry pass. Each -/// plaintext chunk goes to `sink` as soon as it is decrypted, in completion -/// order, so only records still in flight are held. A missing record is -/// retried alongside the rest instead of stalling chunks behind it. -pub(crate) async fn for_each_chunk( +/// Fetch, verify and decrypt `chunks` in one deferred retry pass, in chunk +/// order. +pub(crate) async fn read_chunks( index: &FileIndex, chunks: Range, fetch: &F, cap: &C, sleep: &S, retryable: fn(&E) -> bool, - mut sink: impl FnMut(usize, Bytes) -> Result<(), String>, -) -> Result<(), ReadError> +) -> Result, ReadError> where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(Duration) -> SF, + S: Fn(std::time::Duration) -> SF, SF: Future, { let requests = chunks .map(|chunk| (chunk, index.dst_hashes[chunk].0)) .collect(); - fetch_records_deferred( - requests, - fetch, - cap, - sleep, - retryable, - |chunk, encrypted| { + fetch_records_deferred(requests, fetch, cap, sleep, retryable) + .await? + .into_iter() + .map(|(chunk, encrypted)| { let plaintext = index .decrypt(chunk, &encrypted) .map_err(ReadError::Invalid)?; - sink(chunk, plaintext).map_err(ReadError::Invalid) - }, - ) - .await -} - -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -enum PipelineEvent { - Written(Result<(), X>), - Fetched(usize, Result), -} - -/// Fetch `items` concurrently and hand each result to `write` in item order. -/// Items launch in order while fewer than `cap()` are in flight and the cost -/// of launched-but-unwritten items stays within `budget`; one item can always -/// run. Fetching continues while a write is pending, so slow items and their -/// retry waits overlap with every other item the budget admits. -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -pub(crate) async fn ordered_pipeline( - items: Range, - cost: impl Fn(usize) -> u64, - budget: u64, - cap: &C, - fetch: F, - mut write: W, -) -> Result<(), X> -where - F: Fn(usize) -> FF, - FF: Future>, - W: FnMut(usize, T) -> WF, - WF: Future>, - C: Fn() -> usize, -{ - let fetch = &fetch; - let mut in_flight = FuturesUnordered::new(); - let mut ready = HashMap::new(); - let mut writing = None; - let (mut next_launch, mut next_write) = (items.start, items.start); - let mut outstanding = 0u64; - while next_write < items.end { - while next_launch < items.end - && in_flight.len() < cap().max(1) - && (outstanding == 0 || outstanding.saturating_add(cost(next_launch)) <= budget) - { - let item = next_launch; - in_flight.push(async move { (item, fetch(item).await) }); - outstanding = outstanding.saturating_add(cost(item)); - next_launch += 1; - } - if writing.is_none() { - if let Some(value) = ready.remove(&next_write) { - writing = Some(Box::pin(write(next_write, value))); - } - } - // `outstanding` covers the next item to write, so with nothing being - // written that item is in flight. - let event = match writing.as_mut() { - Some(pending) if in_flight.is_empty() => PipelineEvent::Written(pending.await), - Some(pending) => match select(pending, in_flight.next()).await { - Either::Left((written, _)) => PipelineEvent::Written(written), - Either::Right((fetched, _)) => { - let (item, value) = fetched.expect("items are in flight"); - PipelineEvent::Fetched(item, value) - } - }, - None => { - let (item, value) = in_flight - .next() - .await - .expect("the next item to write is in flight"); - PipelineEvent::Fetched(item, value) - } - }; - match event { - PipelineEvent::Written(written) => { - written?; - writing = None; - outstanding -= cost(next_write); - next_write += 1; - } - PipelineEvent::Fetched(item, value) => { - ready.insert(item, value?); - } - } - } - Ok(()) -} - -/// Whole-file BLAKE3 over chunks that complete out of order. Chunks are hashed -/// in file order. One that completes ahead of that order is read back from the -/// caller's output later, one chunk at a time, so no plaintext is retained -/// here and the caller decides when that work runs. -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -pub(crate) struct OrderedHasher { - hasher: blake3::Hasher, - next: usize, - completed: Vec, -} - -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -impl OrderedHasher { - pub(crate) fn new(chunks: usize) -> Self { - Self { - hasher: blake3::Hasher::new(), - next: 0, - completed: vec![false; chunks], - } - } - - /// Record `chunk` as complete, hashing it now if it is next in file order. - pub(crate) fn complete(&mut self, chunk: usize, plaintext: &[u8]) { - self.completed[chunk] = true; - if chunk == self.next { - self.hasher.update(plaintext); - self.next += 1; - } - } - - /// The next chunk in file order, if it completed earlier and must now be - /// read back to be hashed. - pub(crate) fn pending_read_back(&self) -> Option { - (self.completed.get(self.next) == Some(&true)).then_some(self.next) - } - - /// Hash the read-back plaintext of [`Self::pending_read_back`]. - pub(crate) fn read_back(&mut self, chunk: usize, plaintext: &[u8]) -> Result<(), String> { - if self.pending_read_back() != Some(chunk) { - return Err(format!("chunk {chunk} is not waiting to be hashed")); - } - self.hasher.update(plaintext); - self.next += 1; - Ok(()) - } - - /// The hash of the whole file, once every chunk is complete. - pub(crate) fn finish(self) -> Result { - if self.next != self.completed.len() { - return Err(format!( - "hashed {} of {} chunks", - self.next, - self.completed.len() - )); - } - Ok(self.hasher.finalize().to_hex().to_string()) - } + Ok((chunk, plaintext)) + }) + .collect() } pub(crate) async fn read_range( @@ -532,7 +321,7 @@ where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(Duration) -> SF, + S: Fn(std::time::Duration) -> SF, SF: Future, { let index = FileIndex::new(root).map_err(ReadError::Invalid)?; @@ -554,7 +343,7 @@ where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(Duration) -> SF, + S: Fn(std::time::Duration) -> SF, SF: Future, { let end = start.saturating_add(length as u64).min(index.size()); @@ -567,33 +356,19 @@ where output .try_reserve_exact(length) .map_err(|e| ReadError::Invalid(format!("cannot allocate range: {e}")))?; - output.resize(length, 0); let chunks = index.chunks_overlapping(start..end); - for_each_chunk( - index, - chunks, - fetch, - cap, - sleep, - retryable, - |chunk, plaintext| { - let span = index.chunk_span(chunk); - let from = start.max(span.start); - let to = end.min(span.end); - output[(from - start) as usize..(to - start) as usize].copy_from_slice( - &plaintext[(from - span.start) as usize..(to - span.start) as usize], - ); - Ok(()) - }, - ) - .await?; + for (chunk, plaintext) in read_chunks(index, chunks, fetch, cap, sleep, retryable).await? { + let span = index.chunk_span(chunk); + let from = start.max(span.start) - span.start; + let to = end.min(span.end) - span.start; + output.extend_from_slice(&plaintext[from as usize..to as usize]); + } Ok(Bytes::from(output)) } /// Native file-download retry rounds: retry missing records together after the /// first batch settles, immediately once, then after 15 and 45 seconds. The /// caller supplies typed fatal errors and a runtime-specific timer. -#[cfg(any(feature = "native", test))] pub(crate) async fn deferred_batch( requests: Vec<(usize, K)>, fetch: F, @@ -606,44 +381,17 @@ where F: Fn(usize, K, usize) -> Fut, Fut: Future), E>>, C: Fn() -> usize, - S: Fn(Duration) -> SF, - SF: Future, -{ - let mut results = Vec::new(); - deferred_rounds(requests, fetch, cap, sleep, exhausted, |index, bytes| { - results.push((index, bytes)); - Ok(()) - }) - .await?; - results.sort_by_key(|(index, _)| *index); - Ok(results) -} - -/// The rounds behind [`deferred_batch`], handing each record to `on_record` -/// as it arrives instead of collecting them. -async fn deferred_rounds( - requests: Vec<(usize, K)>, - fetch: F, - cap: C, - sleep: S, - exhausted: impl Fn(K) -> E, - mut on_record: impl FnMut(usize, Bytes) -> Result<(), E>, -) -> Result<(), E> -where - K: Copy, - F: Fn(usize, K, usize) -> Fut, - Fut: Future), E>>, - C: Fn() -> usize, - S: Fn(Duration) -> SF, + S: Fn(std::time::Duration) -> SF, SF: Future, { let mut remaining = requests; - for (round, delay) in DEFERRED_ROUND_DELAYS_SECS.into_iter().enumerate() { + let mut results = Vec::new(); + for (round, delay) in [0, 0, 15, 45].into_iter().enumerate() { if remaining.is_empty() { break; } if delay > 0 { - sleep(Duration::from_secs(delay)).await; + sleep(std::time::Duration::from_secs(delay)).await; } let input = std::mem::take(&mut remaining); let pending = @@ -652,44 +400,44 @@ where while let Some(result) = pending.next().await { let (index, content) = result?; match content { - Ok(bytes) => on_record(index, bytes)?, + Ok(bytes) => results.push((index, bytes)), Err(key) => remaining.push((index, key)), } } } - match remaining.first() { - Some((_, key)) => Err(exhausted(*key)), - None => Ok(()), + if let Some((_, key)) = remaining.first() { + return Err(exhausted(*key)); } + results.sort_by_key(|(index, _)| *index); + Ok(results) } -/// Deferred retry rounds over records, preserving each record's last typed -/// fetch error. Records reach `on_record` unhashed: the fetcher verifies -/// addresses, and decryption authenticates every record. async fn fetch_records_deferred( requests: Vec, fetch: &F, cap: &C, sleep: &S, retryable: fn(&E) -> bool, - on_record: impl FnMut(usize, Bytes) -> Result<(), ReadError>, -) -> Result<(), ReadError> +) -> Result, ReadError> where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, - S: Fn(Duration) -> SF, + S: Fn(std::time::Duration) -> SF, SF: Future, { // Preserve the adapter's final typed fetch error across deferred rounds. let errors = std::sync::Mutex::new(HashMap::new()); - deferred_rounds( + deferred_batch( requests, |index, address, _| { let errors = &errors; async move { match fetch(address).await { - Ok(bytes) => Ok((index, Ok(bytes))), + Ok(bytes) => { + crate::record::verify(&address, &bytes).map_err(ReadError::Invalid)?; + Ok((index, Ok(bytes))) + } Err(error) if !retryable(&error) => Err(ReadError::Fetch(error)), Err(error) => { errors @@ -712,7 +460,6 @@ where .expect("deferred record has a fetch error"), ) }, - on_record, ) .await } @@ -758,12 +505,7 @@ mod tests { Ok::<_, String>(bytes) } }; - assert_eq!( - resolve(&published, &fetch, &|| 3, usize::MAX) - .await - .unwrap(), - native - ); + assert_eq!(resolve(&published, &fetch, &|| 3).await.unwrap(), native); assert_eq!( download(&published, &fetch, &|| 3, &|_| async {}, |_| true) .await @@ -903,8 +645,9 @@ mod tests { ) .await .unwrap_err(); - // self_encryption finds no record matching the map's addresses. - assert!(matches!(error, ReadError::Invalid(_))); + assert!( + matches!(error, ReadError::Invalid(message) if message.contains("BLAKE3 mismatch")) + ); let error = download( &map, &|_| async { Err::(42u8) }, @@ -916,14 +659,9 @@ mod tests { .unwrap_err(); assert!(matches!(error, ReadError::Fetch(42))); let child = DataMap::with_child(map.infos().to_vec(), 1); - let error = resolve( - &child, - &|_| async { Err::(23u8) }, - &|| 2, - usize::MAX, - ) - .await - .unwrap_err(); + let error = resolve(&child, &|_| async { Err::(23u8) }, &|| 2) + .await + .unwrap_err(); assert!(matches!(error, ReadError::Fetch(23))); } @@ -1022,58 +760,7 @@ mod tests { } #[tokio::test] - async fn nested_maps_resolve_within_limits_and_reject_expanding_records() { - let content = Bytes::from(vec![17; 3 * self_encryption::MAX_CHUNK_SIZE + 1]); - let (map, chunks) = self_encryption::encrypt(content).unwrap(); - assert!(map.is_child()); - let records: HashMap<_, _> = chunks - .iter() - .map(|c| (*blake3::hash(&c.content).as_bytes(), c.content.clone())) - .collect(); - let fetch = |address| { - let bytes = records[&address].clone(); - async move { Ok::<_, String>(bytes) } - }; - let level_size: usize = map.infos().iter().map(|info| info.src_size).sum(); - let root = resolve(&map, &fetch, &|| 3, level_size).await.unwrap(); - assert!(!root.is_child()); - - let error = resolve(&map, &fetch, &|| 3, level_size - 1) - .await - .unwrap_err(); - assert!(matches!(error, ReadError::Invalid(message) if message.contains("exceeds"))); - - // A record that decompresses past its declared size is cut off there. - let mut infos = map.infos().to_vec(); - infos[0].src_size -= 1; - let understated = DataMap::with_child(infos, map.child().unwrap()); - let error = resolve(&understated, &fetch, &|| 3, usize::MAX) - .await - .unwrap_err(); - assert!(matches!(error, ReadError::Invalid(message) if message.contains("differs"))); - } - - #[tokio::test] - async fn declared_nested_sizes_allocate_nothing_before_data_arrives() { - const DECLARED_CHUNK: usize = 1 << 40; - let infos = (0..MIN_FILE_CHUNKS) - .map(|index| self_encryption::ChunkInfo { - index, - src_size: DECLARED_CHUNK, - src_hash: XorName([index as u8; 32]), - dst_hash: XorName([0xee - index as u8; 32]), - }) - .collect(); - let map = DataMap::with_child(infos, 1); - // Terabytes are declared, but the fetch fails before any data exists. - let error = resolve(&map, &|_| async { Err::(7u8) }, &|| 3, usize::MAX) - .await - .unwrap_err(); - assert!(matches!(error, ReadError::Fetch(7))); - } - - #[tokio::test] - async fn altered_content_records_fail_decryption() { + async fn altered_range_records_are_rejected() { let (_, map, records) = fixture(); let fetch = |address| { let mut bytes = records[&address].to_vec(); @@ -1083,172 +770,9 @@ mod tests { let error = read_range(&map, 0, 1, &fetch, &|| 1, &|_| async {}, |_| true) .await .unwrap_err(); - assert!(matches!(error, ReadError::Invalid(message) if message.contains("decryption"))); - } - - #[tokio::test] - async fn pipeline_writes_in_order_within_its_budget() { - const ITEMS: usize = 6; - const COST: u64 = 10; - const BUDGET: u64 = 3 * COST; - let launched = std::cell::Cell::new(0u64); - let most_outstanding = std::cell::Cell::new(0u64); - let mut written = Vec::new(); - ordered_pipeline( - 0..ITEMS, - |_| COST, - BUDGET, - &|| ITEMS, - |item| { - launched.set(launched.get() + 1); - async move { - // Later items finish first. - for _ in item..ITEMS { - tokio::task::yield_now().await; - } - Ok::<_, String>(item) - } - }, - |item, value| { - assert_eq!(item, value); - let outstanding = (launched.get() - written.len() as u64) * COST; - most_outstanding.set(most_outstanding.get().max(outstanding)); - written.push(item); - async { Ok(()) } - }, - ) - .await - .unwrap(); - assert_eq!(written, (0..ITEMS).collect::>()); - assert_eq!(most_outstanding.get(), BUDGET); - } - - #[tokio::test] - async fn pipeline_keeps_fetching_while_a_write_is_pending() { - // One fetch at a time: the first write only finishes once the last - // item has been fetched, so fetching must continue behind it. - let (fetched_last, unblock) = tokio::sync::oneshot::channel(); - let fetched_last = std::cell::RefCell::new(Some(fetched_last)); - let unblock = std::cell::RefCell::new(Some(unblock)); - let mut written = Vec::new(); - let pipeline = ordered_pipeline( - 0..4, - |_| 1, - u64::MAX, - &|| 1, - |item| { - if item == 3 { - let _ = fetched_last.borrow_mut().take().unwrap().send(()); - } - async move { Ok::<_, String>(item) } - }, - |item, _| { - let unblock = (item == 0).then(|| unblock.borrow_mut().take().unwrap()); - written.push(item); - async move { - if let Some(unblock) = unblock { - unblock.await.unwrap(); - } - Ok(()) - } - }, + assert!( + matches!(error, ReadError::Invalid(message) if message.contains("BLAKE3 mismatch")) ); - tokio::time::timeout(Duration::from_secs(5), pipeline) - .await - .expect("fetching stalled behind a pending write") - .unwrap(); - assert_eq!(written, vec![0, 1, 2, 3]); - } - - #[tokio::test] - async fn a_slow_item_does_not_hold_back_fetches_behind_it() { - // The first item stays in flight (as during a retry wait) until the - // third has been fetched. - let (fetched_third, released) = tokio::sync::oneshot::channel(); - let fetched_third = std::cell::RefCell::new(Some(fetched_third)); - let released = std::cell::RefCell::new(Some(released)); - let completed = std::cell::RefCell::new(Vec::new()); - let pipeline = ordered_pipeline( - 0..3, - |_| 1, - 3, - &|| 3, - |item| { - let released = (item == 0).then(|| released.borrow_mut().take().unwrap()); - let fetched_third = (item == 2).then(|| fetched_third.borrow_mut().take().unwrap()); - let completed = &completed; - async move { - if let Some(released) = released { - released.await.unwrap(); - } - completed.borrow_mut().push(item); - if let Some(fetched_third) = fetched_third { - let _ = fetched_third.send(()); - } - Ok::<_, String>(item) - } - }, - |_, _| async { Ok(()) }, - ); - tokio::time::timeout(Duration::from_secs(5), pipeline) - .await - .expect("a slow item held back the items behind it") - .unwrap(); - assert_eq!(*completed.borrow(), vec![1, 2, 0]); - } - - #[tokio::test] - async fn pipeline_admits_an_oversized_item_and_stops_at_the_first_error() { - let mut written = Vec::new(); - ordered_pipeline( - 0..2, - |_| 100, - 10, - &|| 4, - |item| async move { Ok::<_, String>(item) }, - |item, _| { - written.push(item); - async { Ok(()) } - }, - ) - .await - .unwrap(); - assert_eq!(written, vec![0, 1]); - - let error = ordered_pipeline( - 0..4, - |_| 1, - 4, - &|| 4, - |item| async move { - if item == 2 { - Err(format!("fetch {item}")) - } else { - Ok(item) - } - }, - |_, _| async { Ok(()) }, - ) - .await - .unwrap_err(); - assert_eq!(error, "fetch 2"); - let error = ordered_pipeline( - 0..4, - |_| 1, - 4, - &|| 4, - |item| async move { Ok::<_, String>(item) }, - |item, _| async move { - if item == 1 { - Err(format!("write {item}")) - } else { - Ok(()) - } - }, - ) - .await - .unwrap_err(); - assert_eq!(error, "write 1"); } #[tokio::test] @@ -1272,57 +796,47 @@ mod tests { } } }; - let sleep = |delay: Duration| { + let sleep = |delay: std::time::Duration| { sleeps.borrow_mut().push(delay.as_secs()); async {} }; + // The first pass and three deferred retries. + const ATTEMPTS: usize = 4; // One chunk on its own, as each pipeTo fetch is. - let mut plaintext = None; - for_each_chunk( - &index, - 1..2, - &fetch_after(4), - &|| 1, - &sleep, - |_| true, - |_, chunk| { - plaintext = Some(chunk); - Ok(()) - }, - ) + let chunks = read_chunks(&index, 1..2, &fetch_after(ATTEMPTS), &|| 1, &sleep, |_| { + true + }) .await .unwrap(); let span = index.chunk_span(1); assert_eq!( - plaintext.unwrap(), - content.slice(span.start as usize..span.end as usize) + chunks, + vec![(1, content.slice(span.start as usize..span.end as usize))] ); assert_eq!(*sleeps.borrow(), vec![15, 45]); attempts.set(0); - let error = for_each_chunk( + let error = read_chunks( &index, 1..2, - &fetch_after(5), + &fetch_after(ATTEMPTS + 1), &|| 1, &sleep, |_| true, - |_, _| Ok(()), ) .await .unwrap_err(); assert!(matches!(error, ReadError::Fetch(message) if message == "missing")); - assert_eq!(attempts.get(), DEFERRED_ROUND_DELAYS_SECS.len()); + assert_eq!(attempts.get(), ATTEMPTS); attempts.set(0); - let error = for_each_chunk( + let error = read_chunks( &index, 1..2, - &fetch_after(5), + &fetch_after(ATTEMPTS + 1), &|| 1, &sleep, |_| false, - |_, _| Ok(()), ) .await .unwrap_err(); @@ -1331,7 +845,7 @@ mod tests { } #[tokio::test] - async fn chunks_share_one_retry_pass_and_reach_the_sink_as_they_complete() { + async fn chunks_share_one_retry_pass() { let (content, map, records) = fixture(); let index = FileIndex::new(&map).unwrap(); let attempts = std::sync::Mutex::new(HashMap::<[u8; 32], usize>::new()); @@ -1364,8 +878,7 @@ mod tests { } } }; - let mut received = Vec::new(); - for_each_chunk( + let received = read_chunks( &index, 0..index.chunk_count(), &fetch, @@ -1375,21 +888,16 @@ mod tests { async {} }, |_| true, - |chunk, plaintext| { - received.push((chunk, plaintext)); - Ok(()) - }, ) .await .unwrap(); assert_eq!(most_in_flight.get(), MIN_FILE_CHUNKS); // Both missing records wait out the same 15-second round. assert_eq!(*sleeps.lock().unwrap(), vec![15]); - // The available middle chunk is delivered without waiting for the others. - assert_eq!(received[0].0, 1); - let mut chunks: Vec<_> = received.iter().map(|(chunk, _)| *chunk).collect(); - chunks.sort_unstable(); - assert_eq!(chunks, (0..MIN_FILE_CHUNKS).collect::>()); + assert_eq!( + received.iter().map(|(chunk, _)| *chunk).collect::>(), + (0..MIN_FILE_CHUNKS).collect::>() + ); for (chunk, plaintext) in received { let span = index.chunk_span(chunk); assert_eq!( @@ -1399,68 +907,6 @@ mod tests { } } - #[test] - fn ordered_hash_matches_in_order_hash_for_any_completion_order() { - let chunks: Vec> = (0..5u8).map(|i| vec![i; 10 + i as usize]).collect(); - let expected = blake3::hash(&chunks.concat()).to_hex().to_string(); - // Only chunks that completed ahead of the frontier are read back. - for (order, expected_read_backs) in [ - ([0, 1, 2, 3, 4], vec![]), - ([4, 3, 2, 1, 0], vec![1, 2, 3, 4]), - ([2, 0, 4, 1, 3], vec![2, 4]), - ] { - let mut hasher = OrderedHasher::new(chunks.len()); - let mut read_backs = Vec::new(); - let mut read_back = |hasher: &mut OrderedHasher| { - let chunk = hasher.pending_read_back()?; - read_backs.push(chunk); - hasher.read_back(chunk, &chunks[chunk]).unwrap(); - Some(()) - }; - // As in a download: at most one read-back per completion, then - // the rest once every chunk has arrived. - for chunk in order { - hasher.complete(chunk, &chunks[chunk]); - read_back(&mut hasher); - } - while read_back(&mut hasher).is_some() {} - assert_eq!(hasher.finish().unwrap(), expected); - assert_eq!(read_backs, expected_read_backs); - } - let mut incomplete = OrderedHasher::new(chunks.len()); - incomplete.complete(1, &chunks[1]); - assert_eq!(incomplete.pending_read_back(), None); - assert!(incomplete.read_back(1, &chunks[1]).is_err()); - assert!(incomplete.finish().is_err()); - } - - #[tokio::test] - async fn sink_errors_stop_a_chunk_pass() { - let (_, map, records) = fixture(); - let index = FileIndex::new(&map).unwrap(); - let fetch = |address| { - let bytes = records[&address].clone(); - async move { Ok::<_, String>(bytes) } - }; - let mut delivered = 0; - let error = for_each_chunk( - &index, - 0..index.chunk_count(), - &fetch, - &|| 1, - &|_| async {}, - |_| true, - |_, _| { - delivered += 1; - Err("destination full".to_string()) - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, ReadError::Invalid(message) if message == "destination full")); - assert_eq!(delivered, 1); - } - #[tokio::test] async fn ranges_reject_mismatched_plaintext_size() { let (_, map, records) = fixture(); diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index 9c232cd3..f9f3d962 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -377,8 +377,8 @@ impl Client { } /// Download a plaintext byte range. Resolves child maps first, then fetches - /// only the records overlapping the range, concurrently, decrypting each - /// chunk as it completes. + /// only the records overlapping the range and decrypts them one chunk at a + /// time, with 64-bit file arithmetic on every platform. /// Length is clamped at EOF; a start at or beyond EOF returns empty bytes. /// /// # Errors @@ -389,8 +389,22 @@ impl Client { start: usize, length: usize, ) -> Result { - self.data_download_range_u64(data_map, start as u64, length) + let fetch = |address| self.fetch_data_record(address); + let cap = || self.controller().fetch.current(); + let root = crate::client_engine::files::resolve(data_map, &fetch, &cap) .await + .map_err(map_read_error)?; + crate::client_engine::files::read_range( + &root, + start as u64, + length, + &fetch, + &cap, + &crate::runtime::sleep, + retry_data_fetch, + ) + .await + .map_err(map_read_error) } /// Resolve a shrunk (child) `DataMap` to the root map that lists the @@ -411,32 +425,6 @@ impl Client { .map_err(map_read_error) } - /// Download a bounded range with a 64-bit file offset, including on wasm32. - pub async fn data_download_range_u64( - &self, - data_map: &DataMap, - start: u64, - length: usize, - ) -> Result { - let fetch = |address| self.fetch_data_record(address); - let cap = || self.controller().fetch.current(); - // Native range reads keep no limit on nested DataMap levels. - let root = crate::client_engine::files::resolve(data_map, &fetch, &cap, usize::MAX) - .await - .map_err(map_read_error)?; - crate::client_engine::files::read_range( - &root, - start, - length, - &fetch, - &cap, - &crate::runtime::sleep, - retry_data_fetch, - ) - .await - .map_err(map_read_error) - } - /// Read a range through a validated index reused across browser seeks, /// taking records from `held` and fetching those it resolves to `None`. A /// record that `held` resolves to an error counts as a failed fetch @@ -480,36 +468,20 @@ impl Client { index: &crate::client_engine::files::FileIndex, chunk: usize, ) -> Result { - let mut plaintext = None; - self.data_download_indexed_chunks(index, chunk..chunk + 1, 1, |_, content| { - plaintext = Some(content); - Ok(()) - }) - .await?; - Ok(plaintext.expect("a completed pass delivers its one chunk")) - } - - /// Fetch and decrypt whole chunks in one deferred retry pass, handing each - /// plaintext chunk to `sink` in completion order. - #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] - pub(crate) async fn data_download_indexed_chunks( - &self, - index: &crate::client_engine::files::FileIndex, - chunks: std::ops::Range, - concurrency: usize, - sink: impl FnMut(usize, Bytes) -> std::result::Result<(), String>, - ) -> Result<()> { - crate::client_engine::files::for_each_chunk( + let mut chunks = crate::client_engine::files::read_chunks( index, - chunks, + chunk..chunk + 1, &|address| self.fetch_data_record(address), - &|| self.controller().fetch.current().min(concurrency), + &|| 1, &crate::runtime::sleep, retry_data_fetch, - sink, ) .await - .map_err(map_read_error) + .map_err(map_read_error)?; + let (_, plaintext) = chunks + .pop() + .expect("a completed pass delivers its one chunk"); + Ok(plaintext) } async fn fetch_data_record(&self, address: [u8; 32]) -> Result { diff --git a/ant-core/src/data/client/file/native.rs b/ant-core/src/data/client/file/native.rs index 3c1420e0..2f3c18e8 100644 --- a/ant-core/src/data/client/file/native.rs +++ b/ant-core/src/data/client/file/native.rs @@ -3336,8 +3336,6 @@ impl Client { } }, &|| self.controller().fetch.current(), - // Native downloads keep no limit on nested DataMap levels. - usize::MAX, ) .await .map_err(super::super::data::map_read_error)?; diff --git a/ant-core/wasm-tests/README.md b/ant-core/wasm-tests/README.md index ec379f2f..e8a3d50a 100644 --- a/ant-core/wasm-tests/README.md +++ b/ant-core/wasm-tests/README.md @@ -66,16 +66,12 @@ checks media ranges across chunk boundaries and EOF. `large-download.test.mjs` streams a native-encrypted 4,303,347,835-byte file through the real WASM decryptor and checks its complete BLAKE3 without retaining the plaintext. It also covers public/private seeks across 4 GiB, actual disk -writes, fetching bounded by the `pipeTo` buffer behind a blocked write, -cancellation of `pipeTo` and complete downloads by `AbortSignal`, cancellation -of `pipeTo` and range reads by closing the reader (including during retry -waits), an abort during a stalled destination write and during the -destination's close, a closed reader leaving another reader's cached records -in place, destinations aborted for invalid calls, a reader closed from the -final progress callback, writer errors, JS allocation and output failures, -memory budgets, download options (including the legacy numeric and `null` -forms and optional typings) and progress, and invalid offsets. The compressed -fixture is about 250 KiB; +writes, fetching bounded behind a blocked write, `AbortSignal` cancellation of +`pipeTo` and complete downloads (including during retry waits, a stalled +destination write and the destination's close), invalid `pipeTo` calls that +leave the destination untouched, writer errors, JS allocation and output +failures, memory budgets, progress and invalid offsets. The compressed fixture +is about 250 KiB; regenerate it from the repository root with `cargo run -p ant-core --release --example generate-browser-large-file`. Generation processes the entire plaintext using bounded buffers. The full-stream diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index b52c9af2..e896486b 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -93,8 +93,8 @@ test("large-file memory budgets fail before content fetch, and allocation errors try { (await opened.client.openPublicFile(large.address)).close(); } finally { opened.client.close(); } const maxMemoryBytes = 64 * 1024 * 1024; for (const download of [ - client => client.downloadPublicFile(large.address, { maxMemoryBytes }), - client => client.downloadPrivateFile({ data_map: map.content }, { maxMemoryBytes }), + client => client.downloadPublicFile(large.address, undefined, undefined, { maxMemoryBytes }), + client => client.downloadPrivateFile({ data_map: map.content }, undefined, undefined, { maxMemoryBytes }), ]) { const { rtc, client } = fixture(); try { @@ -106,30 +106,14 @@ test("large-file memory budgets fail before content fetch, and allocation errors const { rtc, client } = fixture(); try { for (const value of [-1, 1.5, Infinity, NaN, 2 ** 53]) { - await assert.rejects(client.downloadPublicFile(large.address, { maxMemoryBytes: value }), /safe integer/); + await assert.rejects(client.downloadPublicFile(large.address, undefined, undefined, { maxMemoryBytes: value }), /safe integer/); } - for (const concurrency of [0, -1, 1.5, NaN]) { - await assert.rejects(client.downloadPublicFile(large.address, { concurrency }), /positive integer/); - } - // The legacy numeric argument keeps wasm-bindgen's `>>> 0` coercion. - for (const concurrency of [0, NaN, "none", 2 ** 32]) { - await assert.rejects(client.downloadPublicFile(large.address, concurrency), /positive integer/); - } - await assert.rejects(client.downloadPublicFile(large.address, { maxMemory: 1 }), /unknown option `maxMemory`/); - await assert.rejects(client.downloadPublicFile(large.address, { onProgress: 1 }), /onProgress must be a function/); - await assert.rejects(client.downloadPublicFile(large.address, { onProgress() {} }, () => {}), /not both/); - await assert.rejects(client.downloadPublicFile(large.address, { signal: {} }), /AbortSignal/); - assert.equal(gets(rtc), 0, "invalid options fail before any fetch"); + await assert.rejects(client.downloadPublicFile(large.address, 0), /positive integer/); + await assert.rejects(client.downloadPublicFile(large.address, undefined, undefined, { signal: {} }), /AbortSignal/); + assert.equal(gets(rtc), 0, "invalid arguments fail before any fetch"); } finally { client.close(); } }); -test("download options stay optional in the generated typings", async () => { - const typings = await readFile(new URL("./pkg/ant_core.d.ts", import.meta.url), "utf8"); - for (const name of ["downloadPublicFile", "downloadPrivateFile"]) { - assert.match(typings, new RegExp(`${name}\\(file: any, options\\?: any`)); - } -}); - test("an AbortSignal cancels a complete download, including during retry waits", async () => { const original = new TextEncoder().encode("Download cancellation.".repeat(300)); const encrypted = encryptPublicFile(original); @@ -137,18 +121,16 @@ test("an AbortSignal cancels a complete download, including during retry waits", const { rtc, client } = fixture(encrypted.records.filter(record => record !== missing)); try { const reason = new Error("stop downloading"); - await assert.rejects(client.downloadPublicFile(encrypted.address, { signal: AbortSignal.abort(reason) }), + await assert.rejects(client.downloadPublicFile(encrypted.address, undefined, undefined, { signal: AbortSignal.abort(reason) }), error => error === reason); const controller = new AbortController(); const started = Date.now(); - const pending = client.downloadPublicFile(encrypted.address, { signal: controller.signal }); + const pending = client.downloadPublicFile(encrypted.address, undefined, undefined, { signal: controller.signal }); setTimeout(() => controller.abort(reason), 100); await assert.rejects(pending, error => error === reason); assert(Date.now() - started < 5_000, `abort took ${Date.now() - started} ms`); rtc.stores[0].set(missing.address, missing.content); - const legacy = await client.downloadPublicFile(encrypted.address, 1.5); - assert.deepEqual(legacy.content, original); - assert.deepEqual((await client.downloadPublicFile(encrypted.address, "2")).content, original); + assert.deepEqual((await client.downloadPublicFile(encrypted.address)).content, original); } finally { client.close(); } }); @@ -207,7 +189,7 @@ test("pipeTo keeps fetching behind a blocked write only up to its 32 MiB buffer" } finally { reader?.close(); client.close(); } }); -test("invalid pipeTo calls abort the destination they were given", async () => { +test("invalid pipeTo calls reject without touching the destination", async () => { const { client } = fixture(); let reader; try { @@ -216,16 +198,15 @@ test("invalid pipeTo calls abort the destination they were given", async () => { [{ end: large.size + 1 }, /outside the file/], [{ start: 10, end: 5 }, /outside the file/], [{ start: -1 }, /safe integer/], - [{ size: 1 }, /unknown option `size`/], [{ onProgress: "no" }, /onProgress must be a function/], [{ signal: {} }, /AbortSignal/], [{}, /closed/, true], ]) { if (close) reader.close(); - let aborted, closed = false; - const sink = new WritableStream({ write() { assert.fail("nothing is written"); }, close() { closed = true; }, abort(reason) { aborted = reason; } }); + let aborted = false, closed = false; + const sink = new WritableStream({ write() { assert.fail("nothing is written"); }, close() { closed = true; }, abort() { aborted = true; } }); await assert.rejects(reader.pipeTo(sink, options), expected); - assert.match(String(aborted), expected); + assert.equal(aborted, false); assert.equal(closed, false); assert.equal(sink.locked, false); } @@ -256,49 +237,21 @@ test("an AbortSignal cancels one pipeTo and leaves its reader open", async () => } finally { reader?.close(); client.close(); } }); -test("closing a reader from the final progress callback keeps the completed file", async () => { - const original = new TextEncoder().encode("Final progress callback.".repeat(200)); - const encrypted = encryptPublicFile(original); - const { client } = fixture(encrypted.records); - let reader; - try { - reader = await client.openPublicFile(encrypted.address); - const chunks = []; - let closed = false, aborted = false; - const sink = new WritableStream({ write(bytes) { chunks.push(bytes); }, close() { closed = true; }, abort() { aborted = true; } }); - const progress = []; - const result = await reader.pipeTo(sink, { onProgress(written, total) { - progress.push([written, total]); - if (written === total) reader.close(); - } }); - assert.equal(result.bytesWritten, original.length); - assert.deepEqual(progress.at(-1), [original.length, original.length]); - assert(closed); - assert(!aborted); - assert.deepEqual(Buffer.concat(chunks), Buffer.from(original)); - } finally { reader?.close(); client.close(); } -}); - -test("cancelling during a missing-record retry does not wait out the retry rounds", async () => { +test("an AbortSignal stops a pipeTo waiting to retry a missing record", async () => { const original = new TextEncoder().encode("Retry cancellation.".repeat(300)); const encrypted = encryptPublicFile(original); const missing = encrypted.records.find(record => record.address !== encrypted.address); const { client } = fixture(encrypted.records.filter(record => record !== missing)); + let reader; try { - for (const cancel of ["close", "signal"]) { - const reader = await client.openPublicFile(encrypted.address); - try { - const controller = new AbortController(); - const started = Date.now(); - const pending = cancel === "close" - ? reader.readRange(0, original.length) - : reader.pipeTo(new WritableStream(), { signal: controller.signal }); - setTimeout(() => cancel === "close" ? reader.close() : controller.abort(new Error("stop")), 100); - await assert.rejects(pending, cancel === "close" ? /closed/ : /stop/); - assert(Date.now() - started < 5_000, `${cancel} took ${Date.now() - started} ms`); - } finally { reader.close(); } - } - } finally { client.close(); } + reader = await client.openPublicFile(encrypted.address); + const controller = new AbortController(); + const started = Date.now(); + const pending = reader.pipeTo(new WritableStream(), { signal: controller.signal }); + setTimeout(() => controller.abort(new Error("stop")), 100); + await assert.rejects(pending, /stop/); + assert(Date.now() - started < 5_000, `abort took ${Date.now() - started} ms`); + } finally { reader?.close(); client.close(); } }); test("an AbortSignal settles pipeTo even while a destination write is stalled", async () => { @@ -339,43 +292,19 @@ test("an AbortSignal during the destination's close rejects pipeTo", async () => } finally { reader?.close(); client.close(); } }); -test("closing a reader leaves records another reader of the file uses cached", async () => { - const original = new TextEncoder().encode("Two readers, one file.".repeat(300)); - const encrypted = encryptPublicFile(original); - const { rtc, client } = fixture(encrypted.records); - let first, second; - try { - first = await client.openPublicFile(encrypted.address); - second = await client.openPublicFile(encrypted.address); - assert.deepEqual(await second.readRange(0, original.length), original); - first.close(); - const cached = gets(rtc); - assert.deepEqual(await second.readRange(0, original.length), original); - assert.equal(gets(rtc), cached, "the open reader's records stay cached"); - } finally { first?.close(); second?.close(); client.close(); } -}); - -test("writer rejection and reader cancellation stop a stream and release its lock", async () => { +test("a writer rejection stops a stream and releases its lock", async () => { const { client } = fixture(); + let reader; try { - for (const cancel of [false, true]) { - const reader = await client.openPublicFile(large.address); - let writes = 0, closed = false, aborted = false; - const failure = new Error("disk full"); - const sink = new WritableStream({ - write() { writes++; if (cancel) reader.close(); else throw failure; }, - close() { closed = true; }, - abort() { aborted = true; }, - }); - try { - await assert.rejects(reader.pipeTo(sink, { start: 2 ** 32 }), cancel ? /closed/ : error => error === failure); - assert.equal(writes, 1); - assert.equal(closed, false); - if (cancel) assert(aborted); - assert.equal(sink.locked, false); - } finally { reader.close(); } - } - } finally { client.close(); } + reader = await client.openPublicFile(large.address); + let writes = 0, closed = false; + const failure = new Error("disk full"); + const sink = new WritableStream({ write() { writes++; throw failure; }, close() { closed = true; } }); + await assert.rejects(reader.pipeTo(sink, { start: 2 ** 32 }), error => error === failure); + assert.equal(writes, 1); + assert.equal(closed, false); + assert.equal(sink.locked, false); + } finally { reader?.close(); client.close(); } }); test("in-memory downloads use one JS output buffer and return recoverable allocation failures", async () => { @@ -386,16 +315,13 @@ test("in-memory downloads use one JS output buffer and return recoverable alloca let reader; try { const messages = []; - const result = await client.downloadPublicFile(encrypted.address, - { concurrency: 2, maxMemoryBytes: original.length, onProgress: message => messages.push(message) }); + const result = await client.downloadPublicFile(encrypted.address, 2, message => messages.push(message), + { maxMemoryBytes: original.length }); assert.deepEqual(result.content, original); assert.equal(result.file.chunks.length, 3); // Probes time the first chunks by these messages. const chunkProgress = messages.filter(message => message.startsWith("Downloaded chunk ")); assert.deepEqual(chunkProgress, ["Downloaded chunk 0/3", "Downloaded chunk 1/3", "Downloaded chunk 2/3", "Downloaded chunk 3/3"]); - const legacy = await client.downloadPublicFile(encrypted.address, 2, message => messages.push(message)); - assert.equal(legacy.hash, result.hash); - // `null` in the legacy concurrency position selects the default, as before. const unset = await client.downloadPublicFile(encrypted.address, null, () => {}); assert.equal(unset.hash, result.hash); globalThis.Uint8Array = class extends NativeUint8Array { diff --git a/ant-core/wasm-tests/mock-webrtc.mjs b/ant-core/wasm-tests/mock-webrtc.mjs index b821d2d4..5338a32c 100644 --- a/ant-core/wasm-tests/mock-webrtc.mjs +++ b/ant-core/wasm-tests/mock-webrtc.mjs @@ -47,10 +47,12 @@ export function mockWebRtc(nodes = [{}], { gatheringMs = 0 } = {}) { if (this.connection.connectionState === "failed") return; // A real DataChannel copies the outgoing bytes. The mock re-enters WASM // instead, where allocating the server argument can grow memory and - // detach an outgoing view into that same memory. Copy before re-entry. + // detach an outgoing view into that same memory. Copy before re-entry, + // including loading the node's records below. + const outgoing = new Uint8Array(message); // One node has one store, whichever of its channels wrote to it. for (const [key, content] of stores[this.index]) this.server.set_record(key, content); - let response = this.server.push(new Uint8Array(message)); + let response = this.server.push(outgoing); if (!response.length) return; const method = this.server.last_method(); if (method === "put_chunk") { diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index da45f19e..f63cfe0a 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -664,103 +664,57 @@ The browser download limit is independent of upload limits. Root-map file sizes, seek-index offsets, and shared range arithmetic use `u64`; only chunk indices and individual buffer lengths use `usize`. The JavaScript facade accepts nonnegative safe-integer numbers, rejecting fractional, overflowing and imprecise positions. -Manifest validation no longer rejects files over 1 GB or over 1,024 chunks. It -caps a file's chunk list at 262,144 entries (about 1 TiB at the native chunk -size), which bounds the work an untrusted manifest can cause. Chunk sizes come -from the DataMap rather than this build's `MAX_CHUNK_SIZE`, so maps produced -with another self-encryption chunk size remain readable. Browsers accept chunks -of up to 16 MiB; native range reads accept any size. +Manifest validation no longer rejects files over 1 GB; it caps a file's chunk +list at 262,144 entries (about 1 TiB at the native chunk size) to bound the +sorting and normalisation of an untrusted entry. A reader retains a validated root-map index and uses binary searches to identify -overlapping chunks. Chunks are decrypted one at a time with the original -indices and neighbouring source hashes, which avoids the dependency's -pointer-sized whole-file range arithmetic without changing its KDF, DataMap -representation or stored ciphertext. `self_encryption::decrypt_chunk` -decompresses without an output limit, so a crafted record could expand to -gigabytes before its declared size is checked and abort the WASM instance. -`client_engine/chunk_decrypt.rs` therefore performs the same steps (BLAKE3 KDF, -XOR pad, ChaCha20-Poly1305, Brotli) and stops decompressing one byte past the -declared size. Nested DataMap levels are resolved through it too, instead of -`get_root_data_map_parallel`, and browsers bound each decoded level at 64 MiB. -Memory follows the data actually decrypted, never a size a map declares. -It uses the dependency's own `chacha20poly1305` and `brotli-decompressor` -versions, and tests pin its output to `decrypt_chunk` and to native nested-map -resolution, so a change to the dependency's KDF fails them; a bounded primitive -upstream would replace it. Native whole-file downloads and the legacy -whole-buffer `decryptPublicFile` binding still decrypt through `self_encryption`, -so neither bounds decompression. - -The fetch layer verifies every record's address. The engine does not hash -records again: ChaCha20-Poly1305 authentication and the plaintext hash reject -an altered content or nested DataMap record, and decompression stops at the -declared size. On the native whole-file path, `self_encryption` matches records -to the map by content hash and authenticates them itself. - -Reads are chunk-aligned. A shared engine primitive fetches a set of chunks in -one deferred retry pass and hands each decrypted chunk on as it completes, so a -missing record is retried alongside the others instead of stalling the chunks -behind it, and no chunk is decrypted twice. - -Complete downloads allocate their output as one JavaScript `Uint8Array`, fetch -every chunk in a single pass at the requested concurrency, and copy each chunk -into the output as it is decrypted. WASM holds only the records in flight. The -whole-file BLAKE3 is computed in file order; a chunk that completes ahead of -that order is read back from the output when its turn comes. Progress is still -reported as `Downloaded chunk n/total`; chunks that complete ahead of file -order are hashed between event-loop turns rather than in one blocking step. -Options are passed as `{ concurrency?, maxMemoryBytes?, onProgress?, signal? }`. -An `AbortSignal` cancels resolution and transfer alike. The earlier -`(file, concurrency?, onProgress?)` form remains supported, coercing the -concurrency number as wasm-bindgen did. `maxMemoryBytes` -limits the output allocation. Allocation failure rejects before file-content -transfer and directs the caller to streaming. Automatic free-memory detection -and implicit disk spill are not portable browser capabilities; applications own -the memory budget and selection of a storage destination. - -`BrowserFileReader.pipeTo(writable, { start?, end?, signal?, onProgress? })` -streams the whole file or a half-open range to a caller-owned `WritableStream`. -An ordered pipeline fetches chunks concurrently and writes them in file order, -at most 4 MiB per write, each awaited before the next. Chunks are launched while -those in flight or awaiting their turn total at most 32 MiB, so fetching -continues behind a pending write but a slow destination still bounds memory. -Each missing record is retried on its own deferred schedule while the rest of -the buffer keeps fetching, so retry waits overlap rather than adding up per -batch. Only a record that stays missing longer than the buffer takes to fill -holds the stream back. -It computes BLAKE3 incrementally, closes the destination on success, aborts it -on any failure including invalid options, and releases the writer lock without -waiting for a stalled destination to finish aborting. An `AbortSignal` cancels -one operation without closing the reader. Closing the reader cancels its -operations at once, including fetches waiting to be retried, until every byte -is written; it may then be closed from the final progress callback. After that -only the signal can cancel, and a destination that has started closing may -already be committed. Closing a reader releases the records its read-ahead -holds but no longer clears the client's shared cache: that cache is bounded -(32 MiB) and may hold records other readers are using, including readers of the -same content opened another way, so its LRU policy releases records instead. -Cancellation is checked before an operation is resumed, so a cancelled -operation never completes or refills the cache afterwards. The core -does not open file pickers, own browser filesystem policy, or change the -companion SDK. - -Read-ahead, described under streaming reads, now plans its windows over the -reader's 64-bit index rather than a `usize` record layout, so it follows reads -past 4 GiB on wasm32. The reader validates that index when it opens. A DataMap -whose range reads reject, such as one with non-contiguous indices, therefore no -longer opens with read-ahead off: opening it fails with the same error, before -any content record is fetched. +overlapping chunks. Decryption delegates to `self_encryption::decrypt_chunk` +with the original indices and neighbouring source hashes. This avoids the +dependency's pointer-sized whole-file range arithmetic without changing its KDF, +DataMap representation or stored ciphertext. Encrypted records and reconstructed +plaintext chunks are verified against their respective hashes and sizes. The +reader validates its index when it opens, so a DataMap whose range reads reject, +such as one with non-contiguous indices, fails to open rather than opening with +read-ahead off as described under streaming reads. Read-ahead plans its windows +over the same 64-bit index, so it follows reads past 4 GiB on wasm32. + +Complete downloads and `BrowserFileReader.pipeTo` share one transfer: chunks are +fetched concurrently, each retried on the deferred schedule on its own, and +handed on in file order. Fetching continues while the consumer works, holding +at most 32 MiB of plaintext. A complete download copies each chunk into one +JavaScript `Uint8Array` allocated up front, hashes it in order and keeps the +`Downloaded chunk n/total` progress. Its optional `maxMemoryBytes` limits that +allocation; allocation failure rejects before file-content transfer and directs +the caller to streaming. Automatic free-memory detection and implicit disk spill +are not portable browser capabilities; applications own the memory budget and +the choice of a storage destination. + +`pipeTo(writable, { start?, end?, signal?, onProgress? })` writes the whole file +or a half-open range to a caller-owned `WritableStream`, at most 4 MiB per write, +awaiting each write. Like `ReadableStream.pipeTo`, it rejects invalid calls +without touching the destination, closes the destination on success, aborts it +on failure without waiting for a stalled write, and releases the writer lock. +An `AbortSignal` cancels a `pipeTo` or a complete download, including resolution +and retry waits. Closing a reader releases its read-ahead records and makes +later calls fail; it no longer clears the client's shared, bounded chunk cache, +which other readers may be using. The core does not open file pickers, own +browser filesystem policy, or change the companion SDK. Removing only the 1 GB check was rejected because wasm32 offsets would still truncate around 4 GiB. Whole-file WASM buffering was rejected because it couples supported file size to linear-memory capacity. Disk-only output was rejected because small files and consumers needing bytes can use JavaScript memory. +Bounding decompression of crafted records was left to a bounded +`decrypt_chunk` in self_encryption: the risk predates this change and also +affects native downloads. This changes the public Rust browser descriptor's `size` from `usize` to `u64` -and Rust signatures of affected WASM exports. JavaScript reader sizes and offsets -remain numbers; the download options object and `pipeTo` are additive. Ordinary -native `data_download_range` callers retain their API, with an additional -`data_download_range_u64` entry point. Wire, storage, payment and node behavior -are unchanged. Upload size limits are unchanged. +and Rust signatures of affected WASM exports. `readRange` now rejects fractional, +negative, missing and imprecise arguments that wasm-bindgen previously coerced. +The download options object and `pipeTo` are additive. Native +`data_download_range` keeps its signature. Wire, storage, payment and node +behavior are unchanged. Upload size limits are unchanged. Content-buffer memory is bounded, but root-map/index memory still grows with the number of chunks. Streaming also depends on destination capacity; in-memory @@ -770,12 +724,10 @@ claiming literally unlimited file sizes. Validation includes a complete 4,303,347,835-byte native-encrypted fixture streamed through generated WASM with a matching BLAKE3; public/private seeks beyond 4 GiB; -disk output; backpressure; cancellation by `AbortSignal`, by closing the reader, -during retry waits, during a stalled write and while the destination closes; -a closed reader leaving another reader's cached records in place; writer and -output errors; destinations aborted for invalid calls; -allocation failures; invalid offsets and options; manifest validation; and -shared native range, single-pass retry, ordered-pipeline, ordered-hash, bounded -decryption, bounded nested-map and decompression-bomb tests. The generated -WASM tests use mocked WebRTC hosts with real Rust protocol and crypto processing. -They do not establish real-browser or live-network compatibility by themselves. +disk output; fetching bounded behind a blocked write; `AbortSignal` cancellation +during retries, stalled writes and the destination's close; invalid calls that +leave the destination untouched; writer, allocation and output errors; memory +budgets; manifest validation; and native index, range and retry tests. The +generated WASM tests use mocked WebRTC hosts with real Rust protocol and crypto +processing. They do not establish real-browser or live-network compatibility by +themselves. From 827c076e21f5c4eb739131df72c61a4ff095176b Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:41:18 +0200 Subject: [PATCH 10/15] fix(wasm): keep fetching during writes within the adaptive fetch cap The buffered(n) stream that replaced ordered_pipeline stopped polling its fetches whenever a chunk waited for the writer, and sized its concurrency from declared chunk sizes alone, so a map of tiny chunks could start tens of thousands of fetches. Restore ordered_pipeline, which polls fetches while a write is pending, follows the client's adaptive fetch cap and counts the chunk being written against the 32 MiB budget. - The backpressure test now delays GETs and requires new ones while the first write is blocked, so a stalled stream fails it. - Document that an abort during the destination's close may come after the commit, parse reader options like the other options objects, and share the download progress format. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- ant-core/src/browser/wasm_transport.rs | 6 +- .../src/browser/wasm_transport/download.rs | 92 +++--- ant-core/src/client_engine/files.rs | 265 +++++++++++++++++- ant-core/wasm-tests/large-download.test.mjs | 17 +- .../ADR-0004-direct-browser-read-client.md | 8 +- 6 files changed, 307 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 5c03a102..5a13422c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ range and reports `(bytesWritten, totalBytes)` after each write. Chunks are fetched concurrently and written in order, at most 4 MiB per write; fetching continues during writes until 32 MiB of plaintext is held. Invalid calls reject without touching the destination; otherwise the destination is closed on -success, aborted on failure, and its writer lock released. The result contains +success, aborted on failure (an abort during its close may come too late to +undo the commit), and its writer lock released. The result contains the byte count and BLAKE3 of the written range, and an `AbortSignal` cancels the call. `reader.close()` releases the records read-ahead holds and makes later calls fail. `readRange(start, length)` remains available for media and custom diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index 3453d016..eb992568 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2111,11 +2111,7 @@ struct BrowserFileReaderOptions { impl BrowserFileReaderOptions { fn from_js(options: Option) -> Result { - options - .map(serde_wasm_bindgen::from_value) - .transpose() - .map(Option::unwrap_or_default) - .map_err(|error| JsValue::from_str(&error.to_string())) + download::options_from_js(options).map_err(|error| JsValue::from_str(&error)) } } diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index 8b51ada1..950a7156 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -1,18 +1,13 @@ //! Bounded plaintext reads with 64-bit positions and caller-owned destinations. use super::*; use crate::browser::manifest::MAX_SAFE_JS_INTEGER; -use crate::client_engine::files::FileIndex; +use crate::client_engine::files::{ordered_pipeline, FileIndex}; use bytes::Bytes; -use futures_channel::mpsc; -use futures_util::{stream, SinkExt, StreamExt}; use std::ops::Range; -/// Plaintext a stream holds at once: chunks in flight, fetched, handed to the -/// writer or being written. Fetching continues during writes up to this bound. +/// Plaintext a stream holds at once: chunks in flight, fetched or being +/// written. Fetching continues during writes up to this bound. const STREAM_BUFFER_BYTES: u64 = 32 * 1024 * 1024; -/// Chunks a stream holds outside its fetch buffer: one handed to the writer -/// and one being written. -const CHUNKS_OUTSIDE_FETCH_BUFFER: u64 = 2; const STREAM_HINT: &str = "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; const CLOSED_READER: &str = "browser file reader is closed"; @@ -42,8 +37,13 @@ fn method(object: &JsValue, name: &str) -> Result { .map_err(|_| JsValue::from_str(&format!("destination has no {name} method"))) } +/// Progress of a complete download, which startup probes parse. +fn chunk_progress(written: usize, total: usize) -> String { + format!("Downloaded chunk {written}/{total}") +} + /// An optional options object; `undefined` and `null` select the defaults. -fn options_from_js( +pub(super) fn options_from_js( value: Option, ) -> Result { match value { @@ -233,54 +233,38 @@ impl Cancellation { } /// Fetch and decrypt `chunks` and hand each to `write` in file order. Fetching -/// continues while a write is pending, and the chunks in flight, fetched, -/// handed over and being written together hold at most `STREAM_BUFFER_BYTES` -/// of plaintext. Each missing record is retried on its own schedule, so retry -/// waits overlap with the other fetches. +/// continues while a write is pending, within the client's adaptive fetch cap +/// and `concurrency`, until the chunks in flight, fetched and being written +/// hold `STREAM_BUFFER_BYTES` of plaintext. Each missing record is retried on +/// its own schedule, so retry waits overlap with the other fetches. async fn stream_chunks( shared: &crate::data::Client, index: &FileIndex, chunks: Range, concurrency: usize, - mut write: W, + write: W, ) -> Result<(), JsValue> where W: FnMut(usize, Bytes) -> WF, WF: Future>, { - let buffered = (STREAM_BUFFER_BYTES / index.largest_chunk().max(1)) - .saturating_sub(CHUNKS_OUTSIDE_FETCH_BUFFER) - .max(1); - let buffered = usize::try_from(buffered) - .unwrap_or(usize::MAX) - .min(concurrency); - // A zero-capacity channel holds one chunk per sender. - let (mut handed, mut received) = mpsc::channel(0); - let fetch = async move { - let mut fetched = stream::iter(chunks) - .map(|chunk| async move { - let plaintext = shared.data_download_indexed_chunk(index, chunk).await; - (chunk, plaintext) - }) - .buffered(buffered); - while let Some(fetched) = fetched.next().await { - if handed.send(fetched).await.is_err() { - return; - } - } - }; - let deliver = async { - while let Some((chunk, plaintext)) = received.next().await { - write(chunk, plaintext.map_err(js_string)?).await?; - } - Ok(()) - }; - futures_util::pin_mut!(fetch, deliver); - match select(fetch, deliver).await { - // Every chunk has been handed over; finish writing them. - Either::Left(((), deliver)) => deliver.await, - Either::Right((delivered, _)) => delivered, - } + ordered_pipeline( + chunks, + |chunk| { + let span = index.chunk_span(chunk); + span.end - span.start + }, + STREAM_BUFFER_BYTES, + &|| shared.controller().fetch.current().min(concurrency), + |chunk| async move { + shared + .data_download_indexed_chunk(index, chunk) + .await + .map_err(js_string) + }, + write, + ) + .await } /// Download a whole file into one JavaScript `Uint8Array`, writing each chunk @@ -317,9 +301,7 @@ pub(super) async fn collect( let total = index.chunk_count(); let mut written = 0usize; let mut hasher = blake3::Hasher::new(); - settings - .progress - .report(&format!("Downloaded chunk {written}/{total}")); + settings.progress.report(&chunk_progress(written, total)); cancel .run(stream_chunks( shared, @@ -338,9 +320,7 @@ pub(super) async fn collect( view.unchecked_into::().copy_from(&plaintext); hasher.update(&plaintext); written += 1; - settings - .progress - .report(&format!("Downloaded chunk {written}/{total}")); + settings.progress.report(&chunk_progress(written, total)); }); std::future::ready(placed) }, @@ -440,8 +420,10 @@ impl BrowserFileReader { /// write, each awaited before the next; fetching continues during writes /// until 32 MiB of plaintext is held. Invalid calls reject without touching /// the destination. Otherwise the destination is closed on success and - /// aborted on failure, and the writer lock is always released. Returns the - /// BLAKE3 and byte count of the written range. + /// aborted on failure, and the writer lock is always released. An abort + /// while the destination is closing still rejects, but the destination may + /// already be committed. Returns the BLAKE3 and byte count of the written + /// range. #[wasm_bindgen(js_name = pipeTo)] pub async fn pipe_to( &self, diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 00b09385..6ea14807 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -2,6 +2,10 @@ //! filesystem or JS dependencies. Cryptography and recursive map semantics stay //! in self_encryption, the same implementation used by the native client. use bytes::Bytes; +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +use futures_util::future::{select, Either}; +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +use futures_util::stream::FuturesUnordered; use futures_util::StreamExt; use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range}; @@ -252,15 +256,6 @@ impl FileIndex { self.dst_hashes[chunk].0 } - /// The size of the largest chunk. - pub(crate) fn largest_chunk(&self) -> u64 { - self.offsets - .windows(2) - .map(|bounds| bounds[1] - bounds[0]) - .max() - .unwrap_or(0) - } - /// The self-encryption chunk metadata in chunk order. pub(crate) fn chunk_infos(&self) -> impl Iterator + '_ { (0..self.chunk_count()).map(|index| { @@ -366,6 +361,88 @@ where Ok(Bytes::from(output)) } +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +enum PipelineEvent { + Written(Result<(), X>), + Fetched(usize, Result), +} + +/// Fetch `items` concurrently and hand each result to `write` in item order. +/// Items launch in order while fewer than `cap()` are in flight and the cost +/// of launched-but-unwritten items stays within `budget`; one item can always +/// run. Fetching continues while a write is pending, so slow items and their +/// retry waits overlap with every other item the budget admits. +#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] +pub(crate) async fn ordered_pipeline( + items: Range, + cost: impl Fn(usize) -> u64, + budget: u64, + cap: &C, + fetch: F, + mut write: W, +) -> Result<(), X> +where + F: Fn(usize) -> FF, + FF: Future>, + W: FnMut(usize, T) -> WF, + WF: Future>, + C: Fn() -> usize, +{ + let fetch = &fetch; + let mut in_flight = FuturesUnordered::new(); + let mut ready = HashMap::new(); + let mut writing = None; + let (mut next_launch, mut next_write) = (items.start, items.start); + let mut outstanding = 0u64; + while next_write < items.end { + while next_launch < items.end + && in_flight.len() < cap().max(1) + && (outstanding == 0 || outstanding.saturating_add(cost(next_launch)) <= budget) + { + let item = next_launch; + in_flight.push(async move { (item, fetch(item).await) }); + outstanding = outstanding.saturating_add(cost(item)); + next_launch += 1; + } + if writing.is_none() { + if let Some(value) = ready.remove(&next_write) { + writing = Some(Box::pin(write(next_write, value))); + } + } + // `outstanding` covers the next item to write, so with nothing being + // written that item is in flight. + let event = match writing.as_mut() { + Some(pending) if in_flight.is_empty() => PipelineEvent::Written(pending.await), + Some(pending) => match select(pending, in_flight.next()).await { + Either::Left((written, _)) => PipelineEvent::Written(written), + Either::Right((fetched, _)) => { + let (item, value) = fetched.expect("items are in flight"); + PipelineEvent::Fetched(item, value) + } + }, + None => { + let (item, value) = in_flight + .next() + .await + .expect("the next item to write is in flight"); + PipelineEvent::Fetched(item, value) + } + }; + match event { + PipelineEvent::Written(written) => { + written?; + writing = None; + outstanding -= cost(next_write); + next_write += 1; + } + PipelineEvent::Fetched(item, value) => { + ready.insert(item, value?); + } + } + } + Ok(()) +} + /// Native file-download retry rounds: retry missing records together after the /// first batch settles, immediately once, then after 15 and 45 seconds. The /// caller supplies typed fatal errors and a runtime-specific timer. @@ -751,12 +828,11 @@ mod tests { infos.iter().map(|info| info.dst_hash).collect::>() ); assert_eq!(index.address(2), infos[2].dst_hash.0); - assert_eq!(index.largest_chunk(), size as u64); - // Chunk sizes are not capped natively; browsers apply their own limit. + // Chunk sizes are not capped; any positive size is indexed. let mut infos = infos; infos[1].src_size = 64 * size; let index = FileIndex::new(&DataMap::new(infos)).unwrap(); - assert_eq!(index.largest_chunk(), 64 * size as u64); + assert_eq!(index.size(), (MIN_FILE_CHUNKS + 63) as u64 * size as u64); } #[tokio::test] @@ -929,4 +1005,169 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("chunk size differs")); } + + #[tokio::test] + async fn pipeline_writes_in_order_within_its_budget() { + const ITEMS: usize = 6; + const COST: u64 = 10; + const BUDGET: u64 = 3 * COST; + let launched = std::cell::Cell::new(0u64); + let most_outstanding = std::cell::Cell::new(0u64); + let mut written = Vec::new(); + ordered_pipeline( + 0..ITEMS, + |_| COST, + BUDGET, + &|| ITEMS, + |item| { + launched.set(launched.get() + 1); + async move { + // Later items finish first. + for _ in item..ITEMS { + tokio::task::yield_now().await; + } + Ok::<_, String>(item) + } + }, + |item, value| { + assert_eq!(item, value); + let outstanding = (launched.get() - written.len() as u64) * COST; + most_outstanding.set(most_outstanding.get().max(outstanding)); + written.push(item); + async { Ok(()) } + }, + ) + .await + .unwrap(); + assert_eq!(written, (0..ITEMS).collect::>()); + assert_eq!(most_outstanding.get(), BUDGET); + } + + #[tokio::test] + async fn pipeline_keeps_fetching_while_a_write_is_pending() { + // One fetch at a time: the first write only finishes once the last + // item has been fetched, so fetching must continue behind it. + let (fetched_last, unblock) = tokio::sync::oneshot::channel(); + let fetched_last = std::cell::RefCell::new(Some(fetched_last)); + let unblock = std::cell::RefCell::new(Some(unblock)); + let mut written = Vec::new(); + let pipeline = ordered_pipeline( + 0..4, + |_| 1, + u64::MAX, + &|| 1, + |item| { + if item == 3 { + let _ = fetched_last.borrow_mut().take().unwrap().send(()); + } + async move { Ok::<_, String>(item) } + }, + |item, _| { + let unblock = (item == 0).then(|| unblock.borrow_mut().take().unwrap()); + written.push(item); + async move { + if let Some(unblock) = unblock { + unblock.await.unwrap(); + } + Ok(()) + } + }, + ); + tokio::time::timeout(std::time::Duration::from_secs(5), pipeline) + .await + .expect("fetching stalled behind a pending write") + .unwrap(); + assert_eq!(written, vec![0, 1, 2, 3]); + } + + #[tokio::test] + async fn a_slow_item_does_not_hold_back_fetches_behind_it() { + // The first item stays in flight (as during a retry wait) until the + // third has been fetched. + let (fetched_third, released) = tokio::sync::oneshot::channel(); + let fetched_third = std::cell::RefCell::new(Some(fetched_third)); + let released = std::cell::RefCell::new(Some(released)); + let completed = std::cell::RefCell::new(Vec::new()); + let pipeline = ordered_pipeline( + 0..3, + |_| 1, + 3, + &|| 3, + |item| { + let released = (item == 0).then(|| released.borrow_mut().take().unwrap()); + let fetched_third = (item == 2).then(|| fetched_third.borrow_mut().take().unwrap()); + let completed = &completed; + async move { + if let Some(released) = released { + released.await.unwrap(); + } + completed.borrow_mut().push(item); + if let Some(fetched_third) = fetched_third { + let _ = fetched_third.send(()); + } + Ok::<_, String>(item) + } + }, + |_, _| async { Ok(()) }, + ); + tokio::time::timeout(std::time::Duration::from_secs(5), pipeline) + .await + .expect("a slow item held back the items behind it") + .unwrap(); + assert_eq!(*completed.borrow(), vec![1, 2, 0]); + } + + #[tokio::test] + async fn pipeline_admits_an_oversized_item_and_stops_at_the_first_error() { + let mut written = Vec::new(); + ordered_pipeline( + 0..2, + |_| 100, + 10, + &|| 4, + |item| async move { Ok::<_, String>(item) }, + |item, _| { + written.push(item); + async { Ok(()) } + }, + ) + .await + .unwrap(); + assert_eq!(written, vec![0, 1]); + + let error = ordered_pipeline( + 0..4, + |_| 1, + 4, + &|| 4, + |item| async move { + if item == 2 { + Err(format!("fetch {item}")) + } else { + Ok(item) + } + }, + |_, _| async { Ok(()) }, + ) + .await + .unwrap_err(); + assert_eq!(error, "fetch 2"); + let error = ordered_pipeline( + 0..4, + |_| 1, + 4, + &|| 4, + |item| async move { Ok::<_, String>(item) }, + |item, _| async move { + if item == 1 { + Err(format!("write {item}")) + } else { + Ok(()) + } + }, + ) + .await + .unwrap_err(); + assert_eq!(error, "write 1"); + } } diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index e896486b..ae9389a9 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -15,8 +15,8 @@ const records = large.records.map(record => ({ })); const map = records.find(record => record.address === large.address); -function fixture(source = records) { - const rtc = mockWebRtc([{}]); +function fixture(source = records, node = {}) { + const rtc = mockWebRtc([node]); for (const record of source) rtc.stores[0].set(record.address, record.content); return { rtc, client: new BrowserNetworkClient(rtc.endpoints) }; } @@ -154,7 +154,8 @@ test("pipeTo keeps fetching behind a blocked write only up to its 32 MiB buffer" // Twelve distinct chunks: about 50 MB, more than one buffer. const original = randomBytes(12 * 4_190_208); const encrypted = encryptPublicFile(original); - const { rtc, client } = fixture(encrypted.records); + // Slow GETs are still in flight when the first write blocks. + const { rtc, client } = fixture(encrypted.records, { delay: method => method === "get_chunk" ? 30 : 0 }); let reader; try { reader = await client.openPublicFile(encrypted.address); @@ -172,15 +173,15 @@ test("pipeTo keeps fetching behind a blocked write only up to its 32 MiB buffer" const before = gets(rtc); const completion = reader.pipeTo(sink); await entered; + const blocked = gets(rtc); // Let fetching run as far as the buffer allows, then check that it stopped. - let settled = gets(rtc); + let settled = blocked; for (let previous = -1; previous !== settled; settled = gets(rtc)) { previous = settled; - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 100)); } - const buffered = settled - before; - assert(buffered > 1, "fetching continues behind a pending write"); - assert(buffered <= Math.floor(32 * 1024 * 1024 / 4_190_208), `${buffered} chunks fetched behind one blocked write`); + assert(settled > blocked, `no GET was sent while the write was blocked (${blocked})`); + assert(settled - before <= Math.floor(32 * 1024 * 1024 / 4_190_208), `${settled - before} chunks fetched behind one blocked write`); unblock(); const result = await completion; assert.equal(result.bytesWritten, original.length); diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index f63cfe0a..3303b6f6 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -680,9 +680,9 @@ read-ahead off as described under streaming reads. Read-ahead plans its windows over the same 64-bit index, so it follows reads past 4 GiB on wasm32. Complete downloads and `BrowserFileReader.pipeTo` share one transfer: chunks are -fetched concurrently, each retried on the deferred schedule on its own, and -handed on in file order. Fetching continues while the consumer works, holding -at most 32 MiB of plaintext. A complete download copies each chunk into one +fetched concurrently within the client's adaptive fetch cap, each retried on the +deferred schedule on its own, and handed on in file order. Fetching continues +while the consumer works, holding at most 32 MiB of plaintext. A complete download copies each chunk into one JavaScript `Uint8Array` allocated up front, hashes it in order and keeps the `Downloaded chunk n/total` progress. Its optional `maxMemoryBytes` limits that allocation; allocation failure rejects before file-content transfer and directs @@ -695,6 +695,8 @@ or a half-open range to a caller-owned `WritableStream`, at most 4 MiB per write awaiting each write. Like `ReadableStream.pipeTo`, it rejects invalid calls without touching the destination, closes the destination on success, aborts it on failure without waiting for a stalled write, and releases the writer lock. +An abort while the destination is closing still rejects, but per the Streams +specification the close completes, so the destination may already be committed. An `AbortSignal` cancels a `pipeTo` or a complete download, including resolution and retry waits. Closing a reader releases its read-ahead records and makes later calls fail; it no longer clears the client's shared, bounded chunk cache, From 16cb3b6a1d62cd0bbef0c3e6f01c95b49512dae4 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:01:38 +0200 Subject: [PATCH 11/15] test(wasm): make the stream backpressure and cap tests able to fail The backpressure test now requires exactly the 32 MiB buffer of chunks to be fetched behind a blocked write, which the stalled buffered stream missed (3 of 8), and fails instead of hanging if pipeTo ends before its first write. A unit test checks that ordered_pipeline keeps no more than its cap in flight. Docs note that a record waiting to retry keeps its place under the cap, and that a download's concurrency also limits it. Co-Authored-By: Claude Opus 5.5 --- .../src/browser/wasm_transport/download.rs | 3 +- ant-core/src/client_engine/files.rs | 38 +++++++++++++++++-- ant-core/wasm-tests/large-download.test.mjs | 19 +++++----- .../ADR-0004-direct-browser-read-client.md | 10 +++-- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index 950a7156..ff3a77e3 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -236,7 +236,8 @@ impl Cancellation { /// continues while a write is pending, within the client's adaptive fetch cap /// and `concurrency`, until the chunks in flight, fetched and being written /// hold `STREAM_BUFFER_BYTES` of plaintext. Each missing record is retried on -/// its own schedule, so retry waits overlap with the other fetches. +/// its own schedule, so retry waits overlap with the other fetches; a record +/// waiting to retry still holds its place under the cap. async fn stream_chunks( shared: &crate::data::Client, index: &FileIndex, diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 6ea14807..068a99ab 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -371,7 +371,8 @@ enum PipelineEvent { /// Items launch in order while fewer than `cap()` are in flight and the cost /// of launched-but-unwritten items stays within `budget`; one item can always /// run. Fetching continues while a write is pending, so slow items and their -/// retry waits overlap with every other item the budget admits. +/// retry waits overlap with the other items the cap and budget admit. An item +/// waiting to retry still holds its place under the cap. #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] pub(crate) async fn ordered_pipeline( items: Range, @@ -545,6 +546,9 @@ where mod tests { use super::*; + /// Long enough that only a stalled pipeline reaches it. + const STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + fn fixture() -> (Bytes, DataMap, HashMap<[u8; 32], Bytes>) { let content = Bytes::from((0..18000).map(|i| (i * 37) as u8).collect::>()); let (map, chunks) = self_encryption::encrypt(content.clone()).unwrap(); @@ -1043,6 +1047,34 @@ mod tests { assert_eq!(most_outstanding.get(), BUDGET); } + #[tokio::test] + async fn pipeline_keeps_no_more_than_its_cap_in_flight() { + const ITEMS: usize = 20; + const CAP: usize = 3; + let in_flight = std::cell::Cell::new(0); + let most_in_flight = std::cell::Cell::new(0); + ordered_pipeline( + 0..ITEMS, + |_| 1, + u64::MAX, + &|| CAP, + |item| { + let (in_flight, most_in_flight) = (&in_flight, &most_in_flight); + async move { + in_flight.set(in_flight.get() + 1); + most_in_flight.set(most_in_flight.get().max(in_flight.get())); + tokio::task::yield_now().await; + in_flight.set(in_flight.get() - 1); + Ok::<_, String>(item) + } + }, + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + assert_eq!(most_in_flight.get(), CAP); + } + #[tokio::test] async fn pipeline_keeps_fetching_while_a_write_is_pending() { // One fetch at a time: the first write only finishes once the last @@ -1073,7 +1105,7 @@ mod tests { } }, ); - tokio::time::timeout(std::time::Duration::from_secs(5), pipeline) + tokio::time::timeout(STALL_TIMEOUT, pipeline) .await .expect("fetching stalled behind a pending write") .unwrap(); @@ -1110,7 +1142,7 @@ mod tests { }, |_, _| async { Ok(()) }, ); - tokio::time::timeout(std::time::Duration::from_secs(5), pipeline) + tokio::time::timeout(STALL_TIMEOUT, pipeline) .await .expect("a slow item held back the items behind it") .unwrap(); diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index ae9389a9..ecbcf3c2 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -172,16 +172,17 @@ test("pipeTo keeps fetching behind a blocked write only up to its 32 MiB buffer" }); const before = gets(rtc); const completion = reader.pipeTo(sink); - await entered; - const blocked = gets(rtc); - // Let fetching run as far as the buffer allows, then check that it stopped. - let settled = blocked; - for (let previous = -1; previous !== settled; settled = gets(rtc)) { - previous = settled; - await new Promise(resolve => setTimeout(resolve, 100)); + await Promise.race([entered, completion.then(() => assert.fail("pipeTo finished without writing"))]); + // Fetching continues behind the blocked write until the buffer is full, + // and stops there. + const bufferChunks = Math.floor(32 * 1024 * 1024 / 4_190_208); + const deadline = Date.now() + 10_000; + while (gets(rtc) - before < bufferChunks && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)); } - assert(settled > blocked, `no GET was sent while the write was blocked (${blocked})`); - assert(settled - before <= Math.floor(32 * 1024 * 1024 / 4_190_208), `${settled - before} chunks fetched behind one blocked write`); + assert.equal(gets(rtc) - before, bufferChunks, "chunks fetched behind one blocked write"); + await new Promise(resolve => setTimeout(resolve, 200)); + assert.equal(gets(rtc) - before, bufferChunks, "fetching stops at the buffer bound"); unblock(); const result = await completion; assert.equal(result.bytesWritten, original.length); diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 3303b6f6..7d57e3d5 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -680,10 +680,12 @@ read-ahead off as described under streaming reads. Read-ahead plans its windows over the same 64-bit index, so it follows reads past 4 GiB on wasm32. Complete downloads and `BrowserFileReader.pipeTo` share one transfer: chunks are -fetched concurrently within the client's adaptive fetch cap, each retried on the -deferred schedule on its own, and handed on in file order. Fetching continues -while the consumer works, holding at most 32 MiB of plaintext. A complete download copies each chunk into one -JavaScript `Uint8Array` allocated up front, hashes it in order and keeps the +fetched concurrently within the client's adaptive fetch cap (and a complete +download's `concurrency` argument), each retried on the deferred schedule on its +own, and handed on in file order. Fetching continues while the consumer works, +holding at most 32 MiB of plaintext; a record waiting to retry keeps its place +under the cap. A complete download copies each chunk into one JavaScript +`Uint8Array` allocated up front, hashes it in order and keeps the `Downloaded chunk n/total` progress. Its optional `maxMemoryBytes` limits that allocation; allocation failure rejects before file-content transfer and directs the caller to streaming. Automatic free-memory detection and implicit disk spill From fc215e66a84ffa1bbbaba75af9898b44d1a0b9a0 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:44:38 +0200 Subject: [PATCH 12/15] test(browser): add an opt-in >4 GiB pipeTo-to-disk check in real Chromium large-file.mjs uploads the large-file fixture's plaintext natively to the local devnet, under a throwaway home so it stays out of the upload history, and checks it lands on the fixture's address. Real Chromium then reads across 4 GiB and pipes the whole file into the origin private file system, checking the hash, stored size and bytes, progress and the memory-budget error. Not run in CI: it needs about 5 GB of disk and a release ant binary. Co-Authored-By: Claude Opus 5.5 --- ant-core/browser-tests/README.md | 23 +++++ ant-core/browser-tests/large-file.mjs | 122 ++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 ant-core/browser-tests/large-file.mjs diff --git a/ant-core/browser-tests/README.md b/ant-core/browser-tests/README.md index 32a1ebd2..229ecf45 100644 --- a/ant-core/browser-tests/README.md +++ b/ant-core/browser-tests/README.md @@ -75,3 +75,26 @@ or node RPC mocks are installed. It also checks that a closed network client rejects reconnection. Temporary node data is removed on shutdown. The suite uses local funds only and verifies that the RPC belongs to the local Anvil devnet before invoking the wallet. + +## Large-file disk streaming + +`large-file.mjs` is an opt-in check, not run in CI, that a file larger than +4 GiB streams to disk in real Chromium. Build the production bindings as above +and a release CLI (`cargo build --release --bin ant` in the workspace root), +start `node start-devnet.mjs` in another terminal, then run: + +```sh +node large-file.mjs [path to ant] +``` + +It writes the 4,303,347,835-byte plaintext of the +[`large-file.json`](../wasm-tests/fixtures/large-file.json) fixture to a +temporary file and uploads it with `ant`, under a throwaway home so the upload +stays out of your upload history, and checks it lands on the fixture's +address. Chromium then opens the file, reads across 4 GiB, and pipes all of it +into the origin private file system with `pipeTo`. The run checks the BLAKE3 +hash and the stored size and bytes, and that a complete in-memory download over +a 64 MiB budget fails and points to `pipeTo`. It exits non-zero if any check +fails and needs about 5 GB of free disk. The file is one repeated byte, so its +records compress to about 67 KB: the run checks correctness and local +decryption and writing, not network throughput. diff --git a/ant-core/browser-tests/large-file.mjs b/ant-core/browser-tests/large-file.mjs new file mode 100644 index 00000000..edc324a2 --- /dev/null +++ b/ant-core/browser-tests/large-file.mjs @@ -0,0 +1,122 @@ +// Opt-in check, not run in CI, that a >4 GiB public file streams to disk in real Chromium. +// With `node start-devnet.mjs` running, production bindings in ../wasm-tests/pkg and a release `ant`: +// node large-file.mjs [path to ant, default ../../target/release/ant] +// Uploads the fixture's plaintext natively, then pipes it into the origin private file system. +import { chromium } from "@playwright/test"; +import { spawnSync } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +const DEVNET_API = "http://127.0.0.1:35000/api"; +const PKG = resolve("../wasm-tests/pkg"); +const WRITE_BLOCK_BYTES = 4 * 1024 * 1024; +// A complete in-memory download over this budget must fail before any transfer. +const BUDGET_BYTES = 64 * 1024 * 1024; +const FOUR_GIB = 2 ** 32; +// Bytes read on each side of a probed position. +const PROBE_BYTES = 8; + +const ant = resolve(process.argv[2] ?? "../../target/release/ant"); +const file = JSON.parse(await readFile("../wasm-tests/fixtures/large-file.json", "utf8")); +const devnet = await (await fetch(`${DEVNET_API}/devnet-manifest.json`)).json(); +const { endpoints } = await (await fetch(`${DEVNET_API}/browser-manifest.json`)).json(); +const directory = await mkdtemp(join(tmpdir(), "ant-large-file-")); +const server = createServer(async (request, response) => { + const name = request.url?.split("?")[0]; + if (name === "/") { response.setHeader("Content-Type", "text/html"); response.end("Large file"); return; } + if (!["/ant_core.js", "/ant_core_bg.wasm"].includes(name)) { response.writeHead(404).end(); return; } + response.setHeader("Content-Type", name.endsWith(".wasm") ? "application/wasm" : "text/javascript"); + response.end(await readFile(join(PKG, name.slice(1)))); +}); +let context; +try { + // The fixture's plaintext, so the native upload must land on the fixture's address. + const source = join(directory, "large-file.bin"); + const block = Buffer.alloc(WRITE_BLOCK_BYTES, file.byte); + await pipeline(Readable.from((function* () { + for (let left = file.size; left > 0; left -= block.length) yield block.subarray(0, Math.min(left, block.length)); + })()), createWriteStream(source)); + const manifest = join(directory, "devnet-manifest.json"); + await writeFile(manifest, JSON.stringify(devnet)); + // A throwaway home keeps the upload out of the real upload history. + const home = join(directory, "home"); + const upload = spawnSync(ant, ["--json", "--devnet-manifest", manifest, "--allow-loopback", "--evm-network", "local", + "file", "upload", source, "--public"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], + env: { ...process.env, HOME: home, XDG_DATA_HOME: home, XDG_CONFIG_HOME: home, SECRET_KEY: devnet.evm.wallet_private_key } }); + if (upload.status !== 0) throw new Error(`ant file upload failed with ${upload.status ?? upload.error}`); + const uploaded = JSON.parse(upload.stdout); + await rm(source); + + await new Promise(done => server.listen(0, "127.0.0.1", done)); + // A persistent profile gives the origin private file system a normal disk quota. + context = await chromium.launchPersistentContext(join(directory, "profile"), { headless: true, + args: ["--force-fieldtrials=WebRTC-NoSdpMangleUfrag/Enabled/"] }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const result = await page.evaluate(async ({ endpoint, file, budget, boundary, probe }) => { + const { default: init, BrowserNetworkClient } = await import("/ant_core.js"); + await init(); + const seconds = since => Math.round(performance.now() - since) / 1000; + const filled = bytes => bytes.every(value => value === file.byte); + const client = new BrowserNetworkClient([endpoint]); + let started = performance.now(); + const reader = await client.openPublicFile(file.address); + const openSeconds = seconds(started); + const seek = await reader.readRange(boundary - probe, 2 * probe); + + const root = await navigator.storage.getDirectory(); + const handle = await root.getFileHandle("large-file.bin", { create: true }); + let progressCalls = 0, lastProgress; + started = performance.now(); + const piped = await reader.pipeTo(await handle.createWritable(), + { onProgress: written => { progressCalls++; lastProgress = written; } }); + const pipeSeconds = seconds(started); + const stored = await handle.getFile(); + const around = new Uint8Array(await stored.slice(boundary - probe, boundary + probe).arrayBuffer()); + const tail = new Uint8Array(await stored.slice(stored.size - 2 * probe).arrayBuffer()); + const size = reader.size; + reader.close(); + + let budgetError; + try { await client.downloadPublicFile(file.address, undefined, undefined, { maxMemoryBytes: budget }); } + catch (error) { budgetError = String(error); } + // Informational: whether this browser could hold the whole file in one buffer at all. + let allocation; + try { allocation = `allocated ${new Uint8Array(file.size).length} bytes`; } + catch (error) { allocation = `${error.name}: ${error.message}`; } + client.close(); client.free(); + await root.removeEntry("large-file.bin"); + return { size, seekFilled: seek.length === 2 * probe && filled(seek), bytesWritten: piped.bytesWritten, + hash: piped.hash, storedSize: stored.size, storedFilled: filled(around) && filled(tail), + progressCalls, lastProgress, budgetError, allocation, openSeconds, pipeSeconds }; + }, { endpoint: endpoints[0], file: { address: file.address, size: file.size, byte: file.byte }, + budget: BUDGET_BYTES, boundary: FOUR_GIB, probe: PROBE_BYTES }); + + const checks = { + uploadAddress: uploaded.address === file.address, + size: result.size === file.size, + seekAcross4GiB: result.seekFilled, + bytesWritten: result.bytesWritten === file.size, + hash: result.hash === file.hash, + storedSize: result.storedSize === file.size, + storedBytes: result.storedFilled, + progress: result.lastProgress === file.size, + budgetPointsAtPipeTo: result.budgetError?.includes("pipeTo") ?? false, + noPageErrors: errors.length === 0, + }; + console.log(JSON.stringify({ browser: context.browser()?.version() ?? chromium.name(), + uploadSeconds: uploaded.elapsed_secs, result, errors, checks }, null, 2)); + const failed = Object.keys(checks).filter(name => !checks[name]); + if (failed.length > 0) { console.error(`failed: ${failed.join(", ")}`); process.exitCode = 1; } +} finally { + await context?.close(); + server.close(); + await rm(directory, { recursive: true, force: true }); +} From 773ea5dac9b7c68efcf1f9c0a73a298e9fa46d3c Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:22:30 +0200 Subject: [PATCH 13/15] fix(wasm): address large-file download review findings Range reads, read-ahead, pipeTo and complete downloads index the root DataMap with self_encryption's DataMapIndex (0.36.2) instead of an ant-core copy of its chunk-index and key-derivation contract. The index uses 64-bit positions on every platform, checks each decrypted chunk against the size and source hash the map declares, and stops decompression at the declared size. Complete downloads no longer run through pipeTo's in-order stream. They fetch every chunk as a native complete download does, within the adaptive fetch cap and the caller's concurrency, retrying missing records together in deferred rounds, and copy each decrypted chunk straight into its place in the output as it arrives. The stream's 32 MiB bound had capped them at about eight fetches, and its per-chunk retries added the waits of each missing record one after another. The file is hashed afterwards, and progress again counts chunks as they land. A complete download also checks its AbortSignal before allocating the output. Signals and destinations use the typed web-sys AbortSignal and WritableStream bindings, so an EventTarget that is not an AbortSignal is rejected. Safe-integer validation uses Number.isSafeInteger, and the large-file fixture generator addresses its DataMap record with compute_address. The workspace temporarily patches self_encryption until 0.36.2 is published. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 5 +- Cargo.toml | 4 + README.md | 6 +- ant-core/Cargo.toml | 5 +- .../examples/generate-browser-large-file.rs | 2 +- ant-core/src/browser/wasm_transport.rs | 14 +- .../src/browser/wasm_transport/download.rs | 255 ++++++------ .../src/browser/wasm_transport/read_ahead.rs | 20 +- ant-core/src/client_engine/files.rs | 371 ++++++------------ ant-core/src/client_engine/read_ahead.rs | 33 +- ant-core/src/data/client/data.rs | 42 +- ant-core/wasm-tests/large-download.test.mjs | 1 + .../ADR-0004-direct-browser-read-client.md | 66 ++-- 13 files changed, 386 insertions(+), 438 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9da2f580..6605d77a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5809,9 +5809,8 @@ dependencies = [ [[package]] name = "self_encryption" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ab904569f88dcbde4f0feadb693c184577dc81e8243f96bb725e72a779c637" +version = "0.36.2" +source = "git+https://github.com/WithAutonomi/self_encryption?rev=e8aa39ee1337c11f52628356d97fa86ac0d1007d#e8aa39ee1337c11f52628356d97fa86ac0d1007d" dependencies = [ "bincode", "blake3", diff --git a/Cargo.toml b/Cargo.toml index ec8f5ce5..e09c858a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,7 @@ members = ["ant-core", "ant-cli"] resolver = "2" + +# Temporary: DataMapIndex from WithAutonomi/self_encryption#447, until 0.36.2 is published. +[patch.crates-io] +self_encryption = { git = "https://github.com/WithAutonomi/self_encryption", rev = "e8aa39ee1337c11f52628356d97fa86ac0d1007d" } diff --git a/README.md b/README.md index 5a13422c..007e9267 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ Uploads retain their existing size limits. `downloadPublicFile(file, concurrency?, onProgress?, { maxMemoryBytes?, signal? })` and its private-file counterpart allocate the output as one JavaScript -`Uint8Array` and copy each decrypted chunk into it, holding at most 32 MiB of -plaintext in WASM. Progress is reported as `Downloaded chunk n/total`, and an -`AbortSignal` cancels the download. `maxMemoryBytes` limits the output buffer, +`Uint8Array` and copy each decrypted chunk into place as it arrives, so WASM +holds only the records in flight. Progress is reported as +`Downloaded chunk n/total`, and an `AbortSignal` cancels the download. `maxMemoryBytes` limits the output buffer, not total process memory; without it, allocation is attempted up to the JavaScript engine's buffer limit. Allocation errors reject with guidance to use streaming, since browsers do not expose a reliable amount of free memory. diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 8abc470c..d435fdaf 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -24,7 +24,7 @@ rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" -self_encryption = "0.36" +self_encryption = "0.36.2" # Portable WebRTC Direct profile shared with ant-node. saorsa-transport = { version = "0.37.0", default-features = false, features = ["webrtc"] } thiserror = "2" @@ -91,6 +91,7 @@ serde-wasm-bindgen = { version = "0.6", optional = true } wasm-bindgen = { version = "0.2", optional = true } wasm-bindgen-futures = { version = "0.4", optional = true } web-sys = { version = "0.3", optional = true, features = [ + "AbortSignal", "Event", "MessageEvent", "RtcConfiguration", @@ -104,6 +105,8 @@ web-sys = { version = "0.3", optional = true, features = [ "RtcSdpType", "RtcSessionDescription", "RtcSessionDescriptionInit", + "WritableStream", + "WritableStreamDefaultWriter", ] } gloo-timers = { version = "0.3", features = ["futures"], optional = true } # Deterministic mock-node signing for generated-WASM transport regression tests. diff --git a/ant-core/examples/generate-browser-large-file.rs b/ant-core/examples/generate-browser-large-file.rs index 746ec55a..d703da34 100644 --- a/ant-core/examples/generate-browser-large-file.rs +++ b/ant-core/examples/generate-browser-large-file.rs @@ -37,7 +37,7 @@ fn main() { assert_eq!(root.original_file_size() as u64, size); let mut retained = records; let map_bytes = rmp_serde::to_vec(&map).unwrap(); - let address = *blake3::hash(&map_bytes).as_bytes(); + let address = ant_protocol::compute_address(&map_bytes); retained.insert(self_encryption::XorName(address), Bytes::from(map_bytes)); let mut records: Vec<_> = retained.into_iter().map(|(hash, content)| { serde_json::json!({"address": hex::encode(hash.0), "content": hex::encode(content)}) diff --git a/ant-core/src/browser/wasm_transport.rs b/ant-core/src/browser/wasm_transport.rs index eb992568..cb859c82 100644 --- a/ant-core/src/browser/wasm_transport.rs +++ b/ant-core/src/browser/wasm_transport.rs @@ -2127,7 +2127,7 @@ struct ResolvedBrowserFile { /// Descriptor without a chunk list; `index` holds the chunk metadata. file: PublicFileDescriptor, data_map_node: Option, - index: crate::client_engine::files::FileIndex, + index: self_encryption::DataMapIndex, } struct UploadRecord { @@ -2685,7 +2685,7 @@ impl BrowserNetworkClient { "Ready to stream {} ({} bytes, {} chunks)", resolved.file.name, resolved.file.size, - resolved.index.chunk_count() + resolved.index.infos().len() )); Ok(BrowserFileReader::new( Rc::clone(&self.shared), @@ -2822,12 +2822,16 @@ impl BrowserNetworkClient { .await .map_err(|error| error.to_string())?; - let index = crate::client_engine::files::FileIndex::new(&root_data_map)?; - if index.size() > super::manifest::MAX_SAFE_JS_INTEGER { + let index = self_encryption::DataMapIndex::new(&root_data_map) + .map_err(|error| error.to_string())?; + if index.infos().len() < crate::client_engine::files::MIN_FILE_CHUNKS { + return Err("ant-core returned an invalid public DataMap".to_string()); + } + if index.file_size() > super::manifest::MAX_SAFE_JS_INTEGER { return Err("file size exceeds JavaScript's exact integer range".into()); } - file.size = index.size(); + file.size = index.file_size(); file.chunks = Vec::new(); // Listed from the index once the file is read. file.data_map_size = encoded_data_map.len(); file.blake3.clear(); // Computed when plaintext is read; not a second content identity. diff --git a/ant-core/src/browser/wasm_transport/download.rs b/ant-core/src/browser/wasm_transport/download.rs index ff3a77e3..b989a9eb 100644 --- a/ant-core/src/browser/wasm_transport/download.rs +++ b/ant-core/src/browser/wasm_transport/download.rs @@ -1,27 +1,32 @@ //! Bounded plaintext reads with 64-bit positions and caller-owned destinations. use super::*; -use crate::browser::manifest::MAX_SAFE_JS_INTEGER; -use crate::client_engine::files::{ordered_pipeline, FileIndex}; +use crate::client_engine::files::ordered_pipeline; use bytes::Bytes; +use self_encryption::DataMapIndex; use std::ops::Range; +use web_sys::{AbortSignal, WritableStream, WritableStreamDefaultWriter}; /// Plaintext a stream holds at once: chunks in flight, fetched or being /// written. Fetching continues during writes up to this bound. const STREAM_BUFFER_BYTES: u64 = 32 * 1024 * 1024; +/// Plaintext a complete download copies back into WASM at a time to hash it. +const HASH_PIECE_BYTES: u64 = 4 * 1024 * 1024; const STREAM_HINT: &str = "use openPublicFile/openPrivateFile and reader.pipeTo(writable) to stream to disk"; const CLOSED_READER: &str = "browser file reader is closed"; const ABORTED: &str = "download aborted"; const ABORT_EVENT: &str = "abort"; const POSITIVE_CONCURRENCY: &str = "download concurrency must be a positive integer"; +const NOT_ABORT_SIGNAL: &str = "signal must be an AbortSignal"; +const NOT_WRITABLE_STREAM: &str = "destination must be a WritableStream"; +/// The output's constructor and view method, called through `Reflect` because +/// the `js_sys` bindings take `u32` lengths and offsets. +const UINT8_ARRAY: &str = "Uint8Array"; +const SUBARRAY: &str = "subarray"; /// A nonnegative integer that JavaScript represents exactly. fn safe_integer(value: f64, label: &str) -> Result { - if !value.is_finite() - || value < 0.0 - || value.fract() != 0.0 - || value > MAX_SAFE_JS_INTEGER as f64 - { + if value < 0.0 || !js_sys::Number::is_safe_integer(&JsValue::from_f64(value)) { return Err(format!("{label} must be a nonnegative safe integer")); } Ok(value as u64) @@ -31,12 +36,6 @@ fn js_string(error: impl std::fmt::Display) -> JsValue { JsValue::from_str(&error.to_string()) } -fn method(object: &JsValue, name: &str) -> Result { - js_sys::Reflect::get(object, &JsValue::from_str(name))? - .dyn_into::() - .map_err(|_| JsValue::from_str(&format!("destination has no {name} method"))) -} - /// Progress of a complete download, which startup probes parse. fn chunk_progress(written: usize, total: usize) -> String { format!("Downloaded chunk {written}/{total}") @@ -55,12 +54,10 @@ pub(super) fn options_from_js( } /// Release the writer even if the Rust future is dropped before it completes. -struct Writer(JsValue); +struct Writer(WritableStreamDefaultWriter); impl Drop for Writer { fn drop(&mut self) { - if let Ok(release) = method(&self.0, "releaseLock") { - let _ = release.call0(&self.0); - } + self.0.release_lock(); } } @@ -133,13 +130,16 @@ impl DownloadSettings { /// An AbortSignal's `abort` event. Detaches its listener when dropped. struct AbortListener { - signal: JsValue, + signal: AbortSignal, callback: Closure, fired: oneshot::Receiver<()>, } impl AbortListener { fn new(signal: JsValue) -> Result { + let signal = signal + .dyn_into::() + .map_err(|_| JsValue::from_str(NOT_ABORT_SIGNAL))?; let (sender, fired) = oneshot::channel(); let mut sender = Some(sender); let callback = Closure::::new(move || { @@ -147,13 +147,7 @@ impl AbortListener { let _ = sender.send(()); } }); - method(&signal, "addEventListener") - .map_err(|_| JsValue::from_str("signal must be an AbortSignal"))? - .call2( - &signal, - &JsValue::from_str(ABORT_EVENT), - callback.as_ref().unchecked_ref(), - )?; + signal.add_event_listener_with_callback(ABORT_EVENT, callback.as_ref().unchecked_ref())?; Ok(Self { signal, callback, @@ -163,26 +157,23 @@ impl AbortListener { /// The signal's abort reason, once it has aborted. fn reason(&self) -> Option { - let aborted = js_sys::Reflect::get(&self.signal, &JsValue::from_str("aborted")) - .is_ok_and(|aborted| aborted.is_truthy()); - aborted.then(|| { - js_sys::Reflect::get(&self.signal, &JsValue::from_str("reason")) - .ok() - .filter(|reason| !reason.is_undefined()) - .unwrap_or_else(|| JsValue::from_str(ABORTED)) + self.signal.aborted().then(|| { + let reason = self.signal.reason(); + if reason.is_undefined() { + JsValue::from_str(ABORTED) + } else { + reason + } }) } } impl Drop for AbortListener { fn drop(&mut self) { - if let Ok(remove) = method(&self.signal, "removeEventListener") { - let _ = remove.call2( - &self.signal, - &JsValue::from_str(ABORT_EVENT), - self.callback.as_ref().unchecked_ref(), - ); - } + let _ = self.signal.remove_event_listener_with_callback( + ABORT_EVENT, + self.callback.as_ref().unchecked_ref(), + ); } } @@ -233,16 +224,15 @@ impl Cancellation { } /// Fetch and decrypt `chunks` and hand each to `write` in file order. Fetching -/// continues while a write is pending, within the client's adaptive fetch cap -/// and `concurrency`, until the chunks in flight, fetched and being written -/// hold `STREAM_BUFFER_BYTES` of plaintext. Each missing record is retried on -/// its own schedule, so retry waits overlap with the other fetches; a record +/// continues while a write is pending, within the client's adaptive fetch cap, +/// until the chunks in flight, fetched and being written hold +/// `STREAM_BUFFER_BYTES` of plaintext. Each missing record is retried on its +/// own schedule, so retry waits overlap with the other fetches; a record /// waiting to retry still holds its place under the cap. async fn stream_chunks( shared: &crate::data::Client, - index: &FileIndex, + index: &DataMapIndex, chunks: Range, - concurrency: usize, write: W, ) -> Result<(), JsValue> where @@ -252,11 +242,11 @@ where ordered_pipeline( chunks, |chunk| { - let span = index.chunk_span(chunk); + let span = index.chunk_range(chunk); span.end - span.start }, STREAM_BUFFER_BYTES, - &|| shared.controller().fetch.current().min(concurrency), + &|| shared.controller().fetch.current(), |chunk| async move { shared .data_download_indexed_chunk(index, chunk) @@ -268,77 +258,105 @@ where .await } -/// Download a whole file into one JavaScript `Uint8Array`, writing each chunk -/// straight into it from WASM memory. A caller can impose its own memory -/// budget; allocation failures recommend the disk-backed path without -/// downloading the file first. +/// Download a whole file into one JavaScript `Uint8Array`. Chunks are fetched +/// as a complete download fetches them, in any order, and each is copied +/// straight into place from WASM memory; the file is hashed afterwards. A +/// caller can impose its own memory budget; allocation failures recommend the +/// disk-backed path without downloading the file first. pub(super) async fn collect( shared: &crate::data::Client, - index: &FileIndex, + index: &DataMapIndex, settings: &DownloadSettings, cancel: &mut Cancellation, ) -> Result<(Uint8Array, String), JsValue> { - let size = index.size(); + cancel.check()?; + let size = index.file_size(); if settings.memory_budget.is_some_and(|limit| size > limit) { return Err(js_string(format_args!( "file exceeds the download memory budget; {STREAM_HINT}" ))); } - let constructor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("Uint8Array"))? - .dyn_into::()?; - let args = Array::new(); - args.push(&JsValue::from_f64(size as f64)); - let output = js_sys::Reflect::construct(&constructor, &args) - .map_err(|error| { - js_string(format_args!( - "cannot allocate download buffer: {}; {STREAM_HINT}", - js_error_message(error) - )) - })? - .unchecked_into::(); // Reflect calls preserve Number offsets above u32::MAX and catch JS // allocation exceptions instead of turning them into WASM traps. - let subarray = method(output.as_ref(), "subarray")?; - let total = index.chunk_count(); - let mut written = 0usize; - let mut hasher = blake3::Hasher::new(); - settings.progress.report(&chunk_progress(written, total)); + let constructor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str(UINT8_ARRAY))? + .dyn_into::()?; + let output = + js_sys::Reflect::construct(&constructor, &Array::of1(&JsValue::from_f64(size as f64))) + .map_err(|error| { + js_string(format_args!( + "cannot allocate download buffer: {}; {STREAM_HINT}", + js_error_message(error) + )) + })? + .unchecked_into::(); + let subarray = js_sys::Reflect::get(&output, &JsValue::from_str(SUBARRAY))? + .dyn_into::()?; + let view = |span: Range| { + subarray + .call2( + &output, + &JsValue::from_f64(span.start as f64), + &JsValue::from_f64(span.end as f64), + ) + .map(|view| view.unchecked_into::()) + }; + let total = index.infos().len(); + let written = Cell::new(0); + // The output's own error, which the caller receives instead of a message. + let output_error = RefCell::new(None); + settings.progress.report(&chunk_progress(0, total)); cancel - .run(stream_chunks( - shared, - index, - 0..total, - settings.concurrency, - |chunk, plaintext| { - let span = index.chunk_span(chunk); - let placed = subarray - .call2( - output.as_ref(), - &JsValue::from_f64(span.start as f64), - &JsValue::from_f64(span.end as f64), - ) - .map(|view| { - view.unchecked_into::().copy_from(&plaintext); - hasher.update(&plaintext); - written += 1; - settings.progress.report(&chunk_progress(written, total)); - }); - std::future::ready(placed) - }, - )) + .run(async { + shared + .data_download_indexed_chunks(index, settings.concurrency, |chunk, plaintext| { + let placed = view(index.chunk_range(chunk)).map_err(|error| { + output_error.replace(Some(error.clone())); + js_error_message(error) + })?; + placed.copy_from(&plaintext); + written.set(written.get() + 1); + settings + .progress + .report(&chunk_progress(written.get(), total)); + Ok(()) + }) + .await + .map_err(|error| output_error.take().unwrap_or_else(|| js_string(error))) + }) .await?; - Ok((output, hasher.finalize().to_hex().to_string())) + let hash = output_hash(size, view)?; + Ok((output, hash)) +} + +/// BLAKE3 of a `size`-byte output, copied back into WASM through `view` in +/// `HASH_PIECE_BYTES` pieces. +fn output_hash( + size: u64, + view: impl Fn(Range) -> Result, +) -> Result { + let mut hasher = blake3::Hasher::new(); + let mut buffer = vec![0; size.min(HASH_PIECE_BYTES) as usize]; + let mut start = 0; + while start < size { + let end = size.min(start + HASH_PIECE_BYTES); + let piece = &mut buffer[..(end - start) as usize]; + view(start..end)?.copy_to(piece); + hasher.update(piece); + start = end; + } + Ok(hasher.finalize().to_hex().to_string()) } /// The descriptor of a completely read file, with its chunk list and hash. pub(super) fn completed_descriptor( mut file: PublicFileDescriptor, - index: &FileIndex, + index: &DataMapIndex, hash: String, ) -> PublicFileDescriptor { file.chunks = index - .chunk_infos() - .map(|info| super::super::browser_chunk_info(&info)) + .infos() + .iter() + .map(super::super::browser_chunk_info) .collect(); file.blake3 = hash; file @@ -358,7 +376,7 @@ pub struct BrowserFileReader { shared: Rc, /// Descriptor without its chunk list; the index holds the chunk metadata. file: PublicFileDescriptor, - index: Rc, + index: Rc, /// Fetches ahead of sequential range reads; see `read_ahead`. pub(super) read_ahead: Rc, closed: Cell, @@ -432,19 +450,18 @@ impl BrowserFileReader { options: Option, ) -> Result { let plan = self.plan_pipe(options)?; - let writer = Writer(method(&writable, "getWriter")?.call0(&writable)?); + let writable = writable + .dyn_into::() + .map_err(|_| JsValue::from_str(NOT_WRITABLE_STREAM))?; + let writer = Writer(writable.get_writer()?); let result = self.pipe(&writer.0, plan).await; if let Err(error) = &result { - if let Ok(abort) = method(&writer.0, "abort") { - if let Ok(promise) = abort.call1(&writer.0, error) { - // Not awaited: an abort settles only once a stalled write - // does, and the rejection must not wait for that. - let settled = JsFuture::from(Promise::resolve(&promise)); - wasm_bindgen_futures::spawn_local(async move { - let _ = settled.await; - }); - } - } + // Not awaited: an abort settles only once a stalled write does, + // and the rejection must not wait for that. + let settled = JsFuture::from(writer.0.abort_with_reason(error)); + wasm_bindgen_futures::spawn_local(async move { + let _ = settled.await; + }); } result } @@ -518,14 +535,17 @@ impl BrowserFileReader { }) } - async fn pipe(&self, writer: &JsValue, plan: PipePlan) -> Result { + async fn pipe( + &self, + writer: &WritableStreamDefaultWriter, + plan: PipePlan, + ) -> Result { let PipePlan { range, progress, mut cancel, } = plan; - let write = method(writer, "write")?; - let (write, progress) = (&write, &progress); + let progress = &progress; let total = range.end - range.start; let mut hasher = blake3::Hasher::new(); cancel @@ -534,9 +554,8 @@ impl BrowserFileReader { &self.shared, &self.index, self.index.chunks_overlapping(range.clone()), - usize::MAX, |chunk, plaintext| { - let span = self.index.chunk_span(chunk); + let span = self.index.chunk_range(chunk); let from = range.start.max(span.start); let to = range.end.min(span.end); let plaintext = plaintext @@ -545,10 +564,8 @@ impl BrowserFileReader { let mut written = from - range.start; async move { for piece in plaintext.chunks(MAX_BROWSER_RANGE_BYTES) { - JsFuture::from(Promise::resolve( - &write.call1(writer, &Uint8Array::from(piece))?, - )) - .await?; + JsFuture::from(writer.write_with_chunk(&Uint8Array::from(piece))) + .await?; written += piece.len() as u64; if let Some(callback) = progress { callback.call2( @@ -563,9 +580,7 @@ impl BrowserFileReader { }, ) .await?; - JsFuture::from(Promise::resolve(&method(writer, "close")?.call0(writer)?)) - .await - .map(drop) + JsFuture::from(writer.close()).await.map(drop) }) .await?; serde_wasm_bindgen::to_value(&PipeResult { diff --git a/ant-core/src/browser/wasm_transport/read_ahead.rs b/ant-core/src/browser/wasm_transport/read_ahead.rs index 5cedc7e0..026204e1 100644 --- a/ant-core/src/browser/wasm_transport/read_ahead.rs +++ b/ant-core/src/browser/wasm_transport/read_ahead.rs @@ -15,13 +15,12 @@ //! read waited for inform the reads' adaptive concurrency, and read-ahead keeps //! its records out of the shared chunk cache. use super::{BrowserNetworkCore, SharedNetworkAdapter}; -use crate::client_engine::files::FileIndex; -use crate::client_engine::read_ahead::releasable; +use crate::client_engine::read_ahead::{read_ahead_window, releasable}; use crate::client_engine::read_budget::ReadBudget; use crate::data::{ChunkCache, Client, ClientConfig, Error, Network}; use bytes::Bytes; use futures::future::{AbortHandle, Abortable, FutureExt, LocalBoxFuture, Shared}; -use self_encryption::MAX_CHUNK_SIZE; +use self_encryption::{DataMapIndex, MAX_CHUNK_SIZE}; use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::ops::Range; @@ -208,7 +207,7 @@ impl ReadAheadPool { pub(super) struct ReadAhead { pool: Rc, /// The reader's index of the file, shared with it. - layout: Rc, + layout: Rc, /// Every read starts a sequential stream, as media playback's reads do. streaming: bool, /// Ends of recent reads; a read starting at one continues it. @@ -233,7 +232,7 @@ impl ReadAhead { /// 64-bit, so read-ahead follows reads past 4 GiB on every platform. pub(super) fn new( pool: &Rc, - layout: Rc, + layout: Rc, streaming: bool, ) -> Rc { let reader = Rc::new(Self { @@ -278,7 +277,7 @@ impl ReadAhead { let read = self.next_id(); let addresses = needed .clone() - .map(|index| self.layout.address(index)) + .map(|index| self.layout.infos()[index].dst_hash.0) .collect::>(); let indices = needed .clone() @@ -311,11 +310,11 @@ impl ReadAhead { } if sequential { self.fill(); - } else if needed.end < self.layout.chunk_count() { + } else if needed.end < self.layout.infos().len() { self.prefetch(needed.end); } if self.streaming && start == 0 { - if let Some(last) = self.layout.chunk_count().checked_sub(1) { + if let Some(last) = self.layout.infos().len().checked_sub(1) { self.prefetch(last); } } @@ -359,8 +358,7 @@ impl ReadAhead { fn window(&self) -> Range { self.anchor.get().map_or(0..0, |anchor| { - self.layout - .window(anchor, READ_AHEAD_BYTES, MAX_WINDOW_RECORDS) + read_ahead_window(&self.layout, anchor, READ_AHEAD_BYTES, MAX_WINDOW_RECORDS) }) } @@ -428,7 +426,7 @@ impl ReadAhead { } let id = self.next_id(); let (abort, registration) = AbortHandle::new_pair(); - let address = self.layout.address(index); + let address = self.layout.infos()[index].dst_hash.0; let this = Rc::clone(self); let fetch = async move { let result = Abortable::new(this.pool.fetch(address), registration).await; diff --git a/ant-core/src/client_engine/files.rs b/ant-core/src/client_engine/files.rs index 068a99ab..a830ed3d 100644 --- a/ant-core/src/client_engine/files.rs +++ b/ant-core/src/client_engine/files.rs @@ -7,13 +7,11 @@ use futures_util::future::{select, Either}; #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] use futures_util::stream::FuturesUnordered; use futures_util::StreamExt; -use self_encryption::{ChunkInfo, DataMap, EncryptedChunk, XorName}; -use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range}; +use self_encryption::{DataMap, DataMapIndex, EncryptedChunk}; +use std::{cell::RefCell, collections::HashMap, future::Future, ops::Range, sync::Mutex}; /// self_encryption splits every encryptable file into at least three chunks. pub(crate) const MIN_FILE_CHUNKS: usize = 3; -/// Content chunks of a resolved root DataMap are encrypted at KDF level zero. -const CONTENT_CHUNK_KDF_LEVEL: usize = 0; pub(crate) type RecordRequest = (usize, [u8; 32]); @@ -143,164 +141,55 @@ where .enumerate() .map(|(index, info)| (index, info.dst_hash.0)) .collect(); - let chunks = fetch_records_deferred(requests, fetch, cap, sleep, retryable) - .await? - .into_iter() - .map(|(_, content)| EncryptedChunk { content }) - .collect::>(); + let chunks = + fetch_records_deferred(requests, fetch, cap, sleep, retryable, |_, bytes| Ok(bytes)) + .await? + .into_iter() + .map(|(_, content)| EncryptedChunk { content }) + .collect::>(); self_encryption::decrypt(&root, &chunks).map_err(|e| ReadError::Invalid(e.to_string())) } -/// Validated root-map index. File positions are independent of the platform's -/// pointer width; only individual chunk buffers and indices use `usize`. -/// Build once per reader so each seek is O(log chunks + overlapping chunks). -pub(crate) struct FileIndex { - /// Plaintext hashes in chunk order. Each chunk's key derives from its - /// neighbours, so decryption needs the whole list. - src_hashes: Vec, - /// Encrypted record addresses in chunk order. - dst_hashes: Vec, - /// Plaintext start of each chunk, followed by the file size. - offsets: Vec, -} - -impl FileIndex { - /// Chunk sizes are taken from the map, so maps produced with another - /// self-encryption chunk size remain readable. - pub(crate) fn new(map: &DataMap) -> Result { - if map.is_child() { - return Err("range reads require a resolved root DataMap".into()); - } - let mut infos: Vec<&ChunkInfo> = map.infos().iter().collect(); - infos.sort_by_key(|info| info.index); - if infos.len() < MIN_FILE_CHUNKS { - return Err(format!( - "DataMap requires at least {MIN_FILE_CHUNKS} chunks" - )); - } - let mut offsets = Vec::with_capacity(infos.len() + 1); - offsets.push(0u64); - for (index, info) in infos.iter().enumerate() { - if info.index != index { - return Err("DataMap chunk indices must be contiguous".into()); - } - if info.src_size == 0 { - return Err("invalid DataMap plaintext chunk size".into()); - } - let end = offsets[index] - .checked_add(info.src_size as u64) - .ok_or("DataMap plaintext size overflow")?; - offsets.push(end); - } - Ok(Self { - src_hashes: infos.iter().map(|info| info.src_hash).collect(), - dst_hashes: infos.iter().map(|info| info.dst_hash).collect(), - offsets, - }) - } - - pub(crate) fn size(&self) -> u64 { - *self.offsets.last().expect("index includes zero offset") - } - - /// Half-open plaintext byte span of one chunk. - pub(crate) fn chunk_span(&self, chunk: usize) -> Range { - self.offsets[chunk]..self.offsets[chunk + 1] - } - - /// Chunks overlapping a half-open byte range, clamped at EOF. - pub(crate) fn chunks_overlapping(&self, range: Range) -> Range { - let end = range.end.min(self.size()); - if range.start >= end { - return 0..0; - } - let first = self - .offsets - .partition_point(|offset| *offset <= range.start) - - 1; - let last = self.offsets.partition_point(|offset| *offset < end); - first..last - } - - /// Decrypt one verified record and check it against the map's plaintext - /// size and hash. - fn decrypt(&self, chunk: usize, encrypted: &Bytes) -> Result { - // The dependency's get_range uses usize file offsets. Its public - // per-chunk primitive has no whole-file arithmetic and retains the - // canonical KDF (original chunk index and neighbouring hashes). - let plaintext = self_encryption::decrypt_chunk( - chunk, - encrypted, - &self.src_hashes, - CONTENT_CHUNK_KDF_LEVEL, - ) - .map_err(|e| e.to_string())?; - let span = self.chunk_span(chunk); - if plaintext.len() as u64 != span.end - span.start { - return Err("decrypted chunk size differs from DataMap".into()); - } - crate::record::verify(&self.src_hashes[chunk].0, &plaintext)?; - Ok(plaintext) - } -} - -/// Browser readers and complete downloads. -#[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] -impl FileIndex { - pub(crate) fn chunk_count(&self) -> usize { - self.dst_hashes.len() - } - - /// Encrypted record address of one chunk. - pub(crate) fn address(&self, chunk: usize) -> [u8; 32] { - self.dst_hashes[chunk].0 - } - - /// The self-encryption chunk metadata in chunk order. - pub(crate) fn chunk_infos(&self) -> impl Iterator + '_ { - (0..self.chunk_count()).map(|index| { - let span = self.chunk_span(index); - ChunkInfo { - index, - dst_hash: self.dst_hashes[index], - src_hash: self.src_hashes[index], - // Each size was a usize in the DataMap this index came from. - src_size: (span.end - span.start) as usize, - } - }) - } -} - -/// Fetch, verify and decrypt `chunks` in one deferred retry pass, in chunk -/// order. -pub(crate) async fn read_chunks( - index: &FileIndex, +/// Fetch, verify and decrypt `chunks` in one deferred retry pass, handing each +/// plaintext chunk to `deliver` as it arrives, in any order. Nothing is kept +/// once delivered, so memory is bounded by the fetches in flight. +pub(crate) async fn read_chunks( + index: &DataMapIndex, chunks: Range, fetch: &F, cap: &C, sleep: &S, retryable: fn(&E) -> bool, -) -> Result, ReadError> + deliver: D, +) -> Result<(), ReadError> where F: Fn([u8; 32]) -> Fut, Fut: Future>, C: Fn() -> usize, S: Fn(std::time::Duration) -> SF, SF: Future, + D: Fn(usize, Bytes) -> Result<(), String>, { - let requests = chunks - .map(|chunk| (chunk, index.dst_hashes[chunk].0)) + let requests = index.infos()[chunks] + .iter() + .map(|info| (info.index, info.dst_hash.0)) .collect(); - fetch_records_deferred(requests, fetch, cap, sleep, retryable) - .await? - .into_iter() - .map(|(chunk, encrypted)| { + fetch_records_deferred( + requests, + fetch, + cap, + sleep, + retryable, + |chunk, encrypted| { let plaintext = index - .decrypt(chunk, &encrypted) - .map_err(ReadError::Invalid)?; - Ok((chunk, plaintext)) - }) - .collect() + .decrypt_chunk(chunk, &encrypted) + .map_err(|e| e.to_string())?; + deliver(chunk, plaintext)?; + Ok(Bytes::new()) + }, + ) + .await + .map(drop) } pub(crate) async fn read_range( @@ -319,14 +208,14 @@ where S: Fn(std::time::Duration) -> SF, SF: Future, { - let index = FileIndex::new(root).map_err(ReadError::Invalid)?; + let index = DataMapIndex::new(root).map_err(|e| ReadError::Invalid(e.to_string()))?; read_indexed_range(&index, start, length, fetch, cap, sleep, retryable).await } /// Half-open plaintext range. EOF and zero-length reads have the same /// semantics on every platform. pub(crate) async fn read_indexed_range( - index: &FileIndex, + index: &DataMapIndex, start: u64, length: usize, fetch: &F, @@ -341,7 +230,7 @@ where S: Fn(std::time::Duration) -> SF, SF: Future, { - let end = start.saturating_add(length as u64).min(index.size()); + let end = start.saturating_add(length as u64).min(index.file_size()); if start >= end { return Ok(Bytes::new()); } @@ -351,14 +240,28 @@ where output .try_reserve_exact(length) .map_err(|e| ReadError::Invalid(format!("cannot allocate range: {e}")))?; - let chunks = index.chunks_overlapping(start..end); - for (chunk, plaintext) in read_chunks(index, chunks, fetch, cap, sleep, retryable).await? { - let span = index.chunk_span(chunk); - let from = start.max(span.start) - span.start; - let to = end.min(span.end) - span.start; - output.extend_from_slice(&plaintext[from as usize..to as usize]); - } - Ok(Bytes::from(output)) + output.resize(length, 0); + let output = Mutex::new(output); + read_chunks( + index, + index.chunks_overlapping(start..end), + fetch, + cap, + sleep, + retryable, + |chunk, plaintext| { + let span = index.chunk_range(chunk); + let (from, to) = (start.max(span.start), end.min(span.end)); + output.lock().expect("range output lock") + [(from - start) as usize..(to - start) as usize] + .copy_from_slice( + &plaintext[(from - span.start) as usize..(to - span.start) as usize], + ); + Ok(()) + }, + ) + .await?; + Ok(Bytes::from(output.into_inner().expect("range output lock"))) } #[cfg(any(all(feature = "browser-wasm", target_arch = "wasm32"), test))] @@ -490,12 +393,15 @@ where Ok(results) } -async fn fetch_records_deferred( +/// Fetch and verify records in deferred retry rounds. `accept` turns each +/// verified record into the bytes kept for it, or rejects it as invalid. +async fn fetch_records_deferred( requests: Vec, fetch: &F, cap: &C, sleep: &S, retryable: fn(&E) -> bool, + accept: A, ) -> Result, ReadError> where F: Fn([u8; 32]) -> Fut, @@ -503,18 +409,20 @@ where C: Fn() -> usize, S: Fn(std::time::Duration) -> SF, SF: Future, + A: Fn(usize, Bytes) -> Result, { // Preserve the adapter's final typed fetch error across deferred rounds. - let errors = std::sync::Mutex::new(HashMap::new()); + let errors = Mutex::new(HashMap::new()); deferred_batch( requests, |index, address, _| { - let errors = &errors; + let (errors, accept) = (&errors, &accept); async move { match fetch(address).await { Ok(bytes) => { crate::record::verify(&address, &bytes).map_err(ReadError::Invalid)?; - Ok((index, Ok(bytes))) + let kept = accept(index, bytes).map_err(ReadError::Invalid)?; + Ok((index, Ok(kept))) } Err(error) if !retryable(&error) => Err(ReadError::Fetch(error)), Err(error) => { @@ -654,7 +562,7 @@ mod tests { &content[start.min(content.len())..start.saturating_add(length).min(content.len())]; assert_eq!(actual.as_ref(), expected); let end = (start as u64).saturating_add(length as u64); - let required = FileIndex::new(&map) + let required = DataMapIndex::new(&map) .unwrap() .chunks_overlapping(start as u64..end); assert_eq!(seen.lock().unwrap().len(), required.len()); @@ -766,77 +674,30 @@ mod tests { assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1); } - #[test] - fn malformed_range_maps_are_rejected() { - let (_, map, _) = fixture(); - let mut infos = map.infos().to_vec(); - infos[1].index = 0; - assert!(FileIndex::new(&DataMap::new(infos)).is_err()); - let mut infos = map.infos().to_vec(); - infos[0].src_size = 0; - assert!(FileIndex::new(&DataMap::new(infos)).is_err()); - let mut infos = map.infos().to_vec(); - infos.iter_mut().for_each(|info| info.src_size = usize::MAX); - assert!(FileIndex::new(&DataMap::new(infos)).is_err()); - let infos = map.infos()[..MIN_FILE_CHUNKS - 1].to_vec(); - assert!(FileIndex::new(&DataMap::new(infos)).is_err()); - } - - #[test] - fn indexed_ranges_cross_four_gib_without_pointer_width_arithmetic() { - let size = self_encryption::MAX_CHUNK_SIZE; - let infos = (0..1030) - .map(|index| self_encryption::ChunkInfo { - index, - src_size: size, - src_hash: XorName([1; 32]), - dst_hash: XorName([2; 32]), - }) - .collect(); - let index = FileIndex::new(&DataMap::new(infos)).unwrap(); - assert_eq!(index.size(), 1030 * size as u64); - let start = (1u64 << 32) - 4; - let chunks = index.chunks_overlapping(start..start + 8); - assert_eq!(chunks.start as u64, start / size as u64); - let boundary = 1026 * size as u64; - assert!(boundary > u32::MAX as u64); - assert_eq!( - index.chunks_overlapping(boundary - 1..boundary + 1), - 1025..1027 - ); - assert_eq!(index.chunk_span(1026), boundary..boundary + size as u64); - assert_eq!( - index.chunks_overlapping(index.size() - 1..u64::MAX), - 1029..1030 - ); - assert!(index.chunks_overlapping(u64::MAX - 10..u64::MAX).is_empty()); - assert!(index.chunks_overlapping(5..5).is_empty()); - } - - #[test] - fn maps_with_larger_chunks_than_this_build_produces_are_indexed() { - let size = 2 * self_encryption::MAX_CHUNK_SIZE; - let infos: Vec<_> = (0..MIN_FILE_CHUNKS) - .map(|index| self_encryption::ChunkInfo { - index, - src_size: size, - src_hash: XorName([index as u8; 32]), - dst_hash: XorName([0xff - index as u8; 32]), - }) - .collect(); - let index = FileIndex::new(&DataMap::new(infos.clone())).unwrap(); - assert_eq!(index.size(), (MIN_FILE_CHUNKS * size) as u64); - assert_eq!(index.chunk_infos().collect::>(), infos); - assert_eq!( - index.dst_hashes, - infos.iter().map(|info| info.dst_hash).collect::>() - ); - assert_eq!(index.address(2), infos[2].dst_hash.0); - // Chunk sizes are not capped; any positive size is indexed. - let mut infos = infos; - infos[1].src_size = 64 * size; - let index = FileIndex::new(&DataMap::new(infos)).unwrap(); - assert_eq!(index.size(), (MIN_FILE_CHUNKS + 63) as u64 * size as u64); + #[tokio::test] + async fn malformed_maps_fail_range_reads() { + let (_, map, records) = fixture(); + let fetch = |address| { + let bytes = records[&address].clone(); + async move { Ok::<_, String>(bytes) } + }; + let mut repeated = map.infos().to_vec(); + repeated[1].index = 0; + let too_few = map.infos()[..MIN_FILE_CHUNKS - 1].to_vec(); + for infos in [repeated, too_few] { + let error = read_range( + &DataMap::new(infos), + 0, + 1, + &fetch, + &|| 1, + &|_| async {}, + |_| true, + ) + .await + .unwrap_err(); + assert!(matches!(error, ReadError::Invalid(_))); + } } #[tokio::test] @@ -858,7 +719,7 @@ mod tests { #[tokio::test] async fn a_missing_chunk_is_retried_on_its_own_schedule() { let (content, map, records) = fixture(); - let index = FileIndex::new(&map).unwrap(); + let index = DataMapIndex::new(&map).unwrap(); let attempts = std::cell::Cell::new(0); let sleeps = std::cell::RefCell::new(Vec::new()); let fetch_after = |available_from: usize| { @@ -883,14 +744,25 @@ mod tests { // The first pass and three deferred retries. const ATTEMPTS: usize = 4; // One chunk on its own, as each pipeTo fetch is. - let chunks = read_chunks(&index, 1..2, &fetch_after(ATTEMPTS), &|| 1, &sleep, |_| { - true - }) + let chunks = std::cell::RefCell::new(Vec::new()); + let deliver = |chunk, plaintext| { + chunks.borrow_mut().push((chunk, plaintext)); + Ok(()) + }; + read_chunks( + &index, + 1..2, + &fetch_after(ATTEMPTS), + &|| 1, + &sleep, + |_| true, + deliver, + ) .await .unwrap(); - let span = index.chunk_span(1); + let span = index.chunk_range(1); assert_eq!( - chunks, + *chunks.borrow(), vec![(1, content.slice(span.start as usize..span.end as usize))] ); assert_eq!(*sleeps.borrow(), vec![15, 45]); @@ -903,6 +775,7 @@ mod tests { &|| 1, &sleep, |_| true, + deliver, ) .await .unwrap_err(); @@ -917,6 +790,7 @@ mod tests { &|| 1, &sleep, |_| false, + deliver, ) .await .unwrap_err(); @@ -927,15 +801,15 @@ mod tests { #[tokio::test] async fn chunks_share_one_retry_pass() { let (content, map, records) = fixture(); - let index = FileIndex::new(&map).unwrap(); + let index = DataMapIndex::new(&map).unwrap(); let attempts = std::sync::Mutex::new(HashMap::<[u8; 32], usize>::new()); let in_flight = std::cell::Cell::new(0); let most_in_flight = std::cell::Cell::new(0); let sleeps = std::sync::Mutex::new(Vec::new()); // The first and last records are missing until their third attempt. let missing = [ - index.dst_hashes[0].0, - index.dst_hashes[MIN_FILE_CHUNKS - 1].0, + index.infos()[0].dst_hash.0, + index.infos()[MIN_FILE_CHUNKS - 1].dst_hash.0, ]; let fetch = |address: [u8; 32]| { let attempt = { @@ -958,9 +832,10 @@ mod tests { } } }; - let received = read_chunks( + let received = std::cell::RefCell::new(Vec::new()); + read_chunks( &index, - 0..index.chunk_count(), + 0..index.infos().len(), &fetch, &|| MIN_FILE_CHUNKS, &|delay| { @@ -968,18 +843,24 @@ mod tests { async {} }, |_| true, + |chunk, plaintext| { + received.borrow_mut().push((chunk, plaintext)); + Ok(()) + }, ) .await .unwrap(); assert_eq!(most_in_flight.get(), MIN_FILE_CHUNKS); // Both missing records wait out the same 15-second round. assert_eq!(*sleeps.lock().unwrap(), vec![15]); + let mut received = received.into_inner(); + received.sort_by_key(|(chunk, _)| *chunk); assert_eq!( received.iter().map(|(chunk, _)| *chunk).collect::>(), (0..MIN_FILE_CHUNKS).collect::>() ); for (chunk, plaintext) in received { - let span = index.chunk_span(chunk); + let span = index.chunk_range(chunk); assert_eq!( plaintext, content.slice(span.start as usize..span.end as usize) @@ -1007,7 +888,7 @@ mod tests { ) .await .unwrap_err(); - assert!(error.to_string().contains("chunk size differs")); + assert!(error.to_string().contains("the DataMap declares")); } #[tokio::test] diff --git a/ant-core/src/client_engine/read_ahead.rs b/ant-core/src/client_engine/read_ahead.rs index c72b0fb9..7f9631f6 100644 --- a/ant-core/src/client_engine/read_ahead.rs +++ b/ant-core/src/client_engine/read_ahead.rs @@ -1,21 +1,24 @@ //! Read-ahead planning for random-access file readers. Transport-neutral: the //! adapter fetches the records a plan names and releases the ones it marks. //! DataMap sizes are untrusted, so plans bound records, never declared bytes. -use super::files::FileIndex; +use self_encryption::DataMapIndex; #[cfg(test)] use self_encryption::{ChunkInfo, DataMap}; use std::ops::Range; #[cfg(test)] use xor_name::XorName; -impl FileIndex { - /// The read-ahead window: records from the one holding `start` that overlap - /// `length` plaintext bytes, at most `max_records`. Undersized records - /// shorten the window rather than lengthen it. - pub(crate) fn window(&self, start: u64, length: usize, max_records: usize) -> Range { - let records = self.chunks_overlapping(start..start.saturating_add(length as u64)); - records.start..records.end.min(records.start.saturating_add(max_records)) - } +/// The read-ahead window of `index`: records from the one holding `start` that +/// overlap `length` plaintext bytes, at most `max_records`. Undersized records +/// shorten the window rather than lengthen it. +pub(crate) fn read_ahead_window( + index: &DataMapIndex, + start: u64, + length: usize, + max_records: usize, +) -> Range { + let records = index.chunks_overlapping(start..start.saturating_add(length as u64)); + records.start..records.end.min(records.start.saturating_add(max_records)) } /// Held records to release so that at most `budget` stay held, never one that is @@ -44,7 +47,7 @@ pub(crate) fn releasable( mod tests { use super::*; - fn layout(sizes: &[usize]) -> FileIndex { + fn layout(sizes: &[usize]) -> DataMapIndex { let infos = sizes .iter() .enumerate() @@ -55,19 +58,19 @@ mod tests { src_size, }) .collect(); - FileIndex::new(&DataMap::new(infos)).unwrap() + DataMapIndex::new(&DataMap::new(infos)).unwrap() } #[test] fn undersized_records_shorten_the_window_instead_of_lengthening_it() { let small = layout(&[10; 40]); // Every record overlaps the window's bytes; only the record cap is taken. - assert_eq!(small.window(0, 1_000, 10), 0..10); - assert_eq!(small.window(15, 25, 10), 1..4); - assert!(small.window(400, 10, 10).is_empty()); + assert_eq!(read_ahead_window(&small, 0, 1_000, 10), 0..10); + assert_eq!(read_ahead_window(&small, 15, 25, 10), 1..4); + assert!(read_ahead_window(&small, 400, 10, 10).is_empty()); // Positions past 4 GiB address records exactly on every platform. let large = layout(&[1 << 30; 6]); - assert_eq!(large.window((1 << 32) + 1, 1, 10), 4..5); + assert_eq!(read_ahead_window(&large, (1 << 32) + 1, 1, 10), 4..5); } #[test] diff --git a/ant-core/src/data/client/data.rs b/ant-core/src/data/client/data.rs index f9f3d962..acd3a655 100644 --- a/ant-core/src/data/client/data.rs +++ b/ant-core/src/data/client/data.rs @@ -432,7 +432,7 @@ impl Client { #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] pub(crate) async fn data_download_indexed_range( &self, - index: &crate::client_engine::files::FileIndex, + index: &self_encryption::DataMapIndex, start: u64, length: usize, held: impl Fn([u8; 32]) -> H, @@ -461,27 +461,55 @@ impl Client { .map_err(map_read_error) } + /// Fetch and decrypt every chunk of a file as a complete download does: + /// within the adaptive fetch cap and `concurrency`, missing records retried + /// together in deferred rounds. Each chunk goes to `deliver` as it arrives, + /// in any order. + #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] + pub(crate) async fn data_download_indexed_chunks( + &self, + index: &self_encryption::DataMapIndex, + concurrency: usize, + deliver: impl Fn(usize, Bytes) -> std::result::Result<(), String>, + ) -> Result<()> { + crate::client_engine::files::read_chunks( + index, + 0..index.infos().len(), + &|address| self.fetch_data_record(address), + &|| self.controller().fetch.current().min(concurrency), + &crate::runtime::sleep, + retry_data_fetch, + deliver, + ) + .await + .map_err(map_read_error) + } + /// Fetch and decrypt one chunk, retrying it on its own deferred schedule. #[cfg(all(feature = "browser-wasm", target_arch = "wasm32"))] pub(crate) async fn data_download_indexed_chunk( &self, - index: &crate::client_engine::files::FileIndex, + index: &self_encryption::DataMapIndex, chunk: usize, ) -> Result { - let mut chunks = crate::client_engine::files::read_chunks( + let plaintext = std::cell::Cell::new(None); + crate::client_engine::files::read_chunks( index, chunk..chunk + 1, &|address| self.fetch_data_record(address), &|| 1, &crate::runtime::sleep, retry_data_fetch, + |_, bytes| { + plaintext.set(Some(bytes)); + Ok(()) + }, ) .await .map_err(map_read_error)?; - let (_, plaintext) = chunks - .pop() - .expect("a completed pass delivers its one chunk"); - Ok(plaintext) + Ok(plaintext + .take() + .expect("a completed pass delivers its one chunk")) } async fn fetch_data_record(&self, address: [u8; 32]) -> Result { diff --git a/ant-core/wasm-tests/large-download.test.mjs b/ant-core/wasm-tests/large-download.test.mjs index ecbcf3c2..05f502d9 100644 --- a/ant-core/wasm-tests/large-download.test.mjs +++ b/ant-core/wasm-tests/large-download.test.mjs @@ -202,6 +202,7 @@ test("invalid pipeTo calls reject without touching the destination", async () => [{ start: -1 }, /safe integer/], [{ onProgress: "no" }, /onProgress must be a function/], [{ signal: {} }, /AbortSignal/], + [{ signal: new EventTarget() }, /AbortSignal/], [{}, /closed/, true], ]) { if (close) reader.close(); diff --git a/docs/adr/ADR-0004-direct-browser-read-client.md b/docs/adr/ADR-0004-direct-browser-read-client.md index 7d57e3d5..e77e4628 100644 --- a/docs/adr/ADR-0004-direct-browser-read-client.md +++ b/docs/adr/ADR-0004-direct-browser-read-client.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-10-01 +- **Last amended:** 2026-10-13 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -668,29 +668,38 @@ Manifest validation no longer rejects files over 1 GB; it caps a file's chunk list at 262,144 entries (about 1 TiB at the native chunk size) to bound the sorting and normalisation of an untrusted entry. -A reader retains a validated root-map index and uses binary searches to identify -overlapping chunks. Decryption delegates to `self_encryption::decrypt_chunk` -with the original indices and neighbouring source hashes. This avoids the -dependency's pointer-sized whole-file range arithmetic without changing its KDF, -DataMap representation or stored ciphertext. Encrypted records and reconstructed -plaintext chunks are verified against their respective hashes and sizes. The -reader validates its index when it opens, so a DataMap whose range reads reject, -such as one with non-contiguous indices, fails to open rather than opening with -read-ahead off as described under streaming reads. Read-ahead plans its windows -over the same 64-bit index, so it follows reads past 4 GiB on wasm32. - -Complete downloads and `BrowserFileReader.pipeTo` share one transfer: chunks are -fetched concurrently within the client's adaptive fetch cap (and a complete -download's `concurrency` argument), each retried on the deferred schedule on its -own, and handed on in file order. Fetching continues while the consumer works, -holding at most 32 MiB of plaintext; a record waiting to retry keeps its place -under the cap. A complete download copies each chunk into one JavaScript -`Uint8Array` allocated up front, hashes it in order and keeps the -`Downloaded chunk n/total` progress. Its optional `maxMemoryBytes` limits that -allocation; allocation failure rejects before file-content transfer and directs -the caller to streaming. Automatic free-memory detection and implicit disk spill -are not portable browser capabilities; applications own the memory budget and -the choice of a storage destination. +A reader retains a `self_encryption::DataMapIndex` of the root map (added in +self_encryption 0.36.2). It finds overlapping chunks with 64-bit positions and +binary searches, and decrypts single chunks with their original indices and +neighbouring source hashes, so self_encryption keeps sole ownership of its KDF, +DataMap representation and stored ciphertext. Encrypted records are verified +against their addresses, and each decrypted chunk against the size and source +hash the map declares; decompression stops at the declared size. The same +release moves the dependency's `DecryptionStream` range arithmetic onto the +index, which removes its pointer-sized whole-file sums. The reader indexes the +map when it opens, so a DataMap with non-contiguous indices fails to open rather +than opening with read-ahead off as described under streaming reads. Read-ahead +plans its windows over the same 64-bit index, so it follows reads past 4 GiB on +wasm32. + +A complete download fetches chunks as the native one does: within the client's +adaptive fetch cap and its `concurrency` argument, with missing records retried +together in deferred rounds, so retry waits do not add up across records. Each +chunk is decrypted as it arrives and copied straight into its place in one +JavaScript `Uint8Array` allocated up front, so WASM memory holds only the records +in flight, and `Downloaded chunk n/total` counts chunks as they land. The file is +hashed afterwards, copied back in 4 MiB pieces. The optional `maxMemoryBytes` +limits the allocation; allocation failure rejects before file-content transfer +and directs the caller to streaming. Automatic free-memory detection and +implicit disk spill are not portable browser capabilities; applications own the +memory budget and the choice of a storage destination. + +`BrowserFileReader.pipeTo` must write in file order. It fetches chunks +concurrently within the adaptive fetch cap, each retried on the deferred schedule +on its own, and hands them on in file order. Fetching continues while the +destination writes, holding at most 32 MiB of plaintext; a record waiting to +retry keeps its place under the cap, so a missing record can hold a stream back +for the 60 seconds of its retry schedule. `pipeTo(writable, { start?, end?, signal?, onProgress? })` writes the whole file or a half-open range to a caller-owned `WritableStream`, at most 4 MiB per write, @@ -709,9 +718,12 @@ Removing only the 1 GB check was rejected because wasm32 offsets would still truncate around 4 GiB. Whole-file WASM buffering was rejected because it couples supported file size to linear-memory capacity. Disk-only output was rejected because small files and consumers needing bytes can use JavaScript memory. -Bounding decompression of crafted records was left to a bounded -`decrypt_chunk` in self_encryption: the risk predates this change and also -affects native downloads. +Routing complete downloads through the in-order stream was rejected: copying +into a preallocated array needs no order, and the stream's 32 MiB bound and +per-record retries would cap their concurrency and add up retry waits. +Decompression of crafted records is bounded for browser reads by +`DataMapIndex::decrypt_chunk`; native complete downloads still decrypt through +`self_encryption::decrypt`, which does not bound it. This changes the public Rust browser descriptor's `size` from `usize` to `u64` and Rust signatures of affected WASM exports. `readRange` now rejects fractional, From e5659c517b19fbcc0d0a4583910131e150972201 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:11:19 +0200 Subject: [PATCH 14/15] build: pin self_encryption#447 at its stream-iteration fix Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6605d77a..63454857 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5810,7 +5810,7 @@ dependencies = [ [[package]] name = "self_encryption" version = "0.36.2" -source = "git+https://github.com/WithAutonomi/self_encryption?rev=e8aa39ee1337c11f52628356d97fa86ac0d1007d#e8aa39ee1337c11f52628356d97fa86ac0d1007d" +source = "git+https://github.com/WithAutonomi/self_encryption?rev=869b2f94d5b3b2c5a488e74c477939b5544ffc22#869b2f94d5b3b2c5a488e74c477939b5544ffc22" dependencies = [ "bincode", "blake3", diff --git a/Cargo.toml b/Cargo.toml index e09c858a..bf8e8464 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,4 +5,4 @@ resolver = "2" # Temporary: DataMapIndex from WithAutonomi/self_encryption#447, until 0.36.2 is published. [patch.crates-io] -self_encryption = { git = "https://github.com/WithAutonomi/self_encryption", rev = "e8aa39ee1337c11f52628356d97fa86ac0d1007d" } +self_encryption = { git = "https://github.com/WithAutonomi/self_encryption", rev = "869b2f94d5b3b2c5a488e74c477939b5544ffc22" } From ea5e68e23cabd4b8c7f2d426d72622fa3a70011a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 14 Oct 2026 19:07:32 +0100 Subject: [PATCH 15/15] chore(deps): use published self_encryption 0.36.2 --- Cargo.lock | 3 ++- Cargo.toml | 5 ----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63454857..b2a1275c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5810,7 +5810,8 @@ dependencies = [ [[package]] name = "self_encryption" version = "0.36.2" -source = "git+https://github.com/WithAutonomi/self_encryption?rev=869b2f94d5b3b2c5a488e74c477939b5544ffc22#869b2f94d5b3b2c5a488e74c477939b5544ffc22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6c9e93b470f0fb747a609cfd7c4a23ea39bfbc1813de3d00fa429a9dc99e38" dependencies = [ "bincode", "blake3", diff --git a/Cargo.toml b/Cargo.toml index bf8e8464..f3978b22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,3 @@ [workspace] members = ["ant-core", "ant-cli"] resolver = "2" - - -# Temporary: DataMapIndex from WithAutonomi/self_encryption#447, until 0.36.2 is published. -[patch.crates-io] -self_encryption = { git = "https://github.com/WithAutonomi/self_encryption", rev = "869b2f94d5b3b2c5a488e74c477939b5544ffc22" }