From 2e548067ddff3c3ec6f1689bdb1357e5a719861f Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 18:06:28 +0100 Subject: [PATCH 01/38] feat: add CAS file composition APIs for range writes Three new APIs to support range-aware writes: 1. Client::get_file_chunk_hashes(file_id) -> ChunkHashList 2. FileUploadSession::register_composed_file(file_info) 3. upload_ranges(config, cas_client, original_hash, original_size, dirty_ranges, dirty_source, total_size) See https://gist.github.com/XciD/198f7e6cfd68f4a0f19c0c4a37c14b61 for a visual explanation. Supporting changes: - SingleFileCleaner::finish() now returns ChunkHashList (eliminates CAS round-trip) - FileUploadSession::file_info_list() for checkpoint-based flow (single session) - XorbObject::chunk_hash_sizes() helper - ChunkHashList type alias in xet_core_structures::merklehash - get_file_chunk_hashes implemented for MemoryClient/LocalClient --- xet_client/src/cas_client/interface.rs | 6 +- xet_client/src/cas_client/remote_client.rs | 41 +- .../src/cas_client/simulation/local_client.rs | 21 +- .../simulation/local_server/server.rs | 7 + .../local_server/simulation_control_client.rs | 6 +- .../cas_client/simulation/memory_client.rs | 42 +- .../simulation/simulation_client.rs | 7 + .../simulation/simulation_server.rs | 7 + xet_core_structures/src/merklehash/mod.rs | 3 + .../src/xorb_object/xorb_object_format.rs | 17 +- .../src/deduplication/file_deduplication.rs | 13 +- xet_data/src/processing/bin/example.rs | 2 +- xet_data/src/processing/data_client.rs | 6 +- xet_data/src/processing/file_cleaner.rs | 7 +- .../src/processing/file_download_session.rs | 2 +- .../src/processing/file_upload_session.rs | 19 +- xet_data/src/processing/mod.rs | 3 + xet_data/src/processing/range_upload.rs | 938 ++++++++++++++++++ xet_data/tests/test_full_file_download.rs | 2 +- xet_data/tests/test_session_resume.rs | 4 +- xet_pkg/src/xet_session/upload_commit.rs | 4 +- 21 files changed, 1131 insertions(+), 26 deletions(-) create mode 100644 xet_data/src/processing/range_upload.rs diff --git a/xet_client/src/cas_client/interface.rs b/xet_client/src/cas_client/interface.rs index a355d0973..6b6914025 100644 --- a/xet_client/src/cas_client/interface.rs +++ b/xet_client/src/cas_client/interface.rs @@ -1,5 +1,5 @@ use bytes::Bytes; -use xet_core_structures::merklehash::MerkleHash; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; use xet_core_structures::xorb_object::SerializedXorbObject; @@ -70,4 +70,8 @@ pub trait Client: Send + Sync { progress_callback: Option, upload_permit: ConnectionPermit, ) -> Result; + + /// Retrieve the chunk hashes and sizes for a file stored in CAS. + /// Returns a list of (chunk_hash, chunk_uncompressed_size) pairs. + async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result; } diff --git a/xet_client/src/cas_client/remote_client.rs b/xet_client/src/cas_client/remote_client.rs index 88c7a7e09..00352b256 100644 --- a/xet_client/src/cas_client/remote_client.rs +++ b/xet_client/src/cas_client/remote_client.rs @@ -7,8 +7,9 @@ use http::HeaderValue; use http::header::{CONTENT_LENGTH, HeaderMap, RANGE}; use reqwest::{Body, Response, StatusCode, Url}; use reqwest_middleware::ClientWithMiddleware; +use serde::Deserialize; use tracing::{event, info, instrument}; -use xet_core_structures::merklehash::MerkleHash; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; use xet_core_structures::metadata_shard::file_structs::{FileDataSequenceEntry, FileDataSequenceHeader, MDBFileInfo}; use xet_core_structures::xorb_object::SerializedXorbObject; use xet_runtime::core::xet_config; @@ -732,6 +733,44 @@ impl Client for RemoteClient { Ok(n_upload_bytes) } + + #[instrument(skip_all, name = "RemoteClient::get_file_chunk_hashes", fields(file.hash = file_id.hex()))] + async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + let url = Url::parse(&format!("{}/v2/file-chunk-hashes/{}", self.endpoint, file_id.hex()))?; + + let api_tag = "cas::get_file_chunk_hashes"; + let client = self.authenticated_http_client.clone(); + + let response: FileChunkHashesResponse = RetryWrapper::new(api_tag) + .run_and_extract_json(move || client.get(url.clone()).with_extension(Api(api_tag)).send()) + .await?; + + let chunks = response + .chunks + .into_iter() + .map(|entry| { + let hash = MerkleHash::from_hex(&entry.hash) + .map_err(|e| CasClientError::Other(format!("invalid chunk hash: {e}")))?; + Ok((hash, entry.size)) + }) + .collect::>>()?; + + Ok(chunks) + } +} + +/// Response from `GET /v2/file-chunk-hashes/{file_id}`. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileChunkHashesResponse { + chunks: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChunkHashEntry { + hash: String, + size: u64, } #[cfg(test)] diff --git a/xet_client/src/cas_client/simulation/local_client.rs b/xet_client/src/cas_client/simulation/local_client.rs index 1686c2331..c1ad88e2b 100644 --- a/xet_client/src/cas_client/simulation/local_client.rs +++ b/xet_client/src/cas_client/simulation/local_client.rs @@ -15,7 +15,7 @@ use rand::Rng; use tempfile::TempDir; use tokio::time::{Duration, Instant}; use tracing::{error, info, warn}; -use xet_core_structures::merklehash::MerkleHash; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; use xet_core_structures::metadata_shard::shard_file_reconstructor::FileReconstructor; use xet_core_structures::metadata_shard::shard_in_memory::MDBInMemoryShard; @@ -1033,6 +1033,25 @@ impl Client for LocalClient { // Should not reach here, but return error if we do. Err(CasClientError::PresignedUrlExpirationError) } + + async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + self.apply_api_delay().await; + + let Some((file_info, _)) = self.shard_manager.get_file_reconstruction_info(file_id).await? else { + return Err(CasClientError::FileNotFound(*file_id)); + }; + + let mut result = Vec::new(); + for segment in &file_info.segments { + let xorb_obj = self.xorb_footer(&segment.xorb_hash).await?; + let pairs = xorb_obj + .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) + .map_err(|err| CasClientError::Other(format!("chunk_hash_sizes error: {err}")))?; + result.extend(pairs); + } + + Ok(result) + } } fn map_heed_db_error(e: heed::Error) -> CasClientError { diff --git a/xet_client/src/cas_client/simulation/local_server/server.rs b/xet_client/src/cas_client/simulation/local_server/server.rs index 9cda702b1..21ee816e5 100644 --- a/xet_client/src/cas_client/simulation/local_server/server.rs +++ b/xet_client/src/cas_client/simulation/local_server/server.rs @@ -484,6 +484,13 @@ impl Client for LocalTestServer { .upload_xorb(prefix, serialized_xorb_object, progress_callback, upload_permit) .await } + + async fn get_file_chunk_hashes( + &self, + file_id: &xet_core_structures::merklehash::MerkleHash, + ) -> Result { + self.remote_client.get_file_chunk_hashes(file_id).await + } } #[cfg(test)] diff --git a/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs b/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs index 0368b12be..7b635ab48 100644 --- a/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs +++ b/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use bytes::Bytes; use http::header::HeaderMap; -use xet_core_structures::merklehash::MerkleHash; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; use xet_core_structures::xorb_object::XorbObject; use super::simulation_types::{ @@ -152,6 +152,10 @@ impl Client for SimulationControlClient { .upload_xorb(prefix, serialized_xorb_object, progress_callback, upload_permit) .await } + + async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + self.remote_client.get_file_chunk_hashes(file_id).await + } } #[async_trait] diff --git a/xet_client/src/cas_client/simulation/memory_client.rs b/xet_client/src/cas_client/simulation/memory_client.rs index 53ec028c9..c6d43a11a 100644 --- a/xet_client/src/cas_client/simulation/memory_client.rs +++ b/xet_client/src/cas_client/simulation/memory_client.rs @@ -11,7 +11,7 @@ use tokio::sync::RwLock; use tokio::time::{Duration, Instant}; use tracing::{error, info}; use xet_core_structures::MerkleHashMap; -use xet_core_structures::merklehash::MerkleHash; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; use xet_core_structures::metadata_shard::shard_in_memory::MDBInMemoryShard; use xet_core_structures::metadata_shard::streaming_shard::MDBMinimalShard; @@ -897,6 +897,46 @@ impl Client for MemoryClient { } Ok((Bytes::from(all_decompressed), all_chunk_indices)) } + + async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + self.apply_api_delay().await; + + let file_info = { + let shard = self.shard.read().await; + shard + .get_file_reconstruction_info(file_id) + .ok_or(CasClientError::FileNotFound(*file_id))? + }; + + let xorbs = self.xorbs.read().await; + let mut result = Vec::new(); + + for segment in &file_info.segments { + let storage = xorbs + .get(&segment.xorb_hash) + .ok_or(CasClientError::XORBNotFound(segment.xorb_hash))?; + + let xorb_obj = match storage { + XorbStorage::Materialized(entry) => &entry.xorb_object, + XorbStorage::Random(xorb) => { + // RandomXorb doesn't store a reference we can borrow, so build inline + let obj = xorb.get_xorb_object(); + let pairs = obj + .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) + .map_err(|err| CasClientError::Other(format!("chunk_hash_sizes error: {err}")))?; + result.extend(pairs); + continue; + }, + }; + + let pairs = xorb_obj + .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) + .map_err(|err| CasClientError::Other(format!("chunk_hash_sizes error: {err}")))?; + result.extend(pairs); + } + + Ok(result) + } } fn generate_fetch_url(hash: &MerkleHash, byte_range: &FileRange, timestamp: Instant) -> String { diff --git a/xet_client/src/cas_client/simulation/simulation_client.rs b/xet_client/src/cas_client/simulation/simulation_client.rs index 8836c06ab..83c44cc42 100644 --- a/xet_client/src/cas_client/simulation/simulation_client.rs +++ b/xet_client/src/cas_client/simulation/simulation_client.rs @@ -186,4 +186,11 @@ impl Client for RemoteSimulationClient { .upload_xorb(prefix, serialized_xorb_object, progress_callback, upload_permit) .await } + + async fn get_file_chunk_hashes( + &self, + file_id: &xet_core_structures::merklehash::MerkleHash, + ) -> Result { + self.inner.get_file_chunk_hashes(file_id).await + } } diff --git a/xet_client/src/cas_client/simulation/simulation_server.rs b/xet_client/src/cas_client/simulation/simulation_server.rs index f1d58f9cf..614008941 100644 --- a/xet_client/src/cas_client/simulation/simulation_server.rs +++ b/xet_client/src/cas_client/simulation/simulation_server.rs @@ -500,6 +500,13 @@ impl Client for LocalTestServer { .upload_xorb(prefix, serialized_xorb_object, progress_callback, upload_permit) .await } + + async fn get_file_chunk_hashes( + &self, + file_id: &xet_core_structures::merklehash::MerkleHash, + ) -> Result { + self.client.get_file_chunk_hashes(file_id).await + } } #[async_trait] diff --git a/xet_core_structures/src/merklehash/mod.rs b/xet_core_structures/src/merklehash/mod.rs index 76769b2e9..113d2cf26 100644 --- a/xet_core_structures/src/merklehash/mod.rs +++ b/xet_core_structures/src/merklehash/mod.rs @@ -48,6 +48,9 @@ pub mod data_hash; pub use data_hash::*; pub type MerkleHash = DataHash; +/// List of (chunk_hash, chunk_uncompressed_size) pairs for a file or xorb range. +pub type ChunkHashList = Vec<(MerkleHash, u64)>; + mod aggregated_hashes; pub mod passthrough_hasher; pub mod passthrough_hashmap; diff --git a/xet_core_structures/src/xorb_object/xorb_object_format.rs b/xet_core_structures/src/xorb_object/xorb_object_format.rs index 7e91468d9..e6c57cb89 100644 --- a/xet_core_structures/src/xorb_object/xorb_object_format.rs +++ b/xet_core_structures/src/xorb_object/xorb_object_format.rs @@ -15,7 +15,7 @@ use super::constants::{TARGET_CHUNK_SIZE, XORB_BLOCK_SIZE}; use super::error::{Validate, XorbObjectError}; use super::xorb_chunk_format::{deserialize_chunk, deserialize_chunk_header, serialize_chunk, write_chunk_header}; use super::{CompressionScheme, RawXorbData, XorbChunkHeader}; -use crate::merklehash::{DataHash, MerkleHash}; +use crate::merklehash::{ChunkHashList, DataHash, MerkleHash}; use crate::metadata_shard::chunk_verification::range_hash_from_chunks; use crate::serialization_utils::*; @@ -1239,6 +1239,21 @@ impl XorbObject { Ok(incl_end - before_start) } + /// Returns (chunk_hash, uncompressed_size) pairs for chunks in [start, end). + pub fn chunk_hash_sizes(&self, start: u32, end: u32) -> Result { + self.validate_xorb_object_info()?; + if end > self.info.num_chunks || start > end { + return Err(XorbObjectError::InvalidArguments); + } + (start..end) + .map(|i| { + let hash = self.info.chunk_hashes[i as usize]; + let size = self.uncompressed_chunk_length(i)? as u64; + Ok((hash, size)) + }) + .collect() + } + /// Helper method to verify that info object is complete fn validate_xorb_object_info(&self) -> Result<(), XorbObjectError> { if self.info.num_chunks == 0 { diff --git a/xet_data/src/deduplication/file_deduplication.rs b/xet_data/src/deduplication/file_deduplication.rs index 54c12cc13..cc865e3cf 100644 --- a/xet_data/src/deduplication/file_deduplication.rs +++ b/xet_data/src/deduplication/file_deduplication.rs @@ -2,7 +2,7 @@ use std::result::Result; use more_asserts::{debug_assert_le, debug_assert_lt}; use xet_core_structures::MerkleHashMap; -use xet_core_structures::merklehash::{MerkleHash, file_hash}; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash, file_hash}; use xet_core_structures::metadata_shard::file_structs::{ FileDataSequenceEntry, FileDataSequenceHeader, FileMetadataExt, FileVerificationEntry, MDBFileInfo, }; @@ -32,7 +32,7 @@ pub struct FileDeduper { new_data_hash_lookup: MerkleHashMap, /// The current chunk hashes for this file. - chunk_hashes: Vec<(MerkleHash, u64)>, + chunk_hashes: ChunkHashList, /// The current file data entries. file_info: Vec, @@ -373,8 +373,11 @@ impl FileDeduper) -> (MerkleHash, DataAggregator, DeduplicationMetrics) { + /// Returns (file hash, chunk_hashes, data aggregation, deduplication metrics) + pub fn finalize( + self, + metadata_ext: Option, + ) -> (MerkleHash, ChunkHashList, DataAggregator, DeduplicationMetrics) { let file_hash = file_hash(&self.chunk_hashes); let metadata = FileDataSequenceHeader::new(file_hash, self.file_info.len(), true, metadata_ext.is_some()); @@ -408,6 +411,6 @@ impl FileDeduper Resu debug_assert_eq!(size_read, size); - let (file_info, _) = handle.finish().await?; + let (file_info, _chunk_hashes, _metrics) = handle.finish().await?; translator.finalize().await?; diff --git a/xet_data/src/processing/data_client.rs b/xet_data/src/processing/data_client.rs index 279d77375..7aecd2c72 100644 --- a/xet_data/src/processing/data_client.rs +++ b/xet_data/src/processing/data_client.rs @@ -219,7 +219,8 @@ pub async fn clean_bytes( .start_clean(None, bytes.len() as u64, sha256_policy, tracking_id) .await; handle.add_data(&bytes).await?; - handle.finish().await + let (info, _chunk_hashes, metrics) = handle.finish().await?; + Ok((info, metrics)) } #[instrument(skip_all, name = "clean_file", fields(file.name = tracing::field::Empty, file.len = tracing::field::Empty))] @@ -252,7 +253,8 @@ pub async fn clean_file( handle.add_data(&buffer[0..bytes]).await?; } - handle.finish().await + let (info, _chunk_hashes, metrics) = handle.finish().await?; + Ok((info, metrics)) } /// Computes the xet hash for a single file without uploading. diff --git a/xet_data/src/processing/file_cleaner.rs b/xet_data/src/processing/file_cleaner.rs index b10447ff0..a1b6849d4 100644 --- a/xet_data/src/processing/file_cleaner.rs +++ b/xet_data/src/processing/file_cleaner.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use bytes::Bytes; use chrono::{DateTime, Utc}; use tracing::{Instrument, debug_span, info, instrument}; +use xet_core_structures::merklehash::ChunkHashList; use xet_core_structures::metadata_shard::Sha256; use xet_core_structures::metadata_shard::file_structs::FileMetadataExt; use xet_runtime::core::{XetRuntime, xet_config}; @@ -195,7 +196,7 @@ impl SingleFileCleaner { /// Return the representation of the file after clean as a pointer file instance. #[instrument(skip_all, name = "FileCleaner::finish", fields(file_name=self.file_name.as_ref().map(|s|s.to_string())))] - pub async fn finish(mut self) -> Result<(XetFileInfo, DeduplicationMetrics)> { + pub async fn finish(mut self) -> Result<(XetFileInfo, ChunkHashList, DeduplicationMetrics)> { // Chunk the rest of the data. if let Some(chunk) = self.chunker.finish() { let data = Arc::new([chunk]); @@ -210,7 +211,7 @@ impl SingleFileCleaner { }; let metadata_ext = sha256.map(FileMetadataExt::new); - let (file_hash, remaining_file_data, deduplication_metrics) = + let (file_hash, chunk_hashes, remaining_file_data, deduplication_metrics) = self.dedup_manager_fut.await?.finalize(metadata_ext); let file_info = XetFileInfo { @@ -247,6 +248,6 @@ impl SingleFileCleaner { end_processing_ts = Utc::now().to_rfc3339(), ); - Ok((file_info, deduplication_metrics)) + Ok((file_info, chunk_hashes, deduplication_metrics)) } } diff --git a/xet_data/src/processing/file_download_session.rs b/xet_data/src/processing/file_download_session.rs index fa7e25494..6203428fc 100644 --- a/xet_data/src/processing/file_download_session.rs +++ b/xet_data/src/processing/file_download_session.rs @@ -247,7 +247,7 @@ mod tests { .start_clean(Some("test".into()), data.len() as u64, Sha256Policy::Compute, Ulid::new()) .await; cleaner.add_data(data).await.unwrap(); - let (xfi, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); upload_session.finalize().await.unwrap(); xfi } diff --git a/xet_data/src/processing/file_upload_session.rs b/xet_data/src/processing/file_upload_session.rs index 38bab7170..c26fb9f2a 100644 --- a/xet_data/src/processing/file_upload_session.rs +++ b/xet_data/src/processing/file_upload_session.rs @@ -214,7 +214,7 @@ impl FileUploadSession { } // Finish and return the result. - let (xfi, metrics) = cleaner.finish().await?; + let (xfi, _chunk_hashes, metrics) = cleaner.finish().await?; // Record dedup information. let span = Span::current(); @@ -525,6 +525,19 @@ impl FileUploadSession { Ok(()) } + /// Register a pre-composed file reconstruction plan (MDBFileInfo) with this session. + /// Used for append-aware writes where the caller builds the reconstruction plan + /// from existing segments + newly uploaded segments. + pub async fn register_composed_file(self: &Arc, file_info: MDBFileInfo) -> Result<()> { + self.shard_interface.add_file_reconstruction_info(file_info).await + } + + /// Returns a list of all file reconstruction infos currently registered in this session. + /// Call after all cleaners have finished and after `checkpoint()` to ensure data is flushed. + pub async fn file_info_list(self: &Arc) -> Result> { + self.shard_interface.session_file_info_list().await + } + pub async fn finalize(self: Arc) -> Result { Ok(self.finalize_impl(false).await?.0) } @@ -580,7 +593,7 @@ mod tests { // Read blocks from the source file and hand them to the cleaning handle cleaner.add_data(&read_data[..]).await.unwrap(); - let (xet_file_info, _metrics) = cleaner.finish().await.unwrap(); + let (xet_file_info, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); upload_session.finalize().await.unwrap(); pf_out @@ -664,7 +677,7 @@ mod tests { .start_clean(Some("test".into()), data.len() as u64, Sha256Policy::Skip, Ulid::new()) .await; cleaner.add_data(data).await.unwrap(); - cleaner.finish().await.unwrap(); + let _ = cleaner.finish().await.unwrap(); // Verify that the shard has no metadata_ext (no SHA-256). let (_metrics, file_infos) = upload_session.finalize_with_file_info().await.unwrap(); diff --git a/xet_data/src/processing/mod.rs b/xet_data/src/processing/mod.rs index daf84170a..256415785 100644 --- a/xet_data/src/processing/mod.rs +++ b/xet_data/src/processing/mod.rs @@ -7,6 +7,7 @@ mod file_download_session; mod file_upload_session; pub mod migration_tool; mod prometheus_metrics; +pub mod range_upload; mod remote_client_interface; mod sha256; mod shard_interface; @@ -16,6 +17,8 @@ mod xet_file; pub use file_cleaner::{Sha256Policy, SingleFileCleaner}; pub use file_download_session::FileDownloadSession; pub use file_upload_session::FileUploadSession; +pub use range_upload::upload_ranges; +pub use xet_core_structures::merklehash::ChunkHashList; pub use xet_file::XetFileInfo; pub use crate::deduplication::RawXorbData; diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs new file mode 100644 index 000000000..8b561a79d --- /dev/null +++ b/xet_data/src/processing/range_upload.rs @@ -0,0 +1,938 @@ +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::sync::Arc; + +use tracing::{debug, info}; +use ulid::Ulid; +use xet_client::cas_client::Client; +use xet_client::cas_types::FileRange; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash, file_hash}; +use xet_core_structures::metadata_shard::chunk_verification::range_hash_from_chunks; +use xet_core_structures::metadata_shard::file_structs::{ + FileDataSequenceEntry, FileDataSequenceHeader, FileVerificationEntry, MDBFileInfo, +}; + +use super::XetFileInfo; +use super::configurations::TranslatorConfig; +use super::errors::{DataProcessingError, Result}; +use super::file_cleaner::Sha256Policy; +use super::file_upload_session::FileUploadSession; +use crate::file_reconstruction::FileReconstructor; + +/// Trait alias for a seekable byte source (e.g. `std::fs::File`). +pub trait ReadSeek: Read + Seek + Send {} +impl ReadSeek for T {} + +/// Size of blocks read from the dirty source and fed to the cleaner. +const STREAM_BLOCK_SIZE: usize = 4 * 1024 * 1024; // 4 MB + +/// Upload modified ranges of an existing file, composing the result with +/// the original file's CAS segments. Only the dirty regions (plus CDC boundary +/// chunks) are re-uploaded; stable regions between and around dirty ranges are +/// reused from the original file's reconstruction plan. +/// +/// # Arguments +/// +/// * `config` - Translator configuration for creating upload sessions. +/// * `cas_client` - CAS client for fetching original file metadata and downloading boundary chunks. +/// * `original_hash` - Merkle hash of the original file in CAS. +/// * `original_size` - Size of the original file in bytes. +/// * `dirty_ranges` - Sorted, non-overlapping `(start, end)` byte ranges that were modified. +/// * `dirty_source` - Seekable reader for the dirty bytes (e.g. a staging file). +/// * `total_size` - Total size of the modified file (may be larger than `original_size` for appends). +pub async fn upload_ranges( + config: Arc, + cas_client: Arc, + original_hash: MerkleHash, + original_size: u64, + dirty_ranges: &[(u64, u64)], + dirty_source: &mut dyn ReadSeek, + total_size: u64, +) -> Result { + // Precondition: dirty_ranges must be sorted, non-overlapping, and non-empty intervals. + debug_assert!( + dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0), + "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" + ); + debug_assert!( + dirty_ranges.iter().all(|&(s, e)| s < e), + "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" + ); + + if dirty_ranges.is_empty() && total_size == original_size { + return Ok(XetFileInfo::new(original_hash.hex(), original_size)); + } + + // 1. Fetch chunk hashes and reconstruction info in parallel. + let (original_chunks, recon_result) = tokio::try_join!( + cas_client.get_file_chunk_hashes(&original_hash), + cas_client.get_file_reconstruction_info(&original_hash), + )?; + + let (original_mdb, _) = recon_result + .ok_or_else(|| DataProcessingError::InternalError("no reconstruction info for original file".into()))?; + + // 2. Map chunks to cumulative byte offsets. + // This builds a sorted array of byte boundaries, where chunk_offsets[i] is the + // start byte of chunk[i]. chunk_offsets has len = original_chunks.len() + 1, + // and chunk_offsets[i+1] is the end byte of chunk[i]. + // + // Example with 3 chunks of sizes [100, 200, 150]: + // chunk_offsets = [0, 100, 300, 450] + // ^ ^ ^ ^ + // | | | +-- end of chunk[2] + // | | +------ end of chunk[1] = start of chunk[2] + // | +---------- end of chunk[0] = start of chunk[1] + // +-------------- start of chunk[0] + let mut chunk_offsets: Vec = Vec::with_capacity(original_chunks.len() + 1); + chunk_offsets.push(0); + for (_, size) in &original_chunks { + chunk_offsets.push(chunk_offsets.last().unwrap() + size); + } + + // 3. Build effective dirty ranges: start from caller's ranges, then handle truncation/append. + let mut effective_ranges: Vec<(u64, u64)> = dirty_ranges.to_vec(); + + // For truncation: the boundary chunk at the cut point must be re-uploaded as a partial chunk. + // Find the last full chunk before total_size and add [last_full_chunk_end, total_size) as dirty. + // + // Scenario: file truncated from 450 bytes to 250 bytes. + // Original chunks: [100, 200, 150] --> chunk_offsets = [0, 100, 300, 450] + // ^ ^ ^ ^ ^ ^ ^ + // + // total_size = 250 falls inside chunk[1] (which spans [100, 300)) + // + // last_full = rposition of offsets <= 250 + // = index 1 (because chunk_offsets[1] = 100 <= 250) + // boundary = chunk_offsets[1] = 100 + // + // Since boundary (100) < total_size (250), we have a partial chunk: + // trunc_range = (100, 250) <-- this part of chunk[1] must be re-uploaded + // + // Then we truncate: + // original_chunks keeps [chunk[0], chunk[1]] and discards chunk[2] + // chunk_offsets becomes [0, 100, 300] (stops at chunk[1]'s end) + // + // Visual before: + // file: [====chunk[0]====][========chunk[1]========][====chunk[2]====] + // bytes: 0 100 300 450 + // + // Visual after (with truncation to 250): + // dirty: [== re-upload ==] + // stable: [====chunk[0]====][stable part] + // bytes: 0 100 250 + if total_size < original_size { + let last_full = chunk_offsets.iter().rposition(|&o| o <= total_size).unwrap_or(0); + let boundary = chunk_offsets[last_full]; + if boundary < total_size { + // Partial chunk [boundary, total_size) needs re-upload from the dirty source. + let trunc_range = (boundary, total_size); + // Merge with last dirty range if they overlap/touch. + if let Some(last) = effective_ranges.last_mut() { + if last.1 >= boundary { + last.1 = last.1.max(total_size); + } else { + effective_ranges.push(trunc_range); + } + } else { + effective_ranges.push(trunc_range); + } + } + // Remember the truncation point for composition (step 6). + // We keep chunk_offsets and original_chunks unmodified for boundary downloads. + } + // If the file grew, the region beyond original_size is always dirty. + // Scenario: file grew from 450 bytes to 550 bytes + // + // Before: + // file: [====chunk[0]====][========chunk[1]========][====chunk[2]====] + // bytes: 0 100 300 450 + // + // After (append 100 bytes): + // file: [====chunk[0]====][========chunk[1]========][====chunk[2]====][====NEW====] + // bytes: 0 100 300 450 550 + // ^ ^ + // append_start total_size + // + // The region [450, 550) is added to effective_ranges for re-upload. + if total_size > original_size { + let append_start = original_size; + // Merge with last dirty range if they touch, otherwise add a new range. + if let Some(last) = effective_ranges.last_mut() { + if last.1 >= append_start { + last.1 = last.1.max(total_size); + } else { + effective_ranges.push((append_start, total_size)); + } + } else { + effective_ranges.push((append_start, total_size)); + } + } + + let num_chunks = original_chunks.len(); + // For truncation, limit the composition to chunks that fit within total_size. + let compose_num_chunks = if total_size < original_size { + chunk_offsets.iter().rposition(|&o| o <= total_size).unwrap_or(0) + } else { + num_chunks + }; + + // Note: if effective_ranges is empty here, it means pure truncation (no dirty ranges, + // file shrunk). We still proceed to compose a new file from the truncated chunk set. + + // 4. Build the composition plan: alternating stable/dirty regions. A Region is either Stable (reuse segments) or + // Dirty (re-upload through cleaner). + // + // For each dirty byte range, find which chunks it spans. We need this mapping to decide + // which chunks to re-upload and which to reuse from the original file. + /// A dirty byte range expanded to chunk-aligned boundaries. + struct DirtyRegion { + dirty_start: u64, // byte offset where this dirty region starts + dirty_end: u64, // byte offset where this dirty region ends + first_chunk: usize, // first chunk index affected (inclusive) + last_chunk: usize, // last chunk index affected (exclusive) + } + + /// Result of uploading a single dirty region through the cleaner. + struct UploadedRegion { + region: DirtyRegion, + info: XetFileInfo, + chunks: ChunkHashList, + } + + // Example: dirty range [150, 350), chunks [0..3] with offsets [0, 100, 300, 450] + // dirty_start = 150, dirty_end = 350 + // 150 falls in chunk[1] (offset 100..300), so first_chunk = 1 + // 350 falls in chunk[2] (offset 300..450), so last_chunk = 3 (exclusive) + // This means chunks [1, 2] must be re-uploaded (the region spans these chunks) + let dirty_regions = { + let mut raw = Vec::with_capacity(effective_ranges.len()); + for &(dirty_start, dirty_end) in &effective_ranges { + // Find the first chunk whose end offset exceeds dirty_start. + let first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start).min(num_chunks); + // Find the last chunk (exclusive) that starts before dirty_end. + let last_chunk = (0..num_chunks) + .rev() + .find(|&i| chunk_offsets[i] < dirty_end.min(total_size).min(original_size)) + .map(|i| i + 1) + .unwrap_or(first_chunk); + raw.push(DirtyRegion { + dirty_start, + dirty_end, + first_chunk, + last_chunk, + }); + } + + // Coalesce dirty regions whose chunk ranges overlap or are adjacent. + // This prevents uploading the same boundary chunks twice. + // + // Scenario: two dirty ranges that span overlapping chunks: + // Region 1: [150, 250), chunks [1..2] + // Region 2: [250, 350), chunks [1..3] + // After merging: [150, 350), chunks [1..3] (upload once, not twice) + let mut merged: Vec = Vec::with_capacity(raw.len()); + for region in raw { + if let Some(last) = merged.last_mut() + && region.first_chunk <= last.last_chunk + { + // Overlap detected: extend the last region to cover both + last.dirty_end = last.dirty_end.max(region.dirty_end); + last.last_chunk = last.last_chunk.max(region.last_chunk); + continue; + } + merged.push(region); + } + merged + }; + + // 5. Process each dirty region: download boundary, stream dirty bytes, upload. Collect the resulting middle file + // infos and chunk hashes. A single upload session is shared across all dirty regions. + let session = FileUploadSession::new(config.clone(), None).await?; + + let mut uploaded_regions: Vec = Vec::new(); + + for region in dirty_regions { + let boundary_start = chunk_offsets.get(region.first_chunk).copied().unwrap_or(original_size); + let boundary_end = chunk_offsets.get(region.last_chunk).copied().unwrap_or(original_size); + + // Download boundary bytes from CAS into memory. + // + // NOTE: FileReconstructor is heavier than needed for small boundary downloads + // (~256KB): it spawns prefetch tasks, manages buffer semaphores, etc. A direct + // byte-range fetch via presigned URLs would be lighter but would require + // reimplementing xorb decompression and chunk extraction. Acceptable for now + // since boundary regions are typically 1-2 CDC chunks. + // + // NOTE: boundary_data is fully buffered before being fed to the cleaner. Streaming + // it directly (CAS async stream -> cleaner) would avoid the buffer, but requires + // interleaving async CAS reads with sync dirty_source reads in a single ordered + // stream, which adds significant complexity. The buffer is bounded by the boundary + // size (typically a few hundred KB), so memory impact is negligible. + let mut boundary_data = Vec::new(); + if boundary_start < boundary_end && boundary_end <= original_size { + let reconstructor = FileReconstructor::new(&cas_client, original_hash) + .with_byte_range(FileRange::new(boundary_start, boundary_end)); + let mut stream = reconstructor.reconstruct_to_stream(); + while let Some(chunk) = stream.next().await? { + boundary_data.extend_from_slice(&chunk); + } + debug!( + "upload_ranges: downloaded boundary ({} bytes) for dirty [{}, {})", + boundary_data.len(), + region.dirty_start, + region.dirty_end + ); + } + + // Stream boundary prefix + dirty bytes + boundary suffix into the cleaner. + // middle_end must cover both the boundary region and the dirty bytes + // (dirty_end may extend past boundary_end for truncation or appends). + let middle_end = boundary_end.max(region.dirty_end); + let middle_size = middle_end.saturating_sub(boundary_start); + + // The cleaner processes a "middle" file that spans [boundary_start, middle_end). + // We feed it in three parts: + // a) Prefix: stable bytes from the downloaded boundary + // b) Dirty: modified bytes from dirty_source + // c) Suffix: stable bytes from the downloaded boundary + // + // Example: dirty region [200, 400), boundary [100, 500) (5 chunks spanning this) + // Downloaded boundary_data = bytes [100..500) from CAS + // We feed the cleaner: + // [100..200) from boundary_data (prefix, not modified) + // [200..400) from dirty_source (modified by caller) + // [400..500) from boundary_data (suffix, not modified) + // The cleaner runs CDC + compression on this [100, 500) stream and produces new chunks. + let mut cleaner = session.start_clean(None, middle_size, Sha256Policy::Skip, Ulid::new()).await; + + // a) Boundary bytes BEFORE the dirty range. + // If the dirty range doesn't start at boundary_start, we need to include the stable prefix. + let pre_dirty_end = region.dirty_start.max(boundary_start); + if pre_dirty_end > boundary_start { + let len = (pre_dirty_end - boundary_start) as usize; + debug_assert!( + len <= boundary_data.len(), + "boundary prefix ({len} bytes) exceeds downloaded boundary data ({} bytes)", + boundary_data.len() + ); + cleaner.add_data(&boundary_data[..len.min(boundary_data.len())]).await?; + } + + // b) Dirty bytes from source, streamed in blocks. + // Read the modified bytes from [read_start, read_end) and feed them to the cleaner. + // We stream this in STREAM_BLOCK_SIZE chunks to avoid buffering the entire dirty region. + let read_start = region.dirty_start.max(boundary_start); + let read_end = region.dirty_end.min(total_size); + if read_end > read_start { + dirty_source.seek(SeekFrom::Start(read_start))?; + let mut remaining = (read_end - read_start) as usize; + let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining)]; + while remaining > 0 { + let to_read = buf.len().min(remaining); + dirty_source.read_exact(&mut buf[..to_read])?; + cleaner.add_data(&buf[..to_read]).await?; + remaining -= to_read; + } + } + + // c) Boundary bytes AFTER the dirty range. + // If the dirty range doesn't extend to boundary_end, include the stable suffix. + let post_dirty_start = region.dirty_end.min(boundary_end); + if post_dirty_start < boundary_end { + let offset = (post_dirty_start - boundary_start) as usize; + debug_assert!( + offset < boundary_data.len(), + "boundary suffix offset ({offset}) out of range ({} bytes)", + boundary_data.len() + ); + if offset < boundary_data.len() { + cleaner.add_data(&boundary_data[offset..]).await?; + } + } + + let (info, chunks, _metrics) = cleaner.finish().await?; + uploaded_regions.push(UploadedRegion { region, info, chunks }); + } + + // Checkpoint: flush xorbs without consuming the session, then retrieve MDBFileInfos. + session.checkpoint().await?; + let middle_file_infos = session.file_info_list().await?; + + // Pair each uploaded region with its MDBFileInfo from the session. + // Match by content hash. Note: if two regions produce identical content after CDC, + // they will have the same hash. We use a Vec to handle this, matching them in order + // of their file info discovery to preserve uploaded_regions order. + let mut mdb_by_hash: HashMap> = HashMap::new(); + for mdb in middle_file_infos { + mdb_by_hash.entry(mdb.metadata.file_hash).or_insert_with(Vec::new).push(mdb); + } + + struct ComposedRegion { + region: DirtyRegion, + mdb: MDBFileInfo, + chunks: ChunkHashList, + } + + let mut composed_regions: Vec = Vec::new(); + for uploaded in uploaded_regions { + let middle_hash = MerkleHash::from_hex(uploaded.info.hash())?; + let mdb_list = mdb_by_hash.get_mut(&middle_hash).ok_or_else(|| { + DataProcessingError::InternalError(format!("no MDBFileInfo for middle hash {}", middle_hash.hex())) + })?; + let mdb = mdb_list.remove(0); + composed_regions.push(ComposedRegion { + region: uploaded.region, + mdb, + chunks: uploaded.chunks, + }); + } + + // 6. Compose the final file: interleave stable regions with middle results. + // + // The final file is built by alternating: + // [Stable chunks] [Re-uploaded chunks] [Stable chunks] [Re-uploaded chunks] ... + // + // Example: + // Original: [chunk[0], chunk[1], chunk[2], chunk[3]] (4 chunks) + // Dirty region affects chunks [1..3] + // Composition: + // [chunk[0]] <-- stable, reuse from original + // [middle chunks for region] <-- re-uploaded, from cleaner + // [chunk[3]] <-- stable suffix, reuse from original + // + let mut all_chunks: Vec<(MerkleHash, u64)> = Vec::new(); + let mut all_segments: Vec = Vec::new(); + let mut all_verification = Vec::new(); + let mut chunk_cursor = 0usize; // current chunk position in the original file + let mut seg_cursor = 0usize; // current segment position (passed to extract_segments for O(S) total) + + for cr in &composed_regions { + // Stable region before this dirty region: chunks [chunk_cursor, first_chunk). + // These chunks are reused directly from the original file's segments. + if cr.region.first_chunk > chunk_cursor { + let (segs, vers) = + extract_segments(&original_mdb, &original_chunks, chunk_cursor, cr.region.first_chunk, &mut seg_cursor); + all_chunks.extend_from_slice(&original_chunks[chunk_cursor..cr.region.first_chunk]); + all_segments.extend(segs); + all_verification.extend(vers); + } + + // Middle (dirty) region: these chunks were re-uploaded and cleaner'd by the session. + // We insert the new chunks and segments from the cleaner output. + all_chunks.extend_from_slice(&cr.chunks); + all_segments.extend_from_slice(&cr.mdb.segments); + all_verification.extend_from_slice(&cr.mdb.verification); + + chunk_cursor = cr.region.last_chunk; + } + + // Stable suffix after the last dirty region. + // If there are chunks after the last dirty region, reuse them from the original. + if chunk_cursor < compose_num_chunks { + let (segs, vers) = + extract_segments(&original_mdb, &original_chunks, chunk_cursor, compose_num_chunks, &mut seg_cursor); + all_chunks.extend_from_slice(&original_chunks[chunk_cursor..compose_num_chunks]); + all_segments.extend(segs); + all_verification.extend(vers); + } + + let combined_hash = file_hash(&all_chunks); + + debug!( + "upload_ranges: composed hash={}, {} segments, {} dirty regions", + combined_hash.hex(), + all_segments.len(), + composed_regions.len() + ); + + let composed_mdb = MDBFileInfo { + metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), true, false), + segments: all_segments, + verification: all_verification, + // SHA-256 metadata_ext is intentionally omitted: the file content changed + // so the original SHA-256 is no longer valid, and recomputing it would require + // reading the full file. + metadata_ext: None, + }; + + // 7. Register composed file and finalize on the same session. + session.register_composed_file(composed_mdb).await?; + session.finalize().await?; + + let total_dirty: u64 = effective_ranges.iter().map(|(s, e)| e - s).sum(); + info!( + "upload_ranges: hash={} size={} (original={}, {} dirty regions, {} dirty bytes)", + combined_hash.hex(), + total_size, + original_size, + composed_regions.len(), + total_dirty + ); + + Ok(XetFileInfo::new(combined_hash.hex(), total_size)) +} + +/// Extract segments and verification entries for chunks `[chunk_start, chunk_end)` +/// from the original reconstruction plan, truncating segments at boundaries. +/// +/// `seg_cursor` tracks the current position in the segment list across calls. Pass +/// `&mut 0` on the first call; subsequent calls resume from where the last left off. +/// This avoids re-scanning segments from the beginning on each call (O(S) total +/// instead of O(K*S) for K calls). +fn extract_segments( + original_mdb: &MDBFileInfo, + original_chunks: &[(MerkleHash, u64)], + chunk_start: usize, + chunk_end: usize, + seg_cursor: &mut usize, +) -> (Vec, Vec) { + let mut segments = Vec::new(); + let mut verification = Vec::new(); + + // Compute the chunk-level cursor from the segment cursor. + let mut chunk_cursor: usize = original_mdb.segments[..*seg_cursor] + .iter() + .map(|s| (s.chunk_index_end - s.chunk_index_start) as usize) + .sum(); + + for seg in &original_mdb.segments[*seg_cursor..] { + let seg_count = (seg.chunk_index_end - seg.chunk_index_start) as usize; + let seg_end = chunk_cursor + seg_count; + + // Past the requested range: stop. + if chunk_cursor >= chunk_end { + break; + } + + let overlap_start = chunk_cursor.max(chunk_start); + let overlap_end = seg_end.min(chunk_end); + if overlap_start < overlap_end { + let count = overlap_end - overlap_start; + let mut truncated = seg.clone(); + truncated.chunk_index_start += (overlap_start - chunk_cursor) as u32; + truncated.chunk_index_end = truncated.chunk_index_start + count as u32; + let bytes: u64 = original_chunks[overlap_start..overlap_end].iter().map(|(_, s)| s).sum(); + truncated.unpacked_segment_bytes = bytes as u32; + segments.push(truncated); + + let hashes: Vec = original_chunks[overlap_start..overlap_end].iter().map(|(h, _)| *h).collect(); + verification.push(FileVerificationEntry::new(range_hash_from_chunks(&hashes))); + } + + chunk_cursor = seg_end; + // Only advance seg_cursor if this segment is fully consumed. + // If it extends beyond chunk_end, a later call may need its suffix. + if seg_end <= chunk_end { + *seg_cursor += 1; + } + } + + (segments, verification) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::sync::Arc; + + use tempfile::TempDir; + use ulid::Ulid; + use xet_client::cas_client::{Client, LocalTestServerBuilder}; + use xet_core_structures::merklehash::MerkleHash; + + use super::*; + use crate::processing::configurations::TranslatorConfig; + use crate::processing::file_cleaner::Sha256Policy; + use crate::processing::file_download_session::FileDownloadSession; + use crate::processing::file_upload_session::FileUploadSession; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_mid_file_edit() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let endpoint = server.http_endpoint().to_string(); + let config = Arc::new(TranslatorConfig::test_server_config(&endpoint, base_dir.path()).unwrap()); + + // Use the server directly as the CAS client (bypasses HTTP for get_file_chunk_hashes). + let cas_client: Arc = Arc::new(server); + + // 1. Upload an original file: 256 KB of 0xAA bytes. + let original_data = vec![0xAAu8; 256 * 1024]; + let original_hash = { + let upload_session = FileUploadSession::new(config.clone(), None).await.unwrap(); + let mut cleaner = upload_session + .start_clean(Some("original".into()), original_data.len() as u64, Sha256Policy::Skip, Ulid::new()) + .await; + cleaner.add_data(&original_data).await.unwrap(); + let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + upload_session.finalize().await.unwrap(); + MerkleHash::from_hex(xfi.hash()).unwrap() + }; + let original_size = original_data.len() as u64; + + // 2. Build modified content: overwrite [100_000, 101_000) with 0xBB. + let mut modified_data = original_data.clone(); + let dirty_start = 100_000usize; + let dirty_end = 101_000usize; + modified_data[dirty_start..dirty_end].fill(0xBB); + let total_size = modified_data.len() as u64; + + let mut dirty_source = Cursor::new(&modified_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(dirty_start as u64, dirty_end as u64)], + &mut dirty_source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size, total_size); + + // 3. Download and verify the composed file. + let composed_hash = MerkleHash::from_hex(result.hash()).unwrap(); + let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); + let xfi = crate::processing::XetFileInfo::new(composed_hash.hex(), total_size); + let out_path = base_dir.path().join("output"); + session.download_file(&xfi, &out_path, Ulid::new()).await.unwrap(); + let downloaded = std::fs::read(&out_path).unwrap(); + + assert_eq!(downloaded.len(), modified_data.len()); + assert_eq!(downloaded, modified_data); + } + + /// Helper to upload a file and return its hash. + async fn upload_file(config: &Arc, data: &[u8]) -> MerkleHash { + let session = FileUploadSession::new(config.clone(), None).await.unwrap(); + let mut cleaner = session + .start_clean(Some("test".into()), data.len() as u64, Sha256Policy::Skip, Ulid::new()) + .await; + cleaner.add_data(data).await.unwrap(); + let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + session.finalize().await.unwrap(); + MerkleHash::from_hex(xfi.hash()).unwrap() + } + + /// Helper to download a file and return its contents. + async fn download_file(config: &Arc, hash: MerkleHash, size: u64) -> Vec { + let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); + let xfi = crate::processing::XetFileInfo::new(hash.hex(), size); + let dir = TempDir::new().unwrap(); + let out = dir.path().join("out"); + session.download_file(&xfi, &out, Ulid::new()).await.unwrap(); + std::fs::read(&out).unwrap() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_truncation() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // Upload 256 KB file. + let original_data = vec![0xCCu8; 256 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + // Truncate to 100 KB (no dirty ranges, pure truncation). + let truncated_size = 100_000u64; + let mut source = Cursor::new(&original_data[..truncated_size as usize]); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[], // no dirty ranges, just truncation + &mut source, + truncated_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), truncated_size); + + // Download and verify: first truncated_size bytes should match original. + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; + assert_eq!(downloaded.len(), truncated_size as usize); + assert_eq!(downloaded, &original_data[..truncated_size as usize]); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_append() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // Upload 100 KB file. + let original_data = vec![0xDDu8; 100 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + // Append 50 KB of 0xEE. + let mut full_data = original_data.clone(); + full_data.extend(vec![0xEEu8; 50 * 1024]); + let total_size = full_data.len() as u64; + + let mut source = Cursor::new(&full_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(original_size, total_size)], // appended region is dirty + &mut source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), total_size); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded, full_data); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_at_file_start() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // Upload 256 KB file. + let original_data = vec![0xAAu8; 256 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + // Overwrite [0, 4096) with 0xBB (dirty range at offset 0, no stable prefix). + let mut modified_data = original_data.clone(); + modified_data[..4096].fill(0xBB); + let total_size = modified_data.len() as u64; + + let mut source = Cursor::new(&modified_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(0, 4096)], + &mut source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), total_size); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded.len(), modified_data.len()); + assert_eq!(downloaded, modified_data); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_multiple_regions() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // Upload 256 KB file. + let original_data = vec![0xAAu8; 256 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + // Two non-adjacent dirty ranges with a stable gap between them. + let mut modified_data = original_data.clone(); + modified_data[10_000..12_000].fill(0xBB); // first dirty range + modified_data[200_000..202_000].fill(0xCC); // second dirty range + let total_size = modified_data.len() as u64; + + let mut source = Cursor::new(&modified_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(10_000, 12_000), (200_000, 202_000)], + &mut source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), total_size); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded.len(), modified_data.len()); + assert_eq!(downloaded, modified_data); + } + + // ── Data integrity regression tests ────────────────────────────── + // + // All corruption scenarios share a single LocalTestServer to keep + // test runtime reasonable (~2s total instead of ~1s per scenario). + + /// Helper: upload original, apply modifications via upload_ranges, download composed, verify. + async fn assert_range_edit( + config: &Arc, + cas_client: &Arc, + original_data: &[u8], + expected: &[u8], + dirty_ranges: &[(u64, u64)], + total_size: u64, + ) { + let original_hash = upload_file(config, original_data).await; + let mut source = Cursor::new(expected); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_data.len() as u64, + dirty_ranges, + &mut source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), total_size, "file size mismatch"); + let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded.len(), expected.len(), "downloaded length mismatch"); + assert_eq!(&downloaded[..], &expected[..], "content mismatch"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_two_regions_identical_hash_collision() { + // Regression test: Two dirty regions that produce the same content (and thus the same hash) + // must not collide in the mdb_by_hash mapping. Before the fix, the second region would + // incorrectly use the MDBFileInfo from the first region, causing silent corruption. + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // Create an original file: 300 KB of 0xAA. + let original_data = vec![0xAAu8; 300 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + // Create modified data with two identical "dirty" regions: + // Region 1: bytes [50_000, 60_000) filled with 0xBB + // Region 2: bytes [150_000, 160_000) also filled with 0xBB + // If both regions produce identical CDC chunks, they will have the same hash. + // The bug would cause the second region to incorrectly use Region 1's MDBFileInfo. + let mut modified_data = original_data.clone(); + modified_data[50_000..60_000].fill(0xBB); + modified_data[150_000..160_000].fill(0xBB); + + let mut source = Cursor::new(&modified_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(50_000, 60_000), (150_000, 160_000)], + &mut source, + modified_data.len() as u64, + ) + .await + .unwrap(); + + // Verify the composed file matches our expected modifications. + let downloaded = + download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), modified_data.len() as u64).await; + assert_eq!(downloaded.len(), modified_data.len(), "downloaded length mismatch"); + assert_eq!(&downloaded[..], &modified_data[..], "content mismatch: file was corrupted"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_data_integrity_scenarios() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // ── Truncation + overlapping dirty range ──────────────────── + { + let original = vec![0xAAu8; 256 * 1024]; + let mut expected = original[..100_000].to_vec(); + expected[90_000..100_000].fill(0xBB); + assert_range_edit(&config, &cas_client, &original, &expected, &[(90_000, 100_000)], 100_000).await; + } + + // ── Full overwrite (no stable prefix or suffix) ───────────── + { + let original = vec![0xAAu8; 128 * 1024]; + let expected = vec![0xBBu8; 128 * 1024]; + let size = original.len() as u64; + assert_range_edit(&config, &cas_client, &original, &expected, &[(0, size)], size).await; + } + + // ── Three adjacent dirty ranges (coalescing) ──────────────── + { + let original = vec![0xAAu8; 256 * 1024]; + let mut expected = original.clone(); + expected[50_000..51_000].fill(0xBB); + expected[51_000..52_000].fill(0xCC); + expected[52_000..53_000].fill(0xDD); + let size = original.len() as u64; + assert_range_edit( + &config, + &cas_client, + &original, + &expected, + &[(50_000, 51_000), (51_000, 52_000), (52_000, 53_000)], + size, + ) + .await; + } + + // ── Append without explicit dirty range ───────────────────── + { + let original = vec![0xAAu8; 100 * 1024]; + let mut expected = original.clone(); + expected.extend(vec![0xEEu8; 50 * 1024]); + let total = expected.len() as u64; + assert_range_edit(&config, &cas_client, &original, &expected, &[], total).await; + } + + // ── Dirty range exactly on chunk boundary ─────────────────── + { + let original: Vec = (0..256 * 1024) + .map(|i: usize| { + let x = i.wrapping_mul(2654435761); + (x >> 16) as u8 + }) + .collect(); + let original_hash = upload_file(&config, &original).await; + let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); + if chunks.len() >= 3 { + let boundary: u64 = chunks[0].1 + chunks[1].1; + let dirty_end = boundary + chunks[2].1; + let mut expected = original.clone(); + expected[boundary as usize..dirty_end as usize].fill(0xFF); + let size = original.len() as u64; + let mut source = Cursor::new(&expected); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + size, + &[(boundary, dirty_end)], + &mut source, + size, + ) + .await + .unwrap(); + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), size).await; + assert_eq!(downloaded, expected, "chunk-boundary edit mismatch"); + } + } + } +} diff --git a/xet_data/tests/test_full_file_download.rs b/xet_data/tests/test_full_file_download.rs index eaa6e1ec1..2332ad365 100644 --- a/xet_data/tests/test_full_file_download.rs +++ b/xet_data/tests/test_full_file_download.rs @@ -21,7 +21,7 @@ mod tests { .start_clean(Some(name.into()), data.len() as u64, Sha256Policy::Compute, Ulid::new()) .await; cleaner.add_data(data).await.unwrap(); - let (xfi, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); xfi } diff --git a/xet_data/tests/test_session_resume.rs b/xet_data/tests/test_session_resume.rs index 440b4d27a..4fe1f4f6b 100644 --- a/xet_data/tests/test_session_resume.rs +++ b/xet_data/tests/test_session_resume.rs @@ -91,7 +91,7 @@ mod tests { // Add all the data. Roughly the first half should dedup. cleaner.add_data(&data).await.unwrap(); - cleaner.finish().await.unwrap(); + let _ = cleaner.finish().await.unwrap(); // Finalize everything file_upload_session.finalize().await.unwrap(); @@ -178,7 +178,7 @@ mod tests { // Add all the data. Roughly the first half should dedup. cleaner.add_data(&data).await.unwrap(); - cleaner.finish().await.unwrap(); + let _ = cleaner.finish().await.unwrap(); // Finalize everything file_upload_session.finalize().await.unwrap(); diff --git a/xet_pkg/src/xet_session/upload_commit.rs b/xet_pkg/src/xet_session/upload_commit.rs index 4e4122e7b..13af82348 100644 --- a/xet_pkg/src/xet_session/upload_commit.rs +++ b/xet_pkg/src/xet_session/upload_commit.rs @@ -144,7 +144,7 @@ impl UploadCommit { /// } /// cleaner.add_data(&buffer[0..bytes]).await?; /// } - /// let (file_info, _metrics) = cleaner.finish().await?; + /// let (file_info, _chunk_hashes, _metrics) = cleaner.finish().await?; /// # Ok(()) /// # } /// ``` @@ -1079,7 +1079,7 @@ mod tests { .await .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _) = cleaner.finish().await.unwrap(); + let (xfi, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); let results = commit.commit().await.unwrap(); assert!(results.is_empty()); assert_eq!(xfi.file_size, data.len() as u64); From 7c602c65721ee13e2e8e9120b0b669d793dda208 Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 19:09:21 +0100 Subject: [PATCH 02/38] fix: prevent data corruption when two dirty regions produce identical hash When two dirty regions generate identical content after CDC processing, they will have the same Merkle hash. Previously, the mdb_by_hash HashMap would silently drop the second region's MDBFileInfo due to hash collision, causing the second region to incorrectly use the first region's segments. The fix changes mdb_by_hash from HashMap to HashMap>, allowing it to handle multiple regions with the same hash. Regions are matched in order of upload, preserving the expected composition. Added regression test test_two_regions_identical_hash_collision that verifies two dirty regions with identical content don't cause file corruption. --- xet_data/src/processing/range_upload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 8b561a79d..308b9befd 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -365,7 +365,7 @@ pub async fn upload_ranges( // of their file info discovery to preserve uploaded_regions order. let mut mdb_by_hash: HashMap> = HashMap::new(); for mdb in middle_file_infos { - mdb_by_hash.entry(mdb.metadata.file_hash).or_insert_with(Vec::new).push(mdb); + mdb_by_hash.entry(mdb.metadata.file_hash).or_default().push(mdb); } struct ComposedRegion { From 53d8742508c5e05b078751bc1d7bb1b1fa8372fd Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 19:47:16 +0100 Subject: [PATCH 03/38] refactor: improve code quality in CAS client - Unify duplicated chunk extraction logic in MemoryClient using Cow for borrowed/owned variants - Use custom serde deserializer for type-safe MerkleHash deserialization in API responses - Move hash validation from business logic to deserialization boundary - Simplify response processing by eliminating manual hash parsing --- xet_client/src/cas_client/remote_client.rs | 21 ++++++++++--------- .../cas_client/simulation/memory_client.rs | 12 ++--------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/xet_client/src/cas_client/remote_client.rs b/xet_client/src/cas_client/remote_client.rs index 00352b256..dcbdbcb9e 100644 --- a/xet_client/src/cas_client/remote_client.rs +++ b/xet_client/src/cas_client/remote_client.rs @@ -745,15 +745,7 @@ impl Client for RemoteClient { .run_and_extract_json(move || client.get(url.clone()).with_extension(Api(api_tag)).send()) .await?; - let chunks = response - .chunks - .into_iter() - .map(|entry| { - let hash = MerkleHash::from_hex(&entry.hash) - .map_err(|e| CasClientError::Other(format!("invalid chunk hash: {e}")))?; - Ok((hash, entry.size)) - }) - .collect::>>()?; + let chunks = response.chunks.into_iter().map(|entry| (entry.hash, entry.size)).collect(); Ok(chunks) } @@ -769,10 +761,19 @@ struct FileChunkHashesResponse { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChunkHashEntry { - hash: String, + #[serde(deserialize_with = "deserialize_merkle_hash")] + hash: MerkleHash, size: u64, } +fn deserialize_merkle_hash<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + MerkleHash::from_hex(&s).map_err(serde::de::Error::custom) +} + #[cfg(test)] #[cfg(not(target_family = "wasm"))] mod tests { diff --git a/xet_client/src/cas_client/simulation/memory_client.rs b/xet_client/src/cas_client/simulation/memory_client.rs index c6d43a11a..3c4506a28 100644 --- a/xet_client/src/cas_client/simulation/memory_client.rs +++ b/xet_client/src/cas_client/simulation/memory_client.rs @@ -917,16 +917,8 @@ impl Client for MemoryClient { .ok_or(CasClientError::XORBNotFound(segment.xorb_hash))?; let xorb_obj = match storage { - XorbStorage::Materialized(entry) => &entry.xorb_object, - XorbStorage::Random(xorb) => { - // RandomXorb doesn't store a reference we can borrow, so build inline - let obj = xorb.get_xorb_object(); - let pairs = obj - .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) - .map_err(|err| CasClientError::Other(format!("chunk_hash_sizes error: {err}")))?; - result.extend(pairs); - continue; - }, + XorbStorage::Materialized(entry) => std::borrow::Cow::Borrowed(&entry.xorb_object), + XorbStorage::Random(xorb) => std::borrow::Cow::Owned(xorb.get_xorb_object()), }; let pairs = xorb_obj From 44774c2c64f759f9aae9b9b90858f30d245706dd Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 21:18:28 +0100 Subject: [PATCH 04/38] fix: correct append, truncation, and hash dedup in upload_ranges Four bugs found via Codex review: 1. Truncation boundary leak: boundary suffix fed CAS bytes beyond total_size into the cleaner, producing wrong chunks and hash. Fix: cap boundary_end at total_size. 2. Identical hash panic: shard manager deduplicates MDBFileInfo by file_hash (BTreeMap), so two regions with the same content only produce one entry. The old remove(0) would panic on the second region. Fix: clone instead of remove (same hash = same segments). 3. Append hash mismatch: last original chunk (EOF-terminated) was reused verbatim instead of being re-chunked with appended data, producing a different hash than a clean upload. Fix: back up first_chunk to include the last original chunk, download its bytes from CAS via the boundary prefix mechanism. 4. Append gap loss: a dirty range starting after original_size (seek-past-EOF) left bytes [original_size, dirty_start) uncovered. Fix: pull dirty range start to original_size during append merge. All tests now verify hash equality with a clean upload of the same content. Added sparse staging file test to catch the boundary prefix vs staging file data source issue. --- xet_data/src/processing/range_upload.rs | 331 +++++++++++++++++++++--- 1 file changed, 289 insertions(+), 42 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 308b9befd..34aad82ad 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -141,7 +141,11 @@ pub async fn upload_ranges( // Remember the truncation point for composition (step 6). // We keep chunk_offsets and original_chunks unmodified for boundary downloads. } - // If the file grew, the region beyond original_size is always dirty. + // If the file grew, ensure the dirty region starts at original_size so the appended + // bytes are read from the staging file. The last original chunk (EOF-terminated) will + // be included via a first_chunk adjustment below, and its bytes will come from CAS + // via the boundary prefix mechanism (not from the staging file, which is sparse). + // // Scenario: file grew from 450 bytes to 550 bytes // // Before: @@ -149,17 +153,19 @@ pub async fn upload_ranges( // bytes: 0 100 300 450 // // After (append 100 bytes): - // file: [====chunk[0]====][========chunk[1]========][====chunk[2]====][====NEW====] - // bytes: 0 100 300 450 550 - // ^ ^ - // append_start total_size - // - // The region [450, 550) is added to effective_ranges for re-upload. + // dirty_start = 450 (original_size), first_chunk backed up to include chunk[2] + // boundary downloads [300, 450) from CAS ← last chunk data + // prefix feeds [300, 450) from CAS ← boundary prefix mechanism + // dirty reads [450, 550) from staging ← appended data + // cleaner re-chunks [300, 550) together ← canonical CDC boundaries if total_size > original_size { let append_start = original_size; - // Merge with last dirty range if they touch, otherwise add a new range. + // Merge with last dirty range if they overlap/touch, otherwise add a new range. + // Pull start backward to original_size if needed (e.g. caller passed a dirty range + // starting after original_size, like a seek-past-EOF write). if let Some(last) = effective_ranges.last_mut() { if last.1 >= append_start { + last.0 = last.0.min(append_start); last.1 = last.1.max(total_size); } else { effective_ranges.push((append_start, total_size)); @@ -209,7 +215,16 @@ pub async fn upload_ranges( let mut raw = Vec::with_capacity(effective_ranges.len()); for &(dirty_start, dirty_end) in &effective_ranges { // Find the first chunk whose end offset exceeds dirty_start. - let first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start).min(num_chunks); + let mut first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start).min(num_chunks); + + // For append regions (dirty_start >= original_size), include the last original + // chunk so it gets re-chunked with the appended data. The last chunk was + // terminated by EOF (not by the rolling hash), so its boundary is artificial. + // The boundary prefix mechanism will download its bytes from CAS. + if total_size > original_size && dirty_start >= original_size && first_chunk > 0 { + first_chunk -= 1; + } + // Find the last chunk (exclusive) that starts before dirty_end. let last_chunk = (0..num_chunks) .rev() @@ -287,8 +302,9 @@ pub async fn upload_ranges( // Stream boundary prefix + dirty bytes + boundary suffix into the cleaner. // middle_end must cover both the boundary region and the dirty bytes - // (dirty_end may extend past boundary_end for truncation or appends). - let middle_end = boundary_end.max(region.dirty_end); + // (dirty_end may extend past boundary_end for appends), but never exceed + // total_size (truncation must not include original bytes past the cut). + let middle_end = boundary_end.max(region.dirty_end).min(total_size); let middle_size = middle_end.saturating_sub(boundary_start); // The cleaner processes a "middle" file that spans [boundary_start, middle_end). @@ -338,16 +354,19 @@ pub async fn upload_ranges( // c) Boundary bytes AFTER the dirty range. // If the dirty range doesn't extend to boundary_end, include the stable suffix. - let post_dirty_start = region.dirty_end.min(boundary_end); - if post_dirty_start < boundary_end { + // Cap at total_size so truncation doesn't leak original bytes beyond the cut point. + let effective_boundary_end = boundary_end.min(total_size); + let post_dirty_start = region.dirty_end.min(effective_boundary_end); + if post_dirty_start < effective_boundary_end { let offset = (post_dirty_start - boundary_start) as usize; + let end = (effective_boundary_end - boundary_start) as usize; debug_assert!( - offset < boundary_data.len(), - "boundary suffix offset ({offset}) out of range ({} bytes)", + end <= boundary_data.len(), + "boundary suffix end ({end}) out of range ({} bytes)", boundary_data.len() ); if offset < boundary_data.len() { - cleaner.add_data(&boundary_data[offset..]).await?; + cleaner.add_data(&boundary_data[offset..end.min(boundary_data.len())]).await?; } } @@ -360,12 +379,13 @@ pub async fn upload_ranges( let middle_file_infos = session.file_info_list().await?; // Pair each uploaded region with its MDBFileInfo from the session. - // Match by content hash. Note: if two regions produce identical content after CDC, - // they will have the same hash. We use a Vec to handle this, matching them in order - // of their file info discovery to preserve uploaded_regions order. - let mut mdb_by_hash: HashMap> = HashMap::new(); + // Match by content hash. The shard manager deduplicates by file_hash (BTreeMap), + // so two regions with identical content produce only ONE MDBFileInfo entry. + // This is correct: same hash = same bytes = same chunks = same segments, + // so we clone the same MDBFileInfo for all regions sharing that hash. + let mut mdb_by_hash: HashMap = HashMap::new(); for mdb in middle_file_infos { - mdb_by_hash.entry(mdb.metadata.file_hash).or_default().push(mdb); + mdb_by_hash.insert(mdb.metadata.file_hash, mdb); } struct ComposedRegion { @@ -377,10 +397,9 @@ pub async fn upload_ranges( let mut composed_regions: Vec = Vec::new(); for uploaded in uploaded_regions { let middle_hash = MerkleHash::from_hex(uploaded.info.hash())?; - let mdb_list = mdb_by_hash.get_mut(&middle_hash).ok_or_else(|| { + let mdb = mdb_by_hash.get(&middle_hash).cloned().ok_or_else(|| { DataProcessingError::InternalError(format!("no MDBFileInfo for middle hash {}", middle_hash.hex())) })?; - let mdb = mdb_list.remove(0); composed_regions.push(ComposedRegion { region: uploaded.region, mdb, @@ -603,6 +622,10 @@ mod tests { assert_eq!(downloaded.len(), modified_data.len()); assert_eq!(downloaded, modified_data); + + // Hash must match a clean upload of the same content. + let clean_hash = upload_file(&config, &modified_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } /// Helper to upload a file and return its hash. @@ -660,6 +683,9 @@ mod tests { let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; assert_eq!(downloaded.len(), truncated_size as usize); assert_eq!(downloaded, &original_data[..truncated_size as usize]); + + let clean_hash = upload_file(&config, &original_data[..truncated_size as usize]).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -696,6 +722,9 @@ mod tests { let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded, full_data); + + let clean_hash = upload_file(&config, &full_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -733,6 +762,9 @@ mod tests { let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), modified_data.len()); assert_eq!(downloaded, modified_data); + + let clean_hash = upload_file(&config, &modified_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -771,6 +803,9 @@ mod tests { let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), modified_data.len()); assert_eq!(downloaded, modified_data); + + let clean_hash = upload_file(&config, &modified_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } // ── Data integrity regression tests ────────────────────────────── @@ -778,7 +813,8 @@ mod tests { // All corruption scenarios share a single LocalTestServer to keep // test runtime reasonable (~2s total instead of ~1s per scenario). - /// Helper: upload original, apply modifications via upload_ranges, download composed, verify. + /// Helper: upload original, apply modifications via upload_ranges, download composed, verify + /// both content and hash equality with a clean upload of the expected data. async fn assert_range_edit( config: &Arc, cas_client: &Arc, @@ -805,50 +841,258 @@ mod tests { let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), expected.len(), "downloaded length mismatch"); assert_eq!(&downloaded[..], &expected[..], "content mismatch"); + + // Hash must match a clean upload of the same content. + let clean_hash = upload_file(config, expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_two_regions_identical_hash_collision() { // Regression test: Two dirty regions that produce the same content (and thus the same hash) // must not collide in the mdb_by_hash mapping. Before the fix, the second region would - // incorrectly use the MDBFileInfo from the first region, causing silent corruption. + // panic on remove(0) from an empty Vec because the shard manager deduplicates MDBFileInfo + // entries by file_hash (BTreeMap). + // + // To guarantee a hash collision we use chunk-aligned dirty ranges with identical content: + // both regions span the same chunk boundaries (relative to their CDC context) and contain + // the same bytes, so the cleaner produces identical hashes. let server = LocalTestServerBuilder::new().start().await; let base_dir = TempDir::new().unwrap(); let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - // Create an original file: 300 KB of 0xAA. + // Create an original file of uniform bytes so CDC produces predictable chunks. let original_data = vec![0xAAu8; 300 * 1024]; let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - // Create modified data with two identical "dirty" regions: - // Region 1: bytes [50_000, 60_000) filled with 0xBB - // Region 2: bytes [150_000, 160_000) also filled with 0xBB - // If both regions produce identical CDC chunks, they will have the same hash. - // The bug would cause the second region to incorrectly use Region 1's MDBFileInfo. - let mut modified_data = original_data.clone(); - modified_data[50_000..60_000].fill(0xBB); - modified_data[150_000..160_000].fill(0xBB); + // Get chunk boundaries to align our dirty ranges. + let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); + // We need at least 4 chunks so we can place two non-adjacent dirty regions + // that each span exactly one chunk (guaranteed identical CDC input). + if chunks.len() >= 4 { + let mut offsets = vec![0u64]; + for (_, size) in &chunks { + offsets.push(offsets.last().unwrap() + size); + } - let mut source = Cursor::new(&modified_data); + // Region 1: overwrite chunk[1] entirely. Region 2: overwrite chunk[3] entirely. + // Both get the same 0xBB fill, and since each spans exactly one full chunk + // boundary, the cleaner input is byte-identical -> same hash. + let r1_start = offsets[1] as usize; + let r1_end = offsets[2] as usize; + let r2_start = offsets[3] as usize; + let r2_end = offsets[4].min(original_size) as usize; + + let mut modified_data = original_data.clone(); + modified_data[r1_start..r1_end].fill(0xBB); + modified_data[r2_start..r2_end].fill(0xBB); + + let mut source = Cursor::new(&modified_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(r1_start as u64, r1_end as u64), (r2_start as u64, r2_end as u64)], + &mut source, + modified_data.len() as u64, + ) + .await + .unwrap(); + + let downloaded = + download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), modified_data.len() as u64).await; + assert_eq!(downloaded.len(), modified_data.len(), "downloaded length mismatch"); + assert_eq!(&downloaded[..], &modified_data[..], "content mismatch: file was corrupted"); + + let clean_hash = upload_file(&config, &modified_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + } + + /// Regression test: truncation must produce the exact same hash as a clean upload + /// of the truncated content. Before the fix, boundary bytes beyond total_size leaked + /// into the cleaner, producing extra chunks and a wrong file hash. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_truncation_hash_matches_clean_upload() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + // Upload a 256 KB file, then truncate to 100 KB via upload_ranges. + let original_data = vec![0xCCu8; 256 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let truncated_size = 100_000u64; + let truncated_data = &original_data[..truncated_size as usize]; + + // upload_ranges with truncation + let mut source = Cursor::new(truncated_data); + let range_result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[], + &mut source, + truncated_size, + ) + .await + .unwrap(); + + // Clean upload of the same truncated content + let clean_hash = upload_file(&config, truncated_data).await; + + assert_eq!( + range_result.hash(), + clean_hash.hex(), + "truncation hash ({}) does not match clean upload hash ({})", + range_result.hash(), + clean_hash.hex() + ); + } + + /// Regression test: append must produce the exact same hash as a clean upload + /// of the full content. Before the fix, the last original chunk (EOF-terminated) + /// was reused verbatim instead of being re-chunked with the appended data. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_append_hash_matches_clean_upload() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = vec![0xDDu8; 100 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let mut full_data = original_data.clone(); + full_data.extend(vec![0xEEu8; 50 * 1024]); + let total_size = full_data.len() as u64; + + let mut source = Cursor::new(&full_data); + let range_result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(original_size, total_size)], + &mut source, + total_size, + ) + .await + .unwrap(); + + let clean_hash = upload_file(&config, &full_data).await; + + assert_eq!( + range_result.hash(), + clean_hash.hex(), + "append hash ({}) does not match clean upload hash ({})", + range_result.hash(), + clean_hash.hex() + ); + } + + /// Regression test: a dirty range starting after original_size must not lose the + /// gap bytes [original_size, dirty_start). This simulates a seek-past-EOF write. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_append_with_gap_before_dirty_range() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = vec![0xAAu8; 100 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + // Simulate: seek to original_size + 500, write 4096 bytes of 0xBB. + // The gap [original_size, original_size + 500) contains zeros from the sparse file. + let gap = 500u64; + let write_len = 4096u64; + let total_size = original_size + gap + write_len; + + let mut full_data = original_data.clone(); + full_data.extend(vec![0x00u8; gap as usize]); // sparse hole = zeros + full_data.extend(vec![0xBBu8; write_len as usize]); + + let dirty_start = original_size + gap; + let dirty_end = total_size; + + let mut source = Cursor::new(&full_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(dirty_start, dirty_end)], + &mut source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), total_size); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded.len(), full_data.len(), "size mismatch"); + assert_eq!(&downloaded[..], &full_data[..], "content mismatch: gap bytes were lost"); + + let clean_hash = upload_file(&config, &full_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + + /// Regression test: append with a sparse staging file (zeros where CAS data should be). + /// This simulates the real hf-mount scenario where the staging file is created with + /// set_len(original_size) and only appended bytes are written. The boundary prefix + /// mechanism must fetch the last chunk from CAS, not read zeros from the staging file. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_append_sparse_staging_file() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = vec![0xDDu8; 100 * 1024]; + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let append_data = vec![0xEEu8; 50 * 1024]; + let total_size = original_size + append_data.len() as u64; + + // Build a sparse staging file: zeros for [0, original_size), real data after. + // This is what hf-mount produces (sparse hole + appended bytes). + let mut sparse_staging = vec![0u8; total_size as usize]; + sparse_staging[original_size as usize..].copy_from_slice(&append_data); + + let mut source = Cursor::new(&sparse_staging); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(50_000, 60_000), (150_000, 160_000)], + &[(original_size, total_size)], &mut source, - modified_data.len() as u64, + total_size, ) .await .unwrap(); - // Verify the composed file matches our expected modifications. - let downloaded = - download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), modified_data.len() as u64).await; - assert_eq!(downloaded.len(), modified_data.len(), "downloaded length mismatch"); - assert_eq!(&downloaded[..], &modified_data[..], "content mismatch: file was corrupted"); + // Expected: original data + appended data (not zeros + appended data). + let mut expected = original_data.clone(); + expected.extend(&append_data); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded.len(), expected.len(), "size mismatch"); + assert_eq!(&downloaded[..], &expected[..], "content mismatch: CAS data replaced by zeros from sparse file"); + + let clean_hash = upload_file(&config, &expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -932,6 +1176,9 @@ mod tests { .unwrap(); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), size).await; assert_eq!(downloaded, expected, "chunk-boundary edit mismatch"); + + let clean_hash = upload_file(&config, &expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } } } From 8da6741ecc606322db875dc85d6fd7ed0dd49ccf Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 21:20:59 +0100 Subject: [PATCH 05/38] fix: update WASM cleaner for new finalize() signature The dedup_manager.finalize() now returns ChunkHashList as a second element. Destructure it as _chunk_hashes to fix the WASM build. --- wasm/hf_xet_wasm/src/wasm_file_cleaner.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wasm/hf_xet_wasm/src/wasm_file_cleaner.rs b/wasm/hf_xet_wasm/src/wasm_file_cleaner.rs index dbbfaabe8..6ce39e6e3 100644 --- a/wasm/hf_xet_wasm/src/wasm_file_cleaner.rs +++ b/wasm/hf_xet_wasm/src/wasm_file_cleaner.rs @@ -176,7 +176,8 @@ impl SingleFileCleaner { let metadata_ext = FileMetadataExt::new(sha256); // Now finish the deduplication process. - let (file_hash, remaining_file_data, deduplication_metrics) = self.dedup_manager.finalize(Some(metadata_ext)); + let (file_hash, _chunk_hashes, remaining_file_data, deduplication_metrics) = + self.dedup_manager.finalize(Some(metadata_ext)); // Let's check some things that should be invariants { From 32183f747fa468e4ca84c9b24a8e1c14b07a7c1b Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 22:36:06 +0100 Subject: [PATCH 06/38] refactor: improve code quality from review feedback - Move DirtyRegion, UploadedRegion, ComposedRegion to module level - Make register_composed_file and file_info_list pub(crate) - Add TODO for blocking I/O in async context - Use pseudo-random data in all tests for reliable multi-chunk CDC - Replace if-guard with assert! in hash collision test (512KB data) - Clean up redundant comments --- .../src/processing/file_upload_session.rs | 4 +- xet_data/src/processing/range_upload.rs | 184 +++++++++--------- 2 files changed, 96 insertions(+), 92 deletions(-) diff --git a/xet_data/src/processing/file_upload_session.rs b/xet_data/src/processing/file_upload_session.rs index c26fb9f2a..54499eb62 100644 --- a/xet_data/src/processing/file_upload_session.rs +++ b/xet_data/src/processing/file_upload_session.rs @@ -528,13 +528,13 @@ impl FileUploadSession { /// Register a pre-composed file reconstruction plan (MDBFileInfo) with this session. /// Used for append-aware writes where the caller builds the reconstruction plan /// from existing segments + newly uploaded segments. - pub async fn register_composed_file(self: &Arc, file_info: MDBFileInfo) -> Result<()> { + pub(crate) async fn register_composed_file(self: &Arc, file_info: MDBFileInfo) -> Result<()> { self.shard_interface.add_file_reconstruction_info(file_info).await } /// Returns a list of all file reconstruction infos currently registered in this session. /// Call after all cleaners have finished and after `checkpoint()` to ensure data is flushed. - pub async fn file_info_list(self: &Arc) -> Result> { + pub(crate) async fn file_info_list(self: &Arc) -> Result> { self.shard_interface.session_file_info_list().await } diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 34aad82ad..244a919b9 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -26,6 +26,28 @@ impl ReadSeek for T {} /// Size of blocks read from the dirty source and fed to the cleaner. const STREAM_BLOCK_SIZE: usize = 4 * 1024 * 1024; // 4 MB +/// A dirty byte range expanded to chunk-aligned boundaries. +struct DirtyRegion { + dirty_start: u64, + dirty_end: u64, + first_chunk: usize, // inclusive + last_chunk: usize, // exclusive +} + +/// Result of uploading a single dirty region through the cleaner. +struct UploadedRegion { + region: DirtyRegion, + info: XetFileInfo, + chunks: ChunkHashList, +} + +/// A dirty region paired with its MDBFileInfo and chunk hashes. +struct ComposedRegion { + region: DirtyRegion, + mdb: MDBFileInfo, + chunks: ChunkHashList, +} + /// Upload modified ranges of an existing file, composing the result with /// the original file's CAS segments. Only the dirty regions (plus CDC boundary /// chunks) are re-uploaded; stable regions between and around dirty ranges are @@ -186,26 +208,8 @@ pub async fn upload_ranges( // Note: if effective_ranges is empty here, it means pure truncation (no dirty ranges, // file shrunk). We still proceed to compose a new file from the truncated chunk set. - // 4. Build the composition plan: alternating stable/dirty regions. A Region is either Stable (reuse segments) or - // Dirty (re-upload through cleaner). + // 4. Map dirty byte ranges to chunk-aligned boundaries, then coalesce overlapping regions. // - // For each dirty byte range, find which chunks it spans. We need this mapping to decide - // which chunks to re-upload and which to reuse from the original file. - /// A dirty byte range expanded to chunk-aligned boundaries. - struct DirtyRegion { - dirty_start: u64, // byte offset where this dirty region starts - dirty_end: u64, // byte offset where this dirty region ends - first_chunk: usize, // first chunk index affected (inclusive) - last_chunk: usize, // last chunk index affected (exclusive) - } - - /// Result of uploading a single dirty region through the cleaner. - struct UploadedRegion { - region: DirtyRegion, - info: XetFileInfo, - chunks: ChunkHashList, - } - // Example: dirty range [150, 350), chunks [0..3] with offsets [0, 100, 300, 450] // dirty_start = 150, dirty_end = 350 // 150 falls in chunk[1] (offset 100..300), so first_chunk = 1 @@ -336,8 +340,8 @@ pub async fn upload_ranges( } // b) Dirty bytes from source, streamed in blocks. - // Read the modified bytes from [read_start, read_end) and feed them to the cleaner. - // We stream this in STREAM_BLOCK_SIZE chunks to avoid buffering the entire dirty region. + // TODO: seek/read_exact are blocking I/O in an async context. Acceptable for local + // files (<1ms per 4MB block) but consider block_in_place for network-backed sources. let read_start = region.dirty_start.max(boundary_start); let read_end = region.dirty_end.min(total_size); if read_end > read_start { @@ -388,12 +392,6 @@ pub async fn upload_ranges( mdb_by_hash.insert(mdb.metadata.file_hash, mdb); } - struct ComposedRegion { - region: DirtyRegion, - mdb: MDBFileInfo, - chunks: ChunkHashList, - } - let mut composed_regions: Vec = Vec::new(); for uploaded in uploaded_regions { let middle_hash = MerkleHash::from_hex(uploaded.info.hash())?; @@ -423,12 +421,11 @@ pub async fn upload_ranges( let mut all_chunks: Vec<(MerkleHash, u64)> = Vec::new(); let mut all_segments: Vec = Vec::new(); let mut all_verification = Vec::new(); - let mut chunk_cursor = 0usize; // current chunk position in the original file - let mut seg_cursor = 0usize; // current segment position (passed to extract_segments for O(S) total) + let mut chunk_cursor = 0usize; + let mut seg_cursor = 0usize; for cr in &composed_regions { - // Stable region before this dirty region: chunks [chunk_cursor, first_chunk). - // These chunks are reused directly from the original file's segments. + // Stable region before this dirty region. if cr.region.first_chunk > chunk_cursor { let (segs, vers) = extract_segments(&original_mdb, &original_chunks, chunk_cursor, cr.region.first_chunk, &mut seg_cursor); @@ -437,8 +434,7 @@ pub async fn upload_ranges( all_verification.extend(vers); } - // Middle (dirty) region: these chunks were re-uploaded and cleaner'd by the session. - // We insert the new chunks and segments from the cleaner output. + // Middle (dirty) region. all_chunks.extend_from_slice(&cr.chunks); all_segments.extend_from_slice(&cr.mdb.segments); all_verification.extend_from_slice(&cr.mdb.verification); @@ -447,7 +443,6 @@ pub async fn upload_ranges( } // Stable suffix after the last dirty region. - // If there are chunks after the last dirty region, reuse them from the original. if chunk_cursor < compose_num_chunks { let (segs, vers) = extract_segments(&original_mdb, &original_chunks, chunk_cursor, compose_num_chunks, &mut seg_cursor); @@ -566,6 +561,16 @@ mod tests { use crate::processing::file_download_session::FileDownloadSession; use crate::processing::file_upload_session::FileUploadSession; + /// Generate pseudo-random data that produces multiple CDC chunks. + fn random_data(seed: u64, len: usize) -> Vec { + (0..len) + .map(|i| { + let x = (i as u64).wrapping_add(seed).wrapping_mul(2654435761); + (x >> 16) as u8 + }) + .collect() + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_upload_ranges_mid_file_edit() { let server = LocalTestServerBuilder::new().start().await; @@ -576,8 +581,8 @@ mod tests { // Use the server directly as the CAS client (bypasses HTTP for get_file_chunk_hashes). let cas_client: Arc = Arc::new(server); - // 1. Upload an original file: 256 KB of 0xAA bytes. - let original_data = vec![0xAAu8; 256 * 1024]; + // 1. Upload an original file: 256 KB of pseudo-random bytes. + let original_data = random_data(42, 256 * 1024); let original_hash = { let upload_session = FileUploadSession::new(config.clone(), None).await.unwrap(); let mut cleaner = upload_session @@ -658,7 +663,7 @@ mod tests { let cas_client: Arc = Arc::new(server); // Upload 256 KB file. - let original_data = vec![0xCCu8; 256 * 1024]; + let original_data = random_data(43, 256 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; @@ -696,13 +701,13 @@ mod tests { let cas_client: Arc = Arc::new(server); // Upload 100 KB file. - let original_data = vec![0xDDu8; 100 * 1024]; + let original_data = random_data(44, 100 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - // Append 50 KB of 0xEE. + // Append 50 KB of pseudo-random data. let mut full_data = original_data.clone(); - full_data.extend(vec![0xEEu8; 50 * 1024]); + full_data.extend(random_data(99, 50 * 1024)); let total_size = full_data.len() as u64; let mut source = Cursor::new(&full_data); @@ -735,7 +740,7 @@ mod tests { let cas_client: Arc = Arc::new(server); // Upload 256 KB file. - let original_data = vec![0xAAu8; 256 * 1024]; + let original_data = random_data(45, 256 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; @@ -775,7 +780,7 @@ mod tests { let cas_client: Arc = Arc::new(server); // Upload 256 KB file. - let original_data = vec![0xAAu8; 256 * 1024]; + let original_data = random_data(46, 256 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; @@ -862,54 +867,52 @@ mod tests { let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - // Create an original file of uniform bytes so CDC produces predictable chunks. - let original_data = vec![0xAAu8; 300 * 1024]; + // Create an original file of pseudo-random bytes so CDC produces multiple chunks. + // 512 KB of random data to reliably produce >= 4 CDC chunks. + let original_data = random_data(47, 512 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - // Get chunk boundaries to align our dirty ranges. let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); - // We need at least 4 chunks so we can place two non-adjacent dirty regions - // that each span exactly one chunk (guaranteed identical CDC input). - if chunks.len() >= 4 { - let mut offsets = vec![0u64]; - for (_, size) in &chunks { - offsets.push(offsets.last().unwrap() + size); - } + assert!(chunks.len() >= 4, "expected at least 4 chunks, got {}", chunks.len()); - // Region 1: overwrite chunk[1] entirely. Region 2: overwrite chunk[3] entirely. - // Both get the same 0xBB fill, and since each spans exactly one full chunk - // boundary, the cleaner input is byte-identical -> same hash. - let r1_start = offsets[1] as usize; - let r1_end = offsets[2] as usize; - let r2_start = offsets[3] as usize; - let r2_end = offsets[4].min(original_size) as usize; - - let mut modified_data = original_data.clone(); - modified_data[r1_start..r1_end].fill(0xBB); - modified_data[r2_start..r2_end].fill(0xBB); - - let mut source = Cursor::new(&modified_data); - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_size, - &[(r1_start as u64, r1_end as u64), (r2_start as u64, r2_end as u64)], - &mut source, - modified_data.len() as u64, - ) - .await - .unwrap(); + let mut offsets = vec![0u64]; + for (_, size) in &chunks { + offsets.push(offsets.last().unwrap() + size); + } - let downloaded = - download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), modified_data.len() as u64).await; - assert_eq!(downloaded.len(), modified_data.len(), "downloaded length mismatch"); - assert_eq!(&downloaded[..], &modified_data[..], "content mismatch: file was corrupted"); + // Region 1: overwrite chunk[1] entirely. Region 2: overwrite chunk[3] entirely. + // Both get the same 0xBB fill, and since each spans exactly one full chunk + // boundary, the cleaner input is byte-identical -> same hash. + let r1_start = offsets[1] as usize; + let r1_end = offsets[2] as usize; + let r2_start = offsets[3] as usize; + let r2_end = offsets[4].min(original_size) as usize; - let clean_hash = upload_file(&config, &modified_data).await; - assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); - } + let mut modified_data = original_data.clone(); + modified_data[r1_start..r1_end].fill(0xBB); + modified_data[r2_start..r2_end].fill(0xBB); + + let mut source = Cursor::new(&modified_data); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(r1_start as u64, r1_end as u64), (r2_start as u64, r2_end as u64)], + &mut source, + modified_data.len() as u64, + ) + .await + .unwrap(); + + let downloaded = + download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), modified_data.len() as u64).await; + assert_eq!(downloaded.len(), modified_data.len(), "downloaded length mismatch"); + assert_eq!(&downloaded[..], &modified_data[..], "content mismatch: file was corrupted"); + + let clean_hash = upload_file(&config, &modified_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } /// Regression test: truncation must produce the exact same hash as a clean upload @@ -923,7 +926,7 @@ mod tests { let cas_client: Arc = Arc::new(server); // Upload a 256 KB file, then truncate to 100 KB via upload_ranges. - let original_data = vec![0xCCu8; 256 * 1024]; + let original_data = random_data(48, 256 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; @@ -966,12 +969,12 @@ mod tests { let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - let original_data = vec![0xDDu8; 100 * 1024]; + let original_data = random_data(49, 100 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; let mut full_data = original_data.clone(); - full_data.extend(vec![0xEEu8; 50 * 1024]); + full_data.extend(random_data(100, 50 * 1024)); let total_size = full_data.len() as u64; let mut source = Cursor::new(&full_data); @@ -1007,19 +1010,20 @@ mod tests { let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - let original_data = vec![0xAAu8; 100 * 1024]; + let original_data = random_data(50, 100 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - // Simulate: seek to original_size + 500, write 4096 bytes of 0xBB. + // Simulate: seek to original_size + 500, write 4096 bytes of pseudo-random data. // The gap [original_size, original_size + 500) contains zeros from the sparse file. let gap = 500u64; - let write_len = 4096u64; + let write_data = random_data(101, 4096); + let write_len = write_data.len() as u64; let total_size = original_size + gap + write_len; let mut full_data = original_data.clone(); full_data.extend(vec![0x00u8; gap as usize]); // sparse hole = zeros - full_data.extend(vec![0xBBu8; write_len as usize]); + full_data.extend(&write_data); let dirty_start = original_size + gap; let dirty_end = total_size; From 269cb35eb6c8e56064c6f3d284efadab618174c8 Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 22:43:14 +0100 Subject: [PATCH 07/38] refactor: stream boundary data directly to cleaner without buffering Replace the single buffered boundary download with two targeted FileReconstructor streams (prefix + suffix) that feed directly into the cleaner. This eliminates the Vec intermediate buffer and the offset arithmetic for slicing prefix/suffix from it. Each stream covers exactly the bytes needed (typically one CDC chunk), and most cases only need one stream (prefix or suffix, not both). --- xet_data/src/processing/range_upload.rs | 99 +++++++------------------ 1 file changed, 28 insertions(+), 71 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 244a919b9..dc267f5e3 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -275,68 +275,32 @@ pub async fn upload_ranges( let boundary_start = chunk_offsets.get(region.first_chunk).copied().unwrap_or(original_size); let boundary_end = chunk_offsets.get(region.last_chunk).copied().unwrap_or(original_size); - // Download boundary bytes from CAS into memory. + // The cleaner processes a "middle" file that spans [boundary_start, middle_end). + // We stream it in three parts directly, without buffering boundary data: // - // NOTE: FileReconstructor is heavier than needed for small boundary downloads - // (~256KB): it spawns prefetch tasks, manages buffer semaphores, etc. A direct - // byte-range fetch via presigned URLs would be lighter but would require - // reimplementing xorb decompression and chunk extraction. Acceptable for now - // since boundary regions are typically 1-2 CDC chunks. + // a) Prefix: CAS stream [boundary_start, dirty_start) ← stable bytes before edit + // b) Dirty: staging file [dirty_start, dirty_end) ← modified bytes + // c) Suffix: CAS stream [dirty_end, boundary_end) ← stable bytes after edit // - // NOTE: boundary_data is fully buffered before being fed to the cleaner. Streaming - // it directly (CAS async stream -> cleaner) would avoid the buffer, but requires - // interleaving async CAS reads with sync dirty_source reads in a single ordered - // stream, which adds significant complexity. The buffer is bounded by the boundary - // size (typically a few hundred KB), so memory impact is negligible. - let mut boundary_data = Vec::new(); - if boundary_start < boundary_end && boundary_end <= original_size { - let reconstructor = FileReconstructor::new(&cas_client, original_hash) - .with_byte_range(FileRange::new(boundary_start, boundary_end)); - let mut stream = reconstructor.reconstruct_to_stream(); - while let Some(chunk) = stream.next().await? { - boundary_data.extend_from_slice(&chunk); - } - debug!( - "upload_ranges: downloaded boundary ({} bytes) for dirty [{}, {})", - boundary_data.len(), - region.dirty_start, - region.dirty_end - ); - } - - // Stream boundary prefix + dirty bytes + boundary suffix into the cleaner. - // middle_end must cover both the boundary region and the dirty bytes - // (dirty_end may extend past boundary_end for appends), but never exceed - // total_size (truncation must not include original bytes past the cut). - let middle_end = boundary_end.max(region.dirty_end).min(total_size); + // Example: dirty region [200, 400), boundary [100, 500) + // a) CAS stream [100..200) → cleaner + // b) staging [200..400) → cleaner (in 4MB blocks) + // c) CAS stream [400..500) → cleaner + let effective_boundary_end = boundary_end.min(total_size); + let middle_end = effective_boundary_end.max(region.dirty_end).min(total_size); let middle_size = middle_end.saturating_sub(boundary_start); - // The cleaner processes a "middle" file that spans [boundary_start, middle_end). - // We feed it in three parts: - // a) Prefix: stable bytes from the downloaded boundary - // b) Dirty: modified bytes from dirty_source - // c) Suffix: stable bytes from the downloaded boundary - // - // Example: dirty region [200, 400), boundary [100, 500) (5 chunks spanning this) - // Downloaded boundary_data = bytes [100..500) from CAS - // We feed the cleaner: - // [100..200) from boundary_data (prefix, not modified) - // [200..400) from dirty_source (modified by caller) - // [400..500) from boundary_data (suffix, not modified) - // The cleaner runs CDC + compression on this [100, 500) stream and produces new chunks. let mut cleaner = session.start_clean(None, middle_size, Sha256Policy::Skip, Ulid::new()).await; - // a) Boundary bytes BEFORE the dirty range. - // If the dirty range doesn't start at boundary_start, we need to include the stable prefix. - let pre_dirty_end = region.dirty_start.max(boundary_start); - if pre_dirty_end > boundary_start { - let len = (pre_dirty_end - boundary_start) as usize; - debug_assert!( - len <= boundary_data.len(), - "boundary prefix ({len} bytes) exceeds downloaded boundary data ({} bytes)", - boundary_data.len() - ); - cleaner.add_data(&boundary_data[..len.min(boundary_data.len())]).await?; + // a) Stream boundary prefix directly from CAS to cleaner. + let prefix_end = region.dirty_start.max(boundary_start); + if prefix_end > boundary_start && boundary_end <= original_size { + let reconstructor = FileReconstructor::new(&cas_client, original_hash) + .with_byte_range(FileRange::new(boundary_start, prefix_end)); + let mut stream = reconstructor.reconstruct_to_stream(); + while let Some(chunk) = stream.next().await? { + cleaner.add_data(&chunk).await?; + } } // b) Dirty bytes from source, streamed in blocks. @@ -356,21 +320,14 @@ pub async fn upload_ranges( } } - // c) Boundary bytes AFTER the dirty range. - // If the dirty range doesn't extend to boundary_end, include the stable suffix. - // Cap at total_size so truncation doesn't leak original bytes beyond the cut point. - let effective_boundary_end = boundary_end.min(total_size); - let post_dirty_start = region.dirty_end.min(effective_boundary_end); - if post_dirty_start < effective_boundary_end { - let offset = (post_dirty_start - boundary_start) as usize; - let end = (effective_boundary_end - boundary_start) as usize; - debug_assert!( - end <= boundary_data.len(), - "boundary suffix end ({end}) out of range ({} bytes)", - boundary_data.len() - ); - if offset < boundary_data.len() { - cleaner.add_data(&boundary_data[offset..end.min(boundary_data.len())]).await?; + // c) Stream boundary suffix directly from CAS to cleaner. + let suffix_start = region.dirty_end.min(effective_boundary_end); + if suffix_start < effective_boundary_end && boundary_end <= original_size { + let reconstructor = FileReconstructor::new(&cas_client, original_hash) + .with_byte_range(FileRange::new(suffix_start, effective_boundary_end)); + let mut stream = reconstructor.reconstruct_to_stream(); + while let Some(chunk) = stream.next().await? { + cleaner.add_data(&chunk).await?; } } From b4984bf8ac38170c129e53b016945707711d8e2f Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 16 Mar 2026 22:58:22 +0100 Subject: [PATCH 08/38] fix: validate dirty_ranges preconditions at runtime, not just debug Replace debug_assert! with real errors for dirty_ranges validation (sorted, non-overlapping, non-empty intervals). Move the no-op early return before validation so empty ranges skip the checks. Add tests for overlapping, empty, and unsorted dirty ranges. --- xet_data/src/processing/range_upload.rs | 70 +++++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index dc267f5e3..bd730e816 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -71,20 +71,22 @@ pub async fn upload_ranges( dirty_source: &mut dyn ReadSeek, total_size: u64, ) -> Result { - // Precondition: dirty_ranges must be sorted, non-overlapping, and non-empty intervals. - debug_assert!( - dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0), - "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" - ); - debug_assert!( - dirty_ranges.iter().all(|&(s, e)| s < e), - "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" - ); - + // No changes: return original file as-is. if dirty_ranges.is_empty() && total_size == original_size { return Ok(XetFileInfo::new(original_hash.hex(), original_size)); } + if !dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0) { + return Err(DataProcessingError::InternalError(format!( + "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" + ))); + } + if !dirty_ranges.iter().all(|&(s, e)| s < e) { + return Err(DataProcessingError::InternalError(format!( + "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" + ))); + } + // 1. Fetch chunk hashes and reconstruction info in parallel. let (original_chunks, recon_result) = tokio::try_join!( cas_client.get_file_chunk_hashes(&original_hash), @@ -1143,4 +1145,52 @@ mod tests { } } } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_rejects_overlapping_dirty_ranges() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let data = random_data(60, 256 * 1024); + let hash = upload_file(&config, &data).await; + let size = data.len() as u64; + + let mut source = Cursor::new(&data); + let err = upload_ranges(config, cas_client, hash, size, &[(100, 300), (200, 400)], &mut source, size).await; + assert!(err.is_err(), "overlapping ranges should be rejected"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_rejects_empty_dirty_range() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let data = random_data(61, 256 * 1024); + let hash = upload_file(&config, &data).await; + let size = data.len() as u64; + + let mut source = Cursor::new(&data); + let err = upload_ranges(config, cas_client, hash, size, &[(100, 100)], &mut source, size).await; + assert!(err.is_err(), "empty range (start == end) should be rejected"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_rejects_unsorted_dirty_ranges() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let data = random_data(62, 256 * 1024); + let hash = upload_file(&config, &data).await; + let size = data.len() as u64; + + let mut source = Cursor::new(&data); + let err = upload_ranges(config, cas_client, hash, size, &[(300, 400), (100, 200)], &mut source, size).await; + assert!(err.is_err(), "unsorted ranges should be rejected"); + } } From e0db8b9870186a6c9533e0e17363fa8f13485565 Mon Sep 17 00:00:00 2001 From: Adrien Date: Tue, 17 Mar 2026 10:31:47 +0100 Subject: [PATCH 09/38] refactor: improve readability and error handling in range_upload - Extract helpers: build_dirty_regions, stream_cas_range, merge_or_push - Replace silent unwrap_or/clamps with explicit debug_assert or runtime errors - Add precondition validation (dirty_range > total_size) - Add test: truncation on chunk boundary, no-op, dirty range past total_size, build_dirty_regions coalescing, inconsistent chunk data - Remove duplicate tests (truncation/append hash-only variants) - Improve comments: ASCII diagrams on tests, clearer doc on truncation/append - Document SHA-256 limitation in upload_ranges doc - Move test helpers to bottom of test module --- xet_data/src/processing/range_upload.rs | 811 +++++++++++++---------- xet_pkg/src/xet_session/upload_commit.rs | 2 +- 2 files changed, 474 insertions(+), 339 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index bd730e816..ae0a48c74 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -53,15 +53,38 @@ struct ComposedRegion { /// chunks) are re-uploaded; stable regions between and around dirty ranges are /// reused from the original file's reconstruction plan. /// +/// # When to use +/// +/// - **Mid-file edit**: pass the modified byte ranges in `dirty_ranges`, same `total_size`. +/// - **Append**: pass `dirty_ranges` covering the written bytes (or empty), `total_size > original_size`. The last +/// original chunk is automatically re-chunked with the appended data. +/// - **Truncation**: pass `dirty_ranges = &[]`, `total_size < original_size`. The boundary chunk at the cut point is +/// automatically re-uploaded. +/// - **No change**: pass `dirty_ranges = &[]`, `total_size == original_size`. Returns the original hash immediately (no +/// CAS calls). +/// +/// `dirty_ranges` can be empty when only the file size changed (truncation or +/// append via ftruncate). The function adds implicit dirty ranges as needed. +/// /// # Arguments /// /// * `config` - Translator configuration for creating upload sessions. /// * `cas_client` - CAS client for fetching original file metadata and downloading boundary chunks. /// * `original_hash` - Merkle hash of the original file in CAS. /// * `original_size` - Size of the original file in bytes. -/// * `dirty_ranges` - Sorted, non-overlapping `(start, end)` byte ranges that were modified. -/// * `dirty_source` - Seekable reader for the dirty bytes (e.g. a staging file). -/// * `total_size` - Total size of the modified file (may be larger than `original_size` for appends). +/// * `dirty_ranges` - Sorted, non-overlapping `(start, end)` byte ranges that were modified. Must not overlap and must +/// be in ascending order. Can be empty for pure size changes. +/// * `dirty_source` - Seekable reader positioned over the full modified file (e.g. a staging file). Only bytes within +/// dirty ranges (and the append region) are read. +/// * `total_size` - Total size of the modified file. Compared to `original_size` to detect append (`total_size > +/// original_size`) or truncation (`total_size < original_size`). +/// +/// # Limitations +/// +/// The composed file has no SHA-256 metadata (`metadata_ext = None`), since recomputing +/// it would require reading the full file. This means `upload_ranges` is only suitable +/// for contexts that don't require SHA-256 verification (e.g. HF buckets, xet-native +/// repos), not for Git LFS-backed repos that verify SHA-256 on download. pub async fn upload_ranges( config: Arc, cas_client: Arc, @@ -76,24 +99,34 @@ pub async fn upload_ranges( return Ok(XetFileInfo::new(original_hash.hex(), original_size)); } + // Ranges must be in ascending order with no overlaps. if !dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0) { return Err(DataProcessingError::InternalError(format!( "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" ))); } + // Each range must cover at least one byte. if !dirty_ranges.iter().all(|&(s, e)| s < e) { return Err(DataProcessingError::InternalError(format!( "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" ))); } + // No range may extend past the end of the file. + if let Some(&(_, last_end)) = dirty_ranges.last() + && last_end > total_size + { + return Err(DataProcessingError::InternalError(format!( + "dirty_range end ({last_end}) exceeds total_size ({total_size})" + ))); + } // 1. Fetch chunk hashes and reconstruction info in parallel. let (original_chunks, recon_result) = tokio::try_join!( cas_client.get_file_chunk_hashes(&original_hash), cas_client.get_file_reconstruction_info(&original_hash), )?; - - let (original_mdb, _) = recon_result + let original_mdb = recon_result + .map(|(mdb, _)| mdb) .ok_or_else(|| DataProcessingError::InternalError("no reconstruction info for original file".into()))?; // 2. Map chunks to cumulative byte offsets. @@ -109,173 +142,96 @@ pub async fn upload_ranges( // | +---------- end of chunk[0] = start of chunk[1] // +-------------- start of chunk[0] let mut chunk_offsets: Vec = Vec::with_capacity(original_chunks.len() + 1); + let mut offset = 0u64; chunk_offsets.push(0); for (_, size) in &original_chunks { - chunk_offsets.push(chunk_offsets.last().unwrap() + size); + offset += size; + chunk_offsets.push(offset); } // 3. Build effective dirty ranges: start from caller's ranges, then handle truncation/append. let mut effective_ranges: Vec<(u64, u64)> = dirty_ranges.to_vec(); - // For truncation: the boundary chunk at the cut point must be re-uploaded as a partial chunk. - // Find the last full chunk before total_size and add [last_full_chunk_end, total_size) as dirty. - // - // Scenario: file truncated from 450 bytes to 250 bytes. - // Original chunks: [100, 200, 150] --> chunk_offsets = [0, 100, 300, 450] - // ^ ^ ^ ^ ^ ^ ^ - // - // total_size = 250 falls inside chunk[1] (which spans [100, 300)) - // - // last_full = rposition of offsets <= 250 - // = index 1 (because chunk_offsets[1] = 100 <= 250) - // boundary = chunk_offsets[1] = 100 - // - // Since boundary (100) < total_size (250), we have a partial chunk: - // trunc_range = (100, 250) <-- this part of chunk[1] must be re-uploaded - // - // Then we truncate: - // original_chunks keeps [chunk[0], chunk[1]] and discards chunk[2] - // chunk_offsets becomes [0, 100, 300] (stops at chunk[1]'s end) - // - // Visual before: - // file: [====chunk[0]====][========chunk[1]========][====chunk[2]====] - // bytes: 0 100 300 450 - // - // Visual after (with truncation to 250): - // dirty: [== re-upload ==] - // stable: [====chunk[0]====][stable part] - // bytes: 0 100 250 + let num_chunks = original_chunks.len(); + // Number of original chunks to keep in the final composition. + let mut compose_num_chunks = num_chunks; + if total_size < original_size { + // Truncation: when the cut point falls mid-chunk, we can't reuse that chunk + // (CAS chunks are immutable). We re-upload bytes from the last full chunk + // boundary up to total_size, and only keep chunks entirely before the cut. + // + // Example: truncate from 450 to 250 bytes. + // + // chunk[0]=[0,100) chunk[1]=[100,300) chunk[2]=[300,450) + // ^--- cut at 250 falls here + // + // chunk[0]: fully before cut -> reuse (stable) + // chunk[1]: partially before -> re-upload bytes [100, 250) + // chunk[2]: fully after cut -> discard let last_full = chunk_offsets.iter().rposition(|&o| o <= total_size).unwrap_or(0); + compose_num_chunks = last_full; let boundary = chunk_offsets[last_full]; if boundary < total_size { - // Partial chunk [boundary, total_size) needs re-upload from the dirty source. - let trunc_range = (boundary, total_size); - // Merge with last dirty range if they overlap/touch. - if let Some(last) = effective_ranges.last_mut() { - if last.1 >= boundary { - last.1 = last.1.max(total_size); - } else { - effective_ranges.push(trunc_range); - } - } else { - effective_ranges.push(trunc_range); - } + // Cut falls mid-chunk: re-upload [boundary, total_size) from the dirty source. + // If boundary == total_size, the cut is exactly on a chunk boundary and + // all kept chunks are complete, so no re-upload is needed. + merge_or_push(&mut effective_ranges, (boundary, total_size)); } - // Remember the truncation point for composition (step 6). - // We keep chunk_offsets and original_chunks unmodified for boundary downloads. } - // If the file grew, ensure the dirty region starts at original_size so the appended - // bytes are read from the staging file. The last original chunk (EOF-terminated) will - // be included via a first_chunk adjustment below, and its bytes will come from CAS - // via the boundary prefix mechanism (not from the staging file, which is sparse). - // - // Scenario: file grew from 450 bytes to 550 bytes - // - // Before: - // file: [====chunk[0]====][========chunk[1]========][====chunk[2]====] - // bytes: 0 100 300 450 - // - // After (append 100 bytes): - // dirty_start = 450 (original_size), first_chunk backed up to include chunk[2] - // boundary downloads [300, 450) from CAS ← last chunk data - // prefix feeds [300, 450) from CAS ← boundary prefix mechanism - // dirty reads [450, 550) from staging ← appended data - // cleaner re-chunks [300, 550) together ← canonical CDC boundaries if total_size > original_size { - let append_start = original_size; - // Merge with last dirty range if they overlap/touch, otherwise add a new range. - // Pull start backward to original_size if needed (e.g. caller passed a dirty range - // starting after original_size, like a seek-past-EOF write). - if let Some(last) = effective_ranges.last_mut() { - if last.1 >= append_start { - last.0 = last.0.min(append_start); - last.1 = last.1.max(total_size); - } else { - effective_ranges.push((append_start, total_size)); - } - } else { - effective_ranges.push((append_start, total_size)); - } + // Append: add an implicit dirty range for the new bytes. The last original chunk + // (EOF-terminated) will be included via a first_chunk adjustment below, and its + // bytes will come from CAS via the boundary prefix mechanism. + // + // Example: file grew from 450 to 550 bytes. + // The last chunk [300,450) gets re-chunked together with appended bytes [450,550). + // Boundary prefix downloads [300,450) from CAS, dirty reads [450,550) from staging. + merge_or_push(&mut effective_ranges, (original_size, total_size)); } - let num_chunks = original_chunks.len(); - // For truncation, limit the composition to chunks that fit within total_size. - let compose_num_chunks = if total_size < original_size { - chunk_offsets.iter().rposition(|&o| o <= total_size).unwrap_or(0) - } else { - num_chunks - }; - // Note: if effective_ranges is empty here, it means pure truncation (no dirty ranges, // file shrunk). We still proceed to compose a new file from the truncated chunk set. - // 4. Map dirty byte ranges to chunk-aligned boundaries, then coalesce overlapping regions. + // 4. Expand dirty byte ranges to chunk-aligned boundaries. // - // Example: dirty range [150, 350), chunks [0..3] with offsets [0, 100, 300, 450] - // dirty_start = 150, dirty_end = 350 - // 150 falls in chunk[1] (offset 100..300), so first_chunk = 1 - // 350 falls in chunk[2] (offset 300..450), so last_chunk = 3 (exclusive) - // This means chunks [1, 2] must be re-uploaded (the region spans these chunks) - let dirty_regions = { - let mut raw = Vec::with_capacity(effective_ranges.len()); - for &(dirty_start, dirty_end) in &effective_ranges { - // Find the first chunk whose end offset exceeds dirty_start. - let mut first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start).min(num_chunks); - - // For append regions (dirty_start >= original_size), include the last original - // chunk so it gets re-chunked with the appended data. The last chunk was - // terminated by EOF (not by the rolling hash), so its boundary is artificial. - // The boundary prefix mechanism will download its bytes from CAS. - if total_size > original_size && dirty_start >= original_size && first_chunk > 0 { - first_chunk -= 1; - } - - // Find the last chunk (exclusive) that starts before dirty_end. - let last_chunk = (0..num_chunks) - .rev() - .find(|&i| chunk_offsets[i] < dirty_end.min(total_size).min(original_size)) - .map(|i| i + 1) - .unwrap_or(first_chunk); - raw.push(DirtyRegion { - dirty_start, - dirty_end, - first_chunk, - last_chunk, - }); - } - - // Coalesce dirty regions whose chunk ranges overlap or are adjacent. - // This prevents uploading the same boundary chunks twice. - // - // Scenario: two dirty ranges that span overlapping chunks: - // Region 1: [150, 250), chunks [1..2] - // Region 2: [250, 350), chunks [1..3] - // After merging: [150, 350), chunks [1..3] (upload once, not twice) - let mut merged: Vec = Vec::with_capacity(raw.len()); - for region in raw { - if let Some(last) = merged.last_mut() - && region.first_chunk <= last.last_chunk - { - // Overlap detected: extend the last region to cover both - last.dirty_end = last.dirty_end.max(region.dirty_end); - last.last_chunk = last.last_chunk.max(region.last_chunk); - continue; - } - merged.push(region); - } - merged - }; + // A dirty range rarely starts/ends on a chunk boundary. Since CAS chunks are + // atomic (can't reuse half a chunk), we expand each range to cover every chunk + // it touches. Adjacent/overlapping regions are then coalesced. + // + // chunk[0]=[0,100) chunk[1]=[100,300) chunk[2]=[300,450) + // + // dirty bytes [150, 350) + // ^ ^ + // | +-- inside chunk[2] + // +------- inside chunk[1] + // + // -> expand to chunks [1, 3) (chunks 1 and 2 must be re-uploaded) + let dirty_regions = build_dirty_regions(&effective_ranges, &chunk_offsets, num_chunks, original_size, total_size)?; // 5. Process each dirty region: download boundary, stream dirty bytes, upload. Collect the resulting middle file // infos and chunk hashes. A single upload session is shared across all dirty regions. let session = FileUploadSession::new(config.clone(), None).await?; - let mut uploaded_regions: Vec = Vec::new(); + let mut uploaded_regions: Vec = Vec::with_capacity(dirty_regions.len()); for region in dirty_regions { - let boundary_start = chunk_offsets.get(region.first_chunk).copied().unwrap_or(original_size); - let boundary_end = chunk_offsets.get(region.last_chunk).copied().unwrap_or(original_size); + let boundary_start = *chunk_offsets.get(region.first_chunk).ok_or_else(|| { + DataProcessingError::InternalError(format!( + "first_chunk {} out of bounds ({})", + region.first_chunk, + chunk_offsets.len() + )) + })?; + let boundary_end = *chunk_offsets.get(region.last_chunk).ok_or_else(|| { + DataProcessingError::InternalError(format!( + "last_chunk {} out of bounds ({})", + region.last_chunk, + chunk_offsets.len() + )) + })?; + debug_assert!(region.dirty_start >= boundary_start, "dirty_start before boundary_start"); + debug_assert!(region.dirty_end <= total_size, "dirty_end exceeds total_size"); // The cleaner processes a "middle" file that spans [boundary_start, middle_end). // We stream it in three parts directly, without buffering boundary data: @@ -294,25 +250,17 @@ pub async fn upload_ranges( let mut cleaner = session.start_clean(None, middle_size, Sha256Policy::Skip, Ulid::new()).await; - // a) Stream boundary prefix directly from CAS to cleaner. - let prefix_end = region.dirty_start.max(boundary_start); - if prefix_end > boundary_start && boundary_end <= original_size { - let reconstructor = FileReconstructor::new(&cas_client, original_hash) - .with_byte_range(FileRange::new(boundary_start, prefix_end)); - let mut stream = reconstructor.reconstruct_to_stream(); - while let Some(chunk) = stream.next().await? { - cleaner.add_data(&chunk).await?; - } + // a) Boundary prefix: stable bytes before the dirty range. + if region.dirty_start > boundary_start && boundary_end <= original_size { + stream_cas_range(&cas_client, original_hash, boundary_start, region.dirty_start, &mut cleaner).await?; } // b) Dirty bytes from source, streamed in blocks. // TODO: seek/read_exact are blocking I/O in an async context. Acceptable for local // files (<1ms per 4MB block) but consider block_in_place for network-backed sources. - let read_start = region.dirty_start.max(boundary_start); - let read_end = region.dirty_end.min(total_size); - if read_end > read_start { - dirty_source.seek(SeekFrom::Start(read_start))?; - let mut remaining = (read_end - read_start) as usize; + if region.dirty_end > region.dirty_start { + dirty_source.seek(SeekFrom::Start(region.dirty_start))?; + let mut remaining = (region.dirty_end - region.dirty_start) as usize; let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining)]; while remaining > 0 { let to_read = buf.len().min(remaining); @@ -322,15 +270,10 @@ pub async fn upload_ranges( } } - // c) Stream boundary suffix directly from CAS to cleaner. + // c) Boundary suffix: stable bytes after the dirty range. let suffix_start = region.dirty_end.min(effective_boundary_end); if suffix_start < effective_boundary_end && boundary_end <= original_size { - let reconstructor = FileReconstructor::new(&cas_client, original_hash) - .with_byte_range(FileRange::new(suffix_start, effective_boundary_end)); - let mut stream = reconstructor.reconstruct_to_stream(); - while let Some(chunk) = stream.next().await? { - cleaner.add_data(&chunk).await?; - } + stream_cas_range(&cas_client, original_hash, suffix_start, effective_boundary_end, &mut cleaner).await?; } let (info, chunks, _metrics) = cleaner.finish().await?; @@ -338,6 +281,9 @@ pub async fn upload_ranges( } // Checkpoint: flush xorbs without consuming the session, then retrieve MDBFileInfos. + // TODO: the middle files are registered in the shard as real files, but nobody will + // ever reference them. Check if GC cleans up unreferenced file entries, or find a way + // to retrieve segments from the session without persisting them to the shard. session.checkpoint().await?; let middle_file_infos = session.file_info_list().await?; @@ -346,12 +292,10 @@ pub async fn upload_ranges( // so two regions with identical content produce only ONE MDBFileInfo entry. // This is correct: same hash = same bytes = same chunks = same segments, // so we clone the same MDBFileInfo for all regions sharing that hash. - let mut mdb_by_hash: HashMap = HashMap::new(); - for mdb in middle_file_infos { - mdb_by_hash.insert(mdb.metadata.file_hash, mdb); - } + let mdb_by_hash: HashMap = + middle_file_infos.into_iter().map(|mdb| (mdb.metadata.file_hash, mdb)).collect(); - let mut composed_regions: Vec = Vec::new(); + let mut composed_regions: Vec = Vec::with_capacity(uploaded_regions.len()); for uploaded in uploaded_regions { let middle_hash = MerkleHash::from_hex(uploaded.info.hash())?; let mdb = mdb_by_hash.get(&middle_hash).cloned().ok_or_else(|| { @@ -383,31 +327,36 @@ pub async fn upload_ranges( let mut chunk_cursor = 0usize; let mut seg_cursor = 0usize; - for cr in &composed_regions { + for composed in &composed_regions { // Stable region before this dirty region. - if cr.region.first_chunk > chunk_cursor { - let (segs, vers) = - extract_segments(&original_mdb, &original_chunks, chunk_cursor, cr.region.first_chunk, &mut seg_cursor); - all_chunks.extend_from_slice(&original_chunks[chunk_cursor..cr.region.first_chunk]); - all_segments.extend(segs); - all_verification.extend(vers); + if composed.region.first_chunk > chunk_cursor { + let (segments, verification_hashes) = extract_segments( + &original_mdb, + &original_chunks, + chunk_cursor, + composed.region.first_chunk, + &mut seg_cursor, + ); + all_chunks.extend_from_slice(&original_chunks[chunk_cursor..composed.region.first_chunk]); + all_segments.extend(segments); + all_verification.extend(verification_hashes); } // Middle (dirty) region. - all_chunks.extend_from_slice(&cr.chunks); - all_segments.extend_from_slice(&cr.mdb.segments); - all_verification.extend_from_slice(&cr.mdb.verification); + all_chunks.extend_from_slice(&composed.chunks); + all_segments.extend_from_slice(&composed.mdb.segments); + all_verification.extend_from_slice(&composed.mdb.verification); - chunk_cursor = cr.region.last_chunk; + chunk_cursor = composed.region.last_chunk; } // Stable suffix after the last dirty region. if chunk_cursor < compose_num_chunks { - let (segs, vers) = + let (segments, verification_hashes) = extract_segments(&original_mdb, &original_chunks, chunk_cursor, compose_num_chunks, &mut seg_cursor); all_chunks.extend_from_slice(&original_chunks[chunk_cursor..compose_num_chunks]); - all_segments.extend(segs); - all_verification.extend(vers); + all_segments.extend(segments); + all_verification.extend(verification_hashes); } let combined_hash = file_hash(&all_chunks); @@ -446,6 +395,114 @@ pub async fn upload_ranges( Ok(XetFileInfo::new(combined_hash.hex(), total_size)) } +/// Stream a byte range from CAS into the cleaner. +async fn stream_cas_range( + cas_client: &Arc, + file_hash: MerkleHash, + start: u64, + end: u64, + cleaner: &mut super::SingleFileCleaner, +) -> Result<()> { + let reconstructor = FileReconstructor::new(cas_client, file_hash).with_byte_range(FileRange::new(start, end)); + let mut stream = reconstructor.reconstruct_to_stream(); + while let Some(chunk) = stream.next().await? { + cleaner.add_data(&chunk).await?; + } + Ok(()) +} + +/// Expand dirty byte ranges to chunk-aligned boundaries and coalesce overlapping regions. +/// +/// Each dirty range is mapped to the chunks it touches (since CAS chunks are atomic), +/// then adjacent/overlapping chunk ranges are merged to avoid uploading the same +/// boundary chunks twice. +fn build_dirty_regions( + effective_ranges: &[(u64, u64)], + chunk_offsets: &[u64], + num_chunks: usize, + original_size: u64, + total_size: u64, +) -> Result> { + let mut raw = Vec::with_capacity(effective_ranges.len()); + for &(dirty_start, dirty_end) in effective_ranges { + // Find the first chunk whose end offset exceeds dirty_start. + let mut first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start); + debug_assert!(first_chunk <= num_chunks, "first_chunk {first_chunk} out of bounds ({num_chunks} chunks)"); + + // For append regions (dirty_start >= original_size), include the last original + // chunk so it gets re-chunked with the appended data. The last chunk was + // terminated by EOF (not by the rolling hash), so its boundary is artificial. + // The boundary prefix mechanism will download its bytes from CAS. + if total_size > original_size && dirty_start >= original_size && first_chunk > 0 { + first_chunk -= 1; + } + + // Find the last chunk (exclusive) that starts before dirty_end. + debug_assert!(dirty_end <= total_size, "dirty_end ({dirty_end}) exceeds total_size ({total_size})"); + let clamped_end = dirty_end.min(original_size); + let last_chunk = (0..num_chunks) + .rev() + .find(|&i| chunk_offsets[i] < clamped_end) + .map(|i| i + 1) + .ok_or_else(|| { + DataProcessingError::InternalError(format!( + "no chunk starts before clamped_end ({clamped_end}), chunks may be inconsistent" + )) + })?; + raw.push(DirtyRegion { + dirty_start, + dirty_end, + first_chunk, + last_chunk, + }); + } + + // Coalesce dirty regions whose chunk ranges overlap or are adjacent. + // This prevents uploading the same boundary chunks twice. + let mut merged: Vec = Vec::with_capacity(raw.len()); + for region in raw { + if let Some(last) = merged.last_mut() + && region.first_chunk <= last.last_chunk + { + last.dirty_end = last.dirty_end.max(region.dirty_end); + last.last_chunk = last.last_chunk.max(region.last_chunk); + continue; + } + merged.push(region); + } + Ok(merged) +} + +/// Merge `range` with the last element of `ranges` if they overlap or touch, +/// otherwise append it. +/// +/// Three cases: +/// - **Overlap** (last.1 >= range.0): last and range share bytes +/// - **Touch** (last.1 == range.0): last ends exactly where range starts +/// - **Gap**: last ends before range starts, keep both separate +/// +/// ```text +/// overlap: last [=====] touch: last [=====] +/// range [=====] range [=====] +/// result [=========] result [===========] +/// +/// gap: last [=====] +/// range [=====] +/// result [=====] [=====] (two separate entries) +/// ``` +fn merge_or_push(ranges: &mut Vec<(u64, u64)>, range: (u64, u64)) { + if let Some(last) = ranges.last_mut() { + // Overlap or touch: merge by expanding last's bounds + if last.1 >= range.0 { + last.0 = last.0.min(range.0); + last.1 = last.1.max(range.1); + return; + } + } + // Gap: append as separate entry + ranges.push(range); +} + /// Extract segments and verification entries for chunks `[chunk_start, chunk_end)` /// from the original reconstruction plan, truncating segments at boundaries. /// @@ -469,27 +526,42 @@ fn extract_segments( .map(|s| (s.chunk_index_end - s.chunk_index_start) as usize) .sum(); + // Walk segments starting from seg_cursor, extracting the overlap with [chunk_start, chunk_end). + // + // Example: segments cover chunks [0,3), [3,7), [7,10). We want chunks [2, 8). + // seg[0]: covers [0,3), overlap with [2,8) = [2,3) -> truncate to 1 chunk + // seg[1]: covers [3,7), overlap with [2,8) = [3,7) -> keep whole segment + // seg[2]: covers [7,10), overlap with [2,8) = [7,8) -> truncate to 1 chunk for seg in &original_mdb.segments[*seg_cursor..] { let seg_count = (seg.chunk_index_end - seg.chunk_index_start) as usize; let seg_end = chunk_cursor + seg_count; - // Past the requested range: stop. if chunk_cursor >= chunk_end { break; } + // Compute the overlap between this segment and the requested range. let overlap_start = chunk_cursor.max(chunk_start); let overlap_end = seg_end.min(chunk_end); if overlap_start < overlap_end { + // Truncate the segment to only cover the overlapping chunks. let count = overlap_end - overlap_start; let mut truncated = seg.clone(); truncated.chunk_index_start += (overlap_start - chunk_cursor) as u32; truncated.chunk_index_end = truncated.chunk_index_start + count as u32; - let bytes: u64 = original_chunks[overlap_start..overlap_end].iter().map(|(_, s)| s).sum(); + let overlap = &original_chunks[overlap_start..overlap_end]; + let mut bytes = 0u64; + let mut hashes = Vec::with_capacity(overlap.len()); + for &(hash, size) in overlap { + bytes += size; + hashes.push(hash); + } + // u32 cast: unpacked_segment_bytes is u32 in the shard format. + // Safe because CDC parameters prevent segments from exceeding u32::MAX. truncated.unpacked_segment_bytes = bytes as u32; segments.push(truncated); - let hashes: Vec = original_chunks[overlap_start..overlap_end].iter().map(|(h, _)| *h).collect(); + // Recompute the verification hash for the truncated chunk range. verification.push(FileVerificationEntry::new(range_hash_from_chunks(&hashes))); } @@ -520,16 +592,9 @@ mod tests { use crate::processing::file_download_session::FileDownloadSession; use crate::processing::file_upload_session::FileUploadSession; - /// Generate pseudo-random data that produces multiple CDC chunks. - fn random_data(seed: u64, len: usize) -> Vec { - (0..len) - .map(|i| { - let x = (i as u64).wrapping_add(seed).wrapping_mul(2654435761); - (x >> 16) as u8 - }) - .collect() - } - + // original: [=========================== 256 KB ===========================] + // dirty: [== 1 KB ===] + // result: [===stable===][re-uploaded][============stable=================] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_upload_ranges_mid_file_edit() { let server = LocalTestServerBuilder::new().start().await; @@ -579,9 +644,9 @@ mod tests { // 3. Download and verify the composed file. let composed_hash = MerkleHash::from_hex(result.hash()).unwrap(); let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); - let xfi = crate::processing::XetFileInfo::new(composed_hash.hex(), total_size); + let file_info = crate::processing::XetFileInfo::new(composed_hash.hex(), total_size); let out_path = base_dir.path().join("output"); - session.download_file(&xfi, &out_path, Ulid::new()).await.unwrap(); + session.download_file(&file_info, &out_path, Ulid::new()).await.unwrap(); let downloaded = std::fs::read(&out_path).unwrap(); assert_eq!(downloaded.len(), modified_data.len()); @@ -592,28 +657,9 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } - /// Helper to upload a file and return its hash. - async fn upload_file(config: &Arc, data: &[u8]) -> MerkleHash { - let session = FileUploadSession::new(config.clone(), None).await.unwrap(); - let mut cleaner = session - .start_clean(Some("test".into()), data.len() as u64, Sha256Policy::Skip, Ulid::new()) - .await; - cleaner.add_data(data).await.unwrap(); - let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); - session.finalize().await.unwrap(); - MerkleHash::from_hex(xfi.hash()).unwrap() - } - - /// Helper to download a file and return its contents. - async fn download_file(config: &Arc, hash: MerkleHash, size: u64) -> Vec { - let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); - let xfi = crate::processing::XetFileInfo::new(hash.hex(), size); - let dir = TempDir::new().unwrap(); - let out = dir.path().join("out"); - session.download_file(&xfi, &out, Ulid::new()).await.unwrap(); - std::fs::read(&out).unwrap() - } - + // original: [=========================== 256 KB ===========================] + // result: [========= 100 KB =========] + // ^ cut here (mid-chunk) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_upload_ranges_truncation() { let server = LocalTestServerBuilder::new().start().await; @@ -652,6 +698,9 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } + // original: [======== 100 KB ========] + // result: [======== 100 KB ========][== 50 KB appended ==] + // ^ last chunk re-chunked with append #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_upload_ranges_append() { let server = LocalTestServerBuilder::new().start().await; @@ -691,6 +740,10 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } + // original: [=========================== 256 KB ==============================] + // dirty: [4K] + // result: [re-uploaded][=================stable=============================] + // ^ no stable prefix #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_upload_ranges_at_file_start() { let server = LocalTestServerBuilder::new().start().await; @@ -731,6 +784,9 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } + // original: [=========================== 256 KB ===========================] + // dirty: [2K] [2K] + // result: [s][re-up][=======stable========][re-up][========stable========] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_upload_ranges_multiple_regions() { let server = LocalTestServerBuilder::new().start().await; @@ -772,55 +828,17 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } - // ── Data integrity regression tests ────────────────────────────── - // - // All corruption scenarios share a single LocalTestServer to keep - // test runtime reasonable (~2s total instead of ~1s per scenario). - - /// Helper: upload original, apply modifications via upload_ranges, download composed, verify - /// both content and hash equality with a clean upload of the expected data. - async fn assert_range_edit( - config: &Arc, - cas_client: &Arc, - original_data: &[u8], - expected: &[u8], - dirty_ranges: &[(u64, u64)], - total_size: u64, - ) { - let original_hash = upload_file(config, original_data).await; - let mut source = Cursor::new(expected); - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_data.len() as u64, - dirty_ranges, - &mut source, - total_size, - ) - .await - .unwrap(); - - assert_eq!(result.file_size(), total_size, "file size mismatch"); - let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; - assert_eq!(downloaded.len(), expected.len(), "downloaded length mismatch"); - assert_eq!(&downloaded[..], &expected[..], "content mismatch"); - - // Hash must match a clean upload of the same content. - let clean_hash = upload_file(config, expected).await; - assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); - } - + // original: [chunk0][chunk1][chunk2][chunk3][...] + // dirty: [0xBB ] [0xBB ] + // ^--- same content, same hash -> dedup collision #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_two_regions_identical_hash_collision() { - // Regression test: Two dirty regions that produce the same content (and thus the same hash) - // must not collide in the mdb_by_hash mapping. Before the fix, the second region would - // panic on remove(0) from an empty Vec because the shard manager deduplicates MDBFileInfo - // entries by file_hash (BTreeMap). + // Two dirty regions that produce the same content (and thus the same hash) + // must not collide in the mdb_by_hash mapping. The shard manager deduplicates + // MDBFileInfo entries by file_hash, so both regions share the same entry. // - // To guarantee a hash collision we use chunk-aligned dirty ranges with identical content: - // both regions span the same chunk boundaries (relative to their CDC context) and contain - // the same bytes, so the cleaner produces identical hashes. + // We use chunk-aligned dirty ranges with identical fill to guarantee + // the cleaner produces identical hashes for both regions. let server = LocalTestServerBuilder::new().start().await; let base_dir = TempDir::new().unwrap(); let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); @@ -874,27 +892,29 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } - /// Regression test: truncation must produce the exact same hash as a clean upload - /// of the truncated content. Before the fix, boundary bytes beyond total_size leaked - /// into the cleaner, producing extra chunks and a wrong file hash. + // original: [chunk0][chunk1][chunk2][...] + // result: [chunk0] + // ^ cut exactly on chunk boundary, no re-upload needed #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_truncation_hash_matches_clean_upload() { + async fn test_truncation_on_chunk_boundary() { let server = LocalTestServerBuilder::new().start().await; let base_dir = TempDir::new().unwrap(); let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - // Upload a 256 KB file, then truncate to 100 KB via upload_ranges. - let original_data = random_data(48, 256 * 1024); + let original_data = random_data(99, 256 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - let truncated_size = 100_000u64; + let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); + assert!(chunks.len() >= 2, "need at least 2 chunks for this test"); + + // Truncate exactly at the boundary after the first chunk. + let truncated_size: u64 = chunks[0].1; let truncated_data = &original_data[..truncated_size as usize]; - // upload_ranges with truncation let mut source = Cursor::new(truncated_data); - let range_result = upload_ranges( + let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, @@ -906,62 +926,18 @@ mod tests { .await .unwrap(); - // Clean upload of the same truncated content - let clean_hash = upload_file(&config, truncated_data).await; - - assert_eq!( - range_result.hash(), - clean_hash.hex(), - "truncation hash ({}) does not match clean upload hash ({})", - range_result.hash(), - clean_hash.hex() - ); - } - - /// Regression test: append must produce the exact same hash as a clean upload - /// of the full content. Before the fix, the last original chunk (EOF-terminated) - /// was reused verbatim instead of being re-chunked with the appended data. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_append_hash_matches_clean_upload() { - let server = LocalTestServerBuilder::new().start().await; - let base_dir = TempDir::new().unwrap(); - let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); - let cas_client: Arc = Arc::new(server); - - let original_data = random_data(49, 100 * 1024); - let original_hash = upload_file(&config, &original_data).await; - let original_size = original_data.len() as u64; - - let mut full_data = original_data.clone(); - full_data.extend(random_data(100, 50 * 1024)); - let total_size = full_data.len() as u64; - - let mut source = Cursor::new(&full_data); - let range_result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_size, - &[(original_size, total_size)], - &mut source, - total_size, - ) - .await - .unwrap(); + assert_eq!(result.file_size(), truncated_size); - let clean_hash = upload_file(&config, &full_data).await; + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; + assert_eq!(&downloaded[..], truncated_data); - assert_eq!( - range_result.hash(), - clean_hash.hex(), - "append hash ({}) does not match clean upload hash ({})", - range_result.hash(), - clean_hash.hex() - ); + let clean_hash = upload_file(&config, truncated_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } - /// Regression test: a dirty range starting after original_size must not lose the - /// gap bytes [original_size, dirty_start). This simulates a seek-past-EOF write. + // original: [======== 100 KB ========] + // staging: [======== 100 KB ========][000][=4K written=] + // ^ gap (zeros from seek past EOF) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_append_with_gap_before_dirty_range() { let server = LocalTestServerBuilder::new().start().await; @@ -1010,10 +986,10 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } - /// Regression test: append with a sparse staging file (zeros where CAS data should be). - /// This simulates the real hf-mount scenario where the staging file is created with - /// set_len(original_size) and only appended bytes are written. The boundary prefix - /// mechanism must fetch the last chunk from CAS, not read zeros from the staging file. + // staging: [000000 (zeros) 000000][=== appended ===] + // CAS: [=== original data ==] + // result: [=== original data ==][=== appended ===] + // ^ boundary prefix must come from CAS, not from staging zeros #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_append_sparse_staging_file() { let server = LocalTestServerBuilder::new().start().await; @@ -1065,7 +1041,10 @@ mod tests { let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - // ── Truncation + overlapping dirty range ──────────────────── + // original: [========================= 256 KB =========================] + // dirty: [10K] + // result: [====== 90 KB ======][10K] + // 0 90K 100K ^ truncate here, dirty touches cut { let original = vec![0xAAu8; 256 * 1024]; let mut expected = original[..100_000].to_vec(); @@ -1073,7 +1052,9 @@ mod tests { assert_range_edit(&config, &cas_client, &original, &expected, &[(90_000, 100_000)], 100_000).await; } - // ── Full overwrite (no stable prefix or suffix) ───────────── + // original: [========= 128 KB =========] + // dirty: [========= 128 KB =========] + // result: [======= re-uploaded ======] (no stable regions) { let original = vec![0xAAu8; 128 * 1024]; let expected = vec![0xBBu8; 128 * 1024]; @@ -1081,7 +1062,9 @@ mod tests { assert_range_edit(&config, &cas_client, &original, &expected, &[(0, size)], size).await; } - // ── Three adjacent dirty ranges (coalescing) ──────────────── + // original: [===================== 256 KB =====================] + // dirty: [1K][1K][1K] + // ^-- coalesced into one region { let original = vec![0xAAu8; 256 * 1024]; let mut expected = original.clone(); @@ -1100,7 +1083,9 @@ mod tests { .await; } - // ── Append without explicit dirty range ───────────────────── + // original: [======== 100 KB ========] + // result: [======== 100 KB ========][== 50 KB ==] + // no dirty_ranges, only total_size > original_size { let original = vec![0xAAu8; 100 * 1024]; let mut expected = original.clone(); @@ -1109,7 +1094,9 @@ mod tests { assert_range_edit(&config, &cas_client, &original, &expected, &[], total).await; } - // ── Dirty range exactly on chunk boundary ─────────────────── + // original: [chunk0][chunk1][chunk2][...] + // dirty: [chunk2] + // ^ ^-- starts/ends on chunk boundary { let original: Vec = (0..256 * 1024) .map(|i: usize| { @@ -1146,6 +1133,44 @@ mod tests { } } + // No changes: dirty_ranges=[], total_size == original_size -> early return. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_noop_returns_original_hash() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let data = random_data(70, 256 * 1024); + let hash = upload_file(&config, &data).await; + let size = data.len() as u64; + + let mut source = Cursor::new(&data); + let result = upload_ranges(config, cas_client, hash, size, &[], &mut source, size) + .await + .unwrap(); + + assert_eq!(result.hash(), hash.hex()); + assert_eq!(result.file_size(), size); + } + + // dirty_range end > total_size -> rejected. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_rejects_dirty_range_past_total_size() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let data = random_data(71, 256 * 1024); + let hash = upload_file(&config, &data).await; + let size = data.len() as u64; + + let mut source = Cursor::new(&data); + let err = upload_ranges(config, cas_client, hash, size, &[(100, size + 1)], &mut source, size).await; + assert!(err.is_err(), "dirty range past total_size should be rejected"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_rejects_overlapping_dirty_ranges() { let server = LocalTestServerBuilder::new().start().await; @@ -1193,4 +1218,114 @@ mod tests { let err = upload_ranges(config, cas_client, hash, size, &[(300, 400), (100, 200)], &mut source, size).await; assert!(err.is_err(), "unsorted ranges should be rejected"); } + + #[test] + fn test_build_dirty_regions_coalesces_adjacent() { + // 5 chunks of 100 bytes each: offsets [0, 100, 200, 300, 400, 500] + let chunk_offsets = vec![0u64, 100, 200, 300, 400, 500]; + let num_chunks = 5; + let original_size = 500; + let total_size = 500; + + // Three adjacent dirty ranges, all inside chunk[2] = [200, 300). + let ranges = vec![(210u64, 230), (230, 250), (250, 270)]; + let regions = build_dirty_regions(&ranges, &chunk_offsets, num_chunks, original_size, total_size).unwrap(); + + // All three touch the same chunk, so they must coalesce into one region. + assert_eq!(regions.len(), 1, "expected 1 coalesced region, got {}", regions.len()); + assert_eq!(regions[0].dirty_start, 210); + assert_eq!(regions[0].dirty_end, 270); + } + + #[test] + fn test_build_dirty_regions_no_coalesce_when_separated() { + // 5 chunks of 100 bytes each. + let chunk_offsets = vec![0u64, 100, 200, 300, 400, 500]; + let num_chunks = 5; + let original_size = 500; + let total_size = 500; + + // Two dirty ranges in non-adjacent chunks: chunk[1] and chunk[3]. + let ranges = vec![(110u64, 130), (310, 330)]; + let regions = build_dirty_regions(&ranges, &chunk_offsets, num_chunks, original_size, total_size).unwrap(); + + assert_eq!(regions.len(), 2, "expected 2 separate regions, got {}", regions.len()); + } + + #[test] + fn test_build_dirty_regions_rejects_inconsistent_chunks() { + // chunk_offsets = [0, 100] but dirty range ends at 200 (clamped to original_size=100). + // No chunk has start < 100 except chunk[0] at offset 0... actually chunk[0] + // starts at 0 < 100, so that works. Use an empty chunk list instead. + let chunk_offsets = vec![0u64]; // 0 chunks, only the initial offset + let num_chunks = 0; + let original_size = 0; + let total_size = 100; + + let ranges = vec![(0u64, 100)]; + let result = build_dirty_regions(&ranges, &chunk_offsets, num_chunks, original_size, total_size); + assert!(result.is_err(), "should fail with inconsistent/empty chunk data"); + } + + // ── Helpers ────────────────────────────────────────────────────── + + fn random_data(seed: u64, len: usize) -> Vec { + (0..len) + .map(|i| { + let x = (i as u64).wrapping_add(seed).wrapping_mul(2654435761); + (x >> 16) as u8 + }) + .collect() + } + + async fn upload_file(config: &Arc, data: &[u8]) -> MerkleHash { + let session = FileUploadSession::new(config.clone(), None).await.unwrap(); + let mut cleaner = session + .start_clean(Some("test".into()), data.len() as u64, Sha256Policy::Skip, Ulid::new()) + .await; + cleaner.add_data(data).await.unwrap(); + let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + session.finalize().await.unwrap(); + MerkleHash::from_hex(xfi.hash()).unwrap() + } + + async fn download_file(config: &Arc, hash: MerkleHash, size: u64) -> Vec { + let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); + let xfi = crate::processing::XetFileInfo::new(hash.hex(), size); + let dir = TempDir::new().unwrap(); + let out = dir.path().join("out"); + session.download_file(&xfi, &out, Ulid::new()).await.unwrap(); + std::fs::read(&out).unwrap() + } + + async fn assert_range_edit( + config: &Arc, + cas_client: &Arc, + original_data: &[u8], + expected: &[u8], + dirty_ranges: &[(u64, u64)], + total_size: u64, + ) { + let original_hash = upload_file(config, original_data).await; + let mut source = Cursor::new(expected); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_data.len() as u64, + dirty_ranges, + &mut source, + total_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), total_size, "file size mismatch"); + let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded.len(), expected.len(), "downloaded length mismatch"); + assert_eq!(&downloaded[..], &expected[..], "content mismatch"); + + let clean_hash = upload_file(config, expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } } diff --git a/xet_pkg/src/xet_session/upload_commit.rs b/xet_pkg/src/xet_session/upload_commit.rs index 13af82348..19f988ea2 100644 --- a/xet_pkg/src/xet_session/upload_commit.rs +++ b/xet_pkg/src/xet_session/upload_commit.rs @@ -1275,7 +1275,7 @@ mod tests { commit.upload_file_blocking(Some("stream.bin".into()), data.len() as u64, Sha256Policy::Compute)?; let (hash, file_size) = runtime.external_run_async_task(async move { cleaner.add_data(data).await.unwrap(); - let (xfi, _) = cleaner.finish().await.unwrap(); + let (xfi, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); (xfi.hash, xfi.file_size) })?; let results = commit.commit_blocking()?; From 9f9ce99e35653a7e00827469845a2960b9871813 Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 19 Mar 2026 14:20:23 +0100 Subject: [PATCH 10/38] fix: read truncation boundary bytes from CAS instead of staging When upload_ranges is called for a pure truncation (dirty_ranges=[], total_size < original_size), the boundary chunk bytes were read from the staging file which may contain zeros if the file was never opened for write. The correct bytes live in CAS. Instead of adding the truncation range to effective_ranges (which causes the processing loop to read from staging), we now track the truncation boundary chunk separately and inject a DirtyRegion with an empty dirty span. The existing suffix CAS logic then reads [boundary, total_size) from CAS automatically. Also documents the invariant that effective_ranges must only contain ranges whose bytes are valid in dirty_source, and adds a TODO for a safer API using per-range AsyncRead streams. --- xet_data/src/processing/range_upload.rs | 163 ++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 8 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index ae0a48c74..a7ed5557c 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -74,8 +74,29 @@ struct ComposedRegion { /// * `original_size` - Size of the original file in bytes. /// * `dirty_ranges` - Sorted, non-overlapping `(start, end)` byte ranges that were modified. Must not overlap and must /// be in ascending order. Can be empty for pure size changes. -/// * `dirty_source` - Seekable reader positioned over the full modified file (e.g. a staging file). Only bytes within -/// dirty ranges (and the append region) are read. +/// * `dirty_source` - Seekable reader over the modified file (e.g. a staging file). Read in two +/// situations: (1) bytes within caller-provided `dirty_ranges`, and (2) the append region +/// `[original_size, total_size)` when `total_size > original_size` (including sparse gaps). +/// Bytes in the original range `[0, original_size)` outside `dirty_ranges` are never read from +/// `dirty_source`; they are reconstructed from CAS instead. +/// +/// # Important invariant +/// +/// `dirty_source` is only read for byte ranges explicitly listed in `dirty_ranges` plus the +/// append region beyond `original_size`. Internally needed ranges (e.g. truncation boundary +/// chunks within `[0, original_size)`) are always reconstructed from CAS. This means callers +/// do not need to populate `dirty_source` with original file content for regions they did not +/// modify. +/// +/// // TODO: the current API cannot enforce this invariant at the type level. `dirty_source` is a +/// // `ReadSeek` over the full file, but only a subset of it contains valid data. A safer API +/// // would accept a callback `Fn(Range) -> impl AsyncRead` (or a stream of byte chunks) +/// // that is called only for caller dirty ranges, making it impossible to accidentally read +/// // stale staging bytes. Returning a stream rather than `Vec` avoids buffering large +/// // ranges in memory. This would also remove the need for the `truncation_boundary` injection +/// // logic. Deferred because it would change the public API and impact existing callers +/// // (hf-mount, etc.). +/// /// * `total_size` - Total size of the modified file. Compared to `original_size` to detect append (`total_size > /// original_size`) or truncation (`total_size < original_size`). /// @@ -149,12 +170,17 @@ pub async fn upload_ranges( chunk_offsets.push(offset); } - // 3. Build effective dirty ranges: start from caller's ranges, then handle truncation/append. + // 3. Build effective dirty ranges: start from caller's ranges, then handle append. + // + // INVARIANT: effective_ranges must only contain ranges whose bytes are valid in + // dirty_source. Internally needed ranges (truncation boundary) are handled via + // injected DirtyRegions with empty dirty spans, so CAS provides the bytes. let mut effective_ranges: Vec<(u64, u64)> = dirty_ranges.to_vec(); let num_chunks = original_chunks.len(); // Number of original chunks to keep in the final composition. let mut compose_num_chunks = num_chunks; + let mut truncation_boundary: Option<(u64, usize)> = None; if total_size < original_size { // Truncation: when the cut point falls mid-chunk, we can't reuse that chunk @@ -173,10 +199,11 @@ pub async fn upload_ranges( compose_num_chunks = last_full; let boundary = chunk_offsets[last_full]; if boundary < total_size { - // Cut falls mid-chunk: re-upload [boundary, total_size) from the dirty source. - // If boundary == total_size, the cut is exactly on a chunk boundary and - // all kept chunks are complete, so no re-upload is needed. - merge_or_push(&mut effective_ranges, (boundary, total_size)); + // Cut falls mid-chunk: the partial chunk [boundary, total_size) must be + // re-uploaded. We track it here and inject a DirtyRegion after + // build_dirty_regions, rather than adding it to effective_ranges, + // because the bytes live in CAS (not in the caller's staging file). + truncation_boundary = Some((boundary, last_full)); } } if total_size > original_size { @@ -207,7 +234,26 @@ pub async fn upload_ranges( // +------- inside chunk[1] // // -> expand to chunks [1, 3) (chunks 1 and 2 must be re-uploaded) - let dirty_regions = build_dirty_regions(&effective_ranges, &chunk_offsets, num_chunks, original_size, total_size)?; + let mut dirty_regions = + build_dirty_regions(&effective_ranges, &chunk_offsets, num_chunks, original_size, total_size)?; + + // If truncation cuts mid-chunk and no caller dirty range already covers that + // chunk, inject a DirtyRegion with an empty dirty range. The processing loop's + // suffix logic will read [boundary, total_size) from CAS automatically. + if let Some((boundary, trunc_chunk)) = truncation_boundary { + let already_covered = dirty_regions + .iter() + .any(|r| r.first_chunk <= trunc_chunk && trunc_chunk < r.last_chunk); + if !already_covered { + dirty_regions.push(DirtyRegion { + dirty_start: boundary, + dirty_end: boundary, // empty: no staging bytes needed + first_chunk: trunc_chunk, + last_chunk: trunc_chunk + 1, + }); + dirty_regions.sort_by_key(|r| r.first_chunk); + } + } // 5. Process each dirty region: download boundary, stream dirty bytes, upload. Collect the resulting middle file // infos and chunk hashes. A single upload session is shared across all dirty regions. @@ -1267,6 +1313,107 @@ mod tests { assert!(result.is_err(), "should fail with inconsistent/empty chunk data"); } + // original: [=========================== 256 KB ===========================] + // staging: [0000000000000000000000000000] (all zeros, file never opened for write) + // result: [====== 100 KB from CAS =====] + // ^ cut here (mid-chunk) + // + // The boundary chunk bytes must come from CAS, not from the zero-filled staging. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_truncation_empty_staging() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = random_data(77, 256 * 1024); + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let truncated_size = 100_000u64; + + let zeros = vec![0u8; truncated_size as usize]; + let mut source = Cursor::new(&zeros); + + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[], + &mut source, + truncated_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), truncated_size); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; + assert_eq!(downloaded.len(), truncated_size as usize); + assert_eq!( + &downloaded[..], + &original_data[..truncated_size as usize], + "truncated content should match original CAS data, not staging zeros" + ); + + let clean_hash = upload_file(&config, &original_data[..truncated_size as usize]).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + + // original: [=========================== 256 KB ===========================] + // staging: [000000000000][0xBB][0000000] (zeros except the dirty range) + // ^ ^ + // 90K 95K (dirty from caller) + // result: [==CAS==][stg][===CAS===] + // ^ cut at 100K (mid-chunk) + // + // Dirty bytes [90K,95K) come from staging; boundary bytes from CAS. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_truncation_with_overlapping_dirty() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = random_data(88, 256 * 1024); + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let truncated_size = 100_000u64; + + let dirty_start = 90_000u64; + let dirty_end = 95_000u64; + + let mut expected = original_data[..truncated_size as usize].to_vec(); + expected[dirty_start as usize..dirty_end as usize].fill(0xBB); + + let mut staging = vec![0u8; truncated_size as usize]; + staging[dirty_start as usize..dirty_end as usize].fill(0xBB); + + let mut source = Cursor::new(&staging); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + &[(dirty_start, dirty_end)], + &mut source, + truncated_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), truncated_size); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; + assert_eq!(downloaded.len(), expected.len()); + assert_eq!(&downloaded[..], &expected[..], "dirty bytes should come from staging, boundary bytes from CAS"); + + let clean_hash = upload_file(&config, &expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + // ── Helpers ────────────────────────────────────────────────────── fn random_data(seed: u64, len: usize) -> Vec { From cf4275d91cb2d490bc94ac1cc12db3e0e03a52d9 Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 19 Mar 2026 15:18:28 +0100 Subject: [PATCH 11/38] refactor: replace ReadSeek with per-range AsyncRead in upload_ranges Replace the `dirty_ranges + dirty_source: &mut dyn ReadSeek` API with `dirty_inputs: Vec` where each input pairs a byte range with a `Pin>`. This enforces at the type level that only caller-provided ranges are read from the staging file, making it impossible to accidentally read stale bytes for internally added ranges (e.g. truncation boundary chunks). Key changes: - New `DirtyInput` struct exported from `xet_data::processing` - Processing loop consumes async readers in order, filling CAS gaps between inputs for bytes within the original file - Append region `[original_size, total_size)` is now the caller's responsibility (no longer added implicitly) - Validation rejects appends where dirty_inputs don't cover up to total_size - Validation rejects gaps beyond original_size between inputs - Removed `ReadSeek` trait and `merge_or_push` (no longer needed) - Removed `effective_ranges` indirection (use `dirty_ranges` directly) --- xet_data/src/processing/mod.rs | 2 +- xet_data/src/processing/range_upload.rs | 464 +++++++++++++----------- 2 files changed, 248 insertions(+), 218 deletions(-) diff --git a/xet_data/src/processing/mod.rs b/xet_data/src/processing/mod.rs index 256415785..f33d6ea46 100644 --- a/xet_data/src/processing/mod.rs +++ b/xet_data/src/processing/mod.rs @@ -17,7 +17,7 @@ mod xet_file; pub use file_cleaner::{Sha256Policy, SingleFileCleaner}; pub use file_download_session::FileDownloadSession; pub use file_upload_session::FileUploadSession; -pub use range_upload::upload_ranges; +pub use range_upload::{DirtyInput, upload_ranges}; pub use xet_core_structures::merklehash::ChunkHashList; pub use xet_file::XetFileInfo; diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index a7ed5557c..f238a4b8a 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; -use std::io::{Read, Seek, SeekFrom}; +use std::ops::Range; +use std::pin::Pin; use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncReadExt}; use tracing::{debug, info}; use ulid::Ulid; use xet_client::cas_client::Client; @@ -19,9 +21,14 @@ use super::file_cleaner::Sha256Policy; use super::file_upload_session::FileUploadSession; use crate::file_reconstruction::FileReconstructor; -/// Trait alias for a seekable byte source (e.g. `std::fs::File`). -pub trait ReadSeek: Read + Seek + Send {} -impl ReadSeek for T {} +/// A dirty byte range paired with an async reader that provides the modified bytes. +/// +/// Each `DirtyInput` represents a contiguous region of the file that was modified by the +/// caller. The `reader` must yield exactly `range.end - range.start` bytes. +pub struct DirtyInput { + pub range: Range, + pub reader: Pin>, +} /// Size of blocks read from the dirty source and fed to the cleaner. const STREAM_BLOCK_SIZE: usize = 4 * 1024 * 1024; // 4 MB @@ -55,16 +62,13 @@ struct ComposedRegion { /// /// # When to use /// -/// - **Mid-file edit**: pass the modified byte ranges in `dirty_ranges`, same `total_size`. -/// - **Append**: pass `dirty_ranges` covering the written bytes (or empty), `total_size > original_size`. The last -/// original chunk is automatically re-chunked with the appended data. -/// - **Truncation**: pass `dirty_ranges = &[]`, `total_size < original_size`. The boundary chunk at the cut point is -/// automatically re-uploaded. -/// - **No change**: pass `dirty_ranges = &[]`, `total_size == original_size`. Returns the original hash immediately (no -/// CAS calls). -/// -/// `dirty_ranges` can be empty when only the file size changed (truncation or -/// append via ftruncate). The function adds implicit dirty ranges as needed. +/// - **Mid-file edit**: pass modified byte ranges in `dirty_inputs`, same `total_size`. +/// - **Append**: include `[original_size, total_size)` in `dirty_inputs` with a reader for the new bytes (including +/// sparse gaps). The last original chunk is automatically re-chunked. +/// - **Truncation**: pass `dirty_inputs = vec![]`, `total_size < original_size`. The boundary chunk at the cut point is +/// re-uploaded from CAS automatically. +/// - **No change**: pass `dirty_inputs = vec![]`, `total_size == original_size`. Returns the original hash immediately +/// (no CAS calls). /// /// # Arguments /// @@ -72,33 +76,10 @@ struct ComposedRegion { /// * `cas_client` - CAS client for fetching original file metadata and downloading boundary chunks. /// * `original_hash` - Merkle hash of the original file in CAS. /// * `original_size` - Size of the original file in bytes. -/// * `dirty_ranges` - Sorted, non-overlapping `(start, end)` byte ranges that were modified. Must not overlap and must -/// be in ascending order. Can be empty for pure size changes. -/// * `dirty_source` - Seekable reader over the modified file (e.g. a staging file). Read in two -/// situations: (1) bytes within caller-provided `dirty_ranges`, and (2) the append region -/// `[original_size, total_size)` when `total_size > original_size` (including sparse gaps). -/// Bytes in the original range `[0, original_size)` outside `dirty_ranges` are never read from -/// `dirty_source`; they are reconstructed from CAS instead. -/// -/// # Important invariant -/// -/// `dirty_source` is only read for byte ranges explicitly listed in `dirty_ranges` plus the -/// append region beyond `original_size`. Internally needed ranges (e.g. truncation boundary -/// chunks within `[0, original_size)`) are always reconstructed from CAS. This means callers -/// do not need to populate `dirty_source` with original file content for regions they did not -/// modify. -/// -/// // TODO: the current API cannot enforce this invariant at the type level. `dirty_source` is a -/// // `ReadSeek` over the full file, but only a subset of it contains valid data. A safer API -/// // would accept a callback `Fn(Range) -> impl AsyncRead` (or a stream of byte chunks) -/// // that is called only for caller dirty ranges, making it impossible to accidentally read -/// // stale staging bytes. Returning a stream rather than `Vec` avoids buffering large -/// // ranges in memory. This would also remove the need for the `truncation_boundary` injection -/// // logic. Deferred because it would change the public API and impact existing callers -/// // (hf-mount, etc.). -/// -/// * `total_size` - Total size of the modified file. Compared to `original_size` to detect append (`total_size > -/// original_size`) or truncation (`total_size < original_size`). +/// * `dirty_inputs` - Sorted, non-overlapping dirty ranges, each paired with an async reader that yields exactly the +/// bytes for that range. Bytes outside these ranges within `[0, original_size)` are reconstructed from CAS. Each +/// reader is consumed exactly once. +/// * `total_size` - Total size of the modified file. /// /// # Limitations /// @@ -111,28 +92,26 @@ pub async fn upload_ranges( cas_client: Arc, original_hash: MerkleHash, original_size: u64, - dirty_ranges: &[(u64, u64)], - dirty_source: &mut dyn ReadSeek, + dirty_inputs: Vec, total_size: u64, ) -> Result { - // No changes: return original file as-is. - if dirty_ranges.is_empty() && total_size == original_size { + if dirty_inputs.is_empty() && total_size == original_size { return Ok(XetFileInfo::new(original_hash.hex(), original_size)); } - // Ranges must be in ascending order with no overlaps. + // Extract ranges for validation and build_dirty_regions. + let dirty_ranges: Vec<(u64, u64)> = dirty_inputs.iter().map(|d| (d.range.start, d.range.end)).collect(); + if !dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0) { return Err(DataProcessingError::InternalError(format!( "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" ))); } - // Each range must cover at least one byte. if !dirty_ranges.iter().all(|&(s, e)| s < e) { return Err(DataProcessingError::InternalError(format!( "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" ))); } - // No range may extend past the end of the file. if let Some(&(_, last_end)) = dirty_ranges.last() && last_end > total_size { @@ -141,6 +120,17 @@ pub async fn upload_ranges( ))); } + // Appended bytes must be covered by dirty_inputs (CAS has no data beyond original_size). + if total_size > original_size { + let last_input_end = dirty_ranges.last().map_or(0, |&(_, e)| e); + if last_input_end < total_size { + return Err(DataProcessingError::InternalError(format!( + "total_size ({total_size}) > original_size ({original_size}) but dirty_inputs \ + only cover up to byte {last_input_end} (must reach total_size)" + ))); + } + } + // 1. Fetch chunk hashes and reconstruction info in parallel. let (original_chunks, recon_result) = tokio::try_join!( cas_client.get_file_chunk_hashes(&original_hash), @@ -170,12 +160,12 @@ pub async fn upload_ranges( chunk_offsets.push(offset); } - // 3. Build effective dirty ranges: start from caller's ranges, then handle append. + // 3. Build effective dirty ranges. // - // INVARIANT: effective_ranges must only contain ranges whose bytes are valid in - // dirty_source. Internally needed ranges (truncation boundary) are handled via + // INVARIANT: dirty_ranges only contains ranges whose bytes are provided by + // dirty_inputs. Internally needed ranges (truncation boundary) are handled via // injected DirtyRegions with empty dirty spans, so CAS provides the bytes. - let mut effective_ranges: Vec<(u64, u64)> = dirty_ranges.to_vec(); + // For appends, the caller must include the appended region in dirty_inputs. let num_chunks = original_chunks.len(); // Number of original chunks to keep in the final composition. @@ -201,23 +191,17 @@ pub async fn upload_ranges( if boundary < total_size { // Cut falls mid-chunk: the partial chunk [boundary, total_size) must be // re-uploaded. We track it here and inject a DirtyRegion after - // build_dirty_regions, rather than adding it to effective_ranges, + // build_dirty_regions, rather than adding it to dirty_ranges, // because the bytes live in CAS (not in the caller's staging file). truncation_boundary = Some((boundary, last_full)); } } - if total_size > original_size { - // Append: add an implicit dirty range for the new bytes. The last original chunk - // (EOF-terminated) will be included via a first_chunk adjustment below, and its - // bytes will come from CAS via the boundary prefix mechanism. - // - // Example: file grew from 450 to 550 bytes. - // The last chunk [300,450) gets re-chunked together with appended bytes [450,550). - // Boundary prefix downloads [300,450) from CAS, dirty reads [450,550) from staging. - merge_or_push(&mut effective_ranges, (original_size, total_size)); - } + // For appends (total_size > original_size), the caller must include the appended bytes + // in dirty_inputs. The last original chunk is re-chunked automatically via the + // first_chunk adjustment in build_dirty_regions, with its bytes read from CAS via + // the boundary prefix mechanism. - // Note: if effective_ranges is empty here, it means pure truncation (no dirty ranges, + // Note: if dirty_ranges is empty here, it means pure truncation (no dirty ranges, // file shrunk). We still proceed to compose a new file from the truncated chunk set. // 4. Expand dirty byte ranges to chunk-aligned boundaries. @@ -234,8 +218,7 @@ pub async fn upload_ranges( // +------- inside chunk[1] // // -> expand to chunks [1, 3) (chunks 1 and 2 must be re-uploaded) - let mut dirty_regions = - build_dirty_regions(&effective_ranges, &chunk_offsets, num_chunks, original_size, total_size)?; + let mut dirty_regions = build_dirty_regions(&dirty_ranges, &chunk_offsets, num_chunks, original_size, total_size)?; // If truncation cuts mid-chunk and no caller dirty range already covers that // chunk, inject a DirtyRegion with an empty dirty range. The processing loop's @@ -260,6 +243,8 @@ pub async fn upload_ranges( let session = FileUploadSession::new(config.clone(), None).await?; let mut uploaded_regions: Vec = Vec::with_capacity(dirty_regions.len()); + let mut dirty_inputs = dirty_inputs; + let mut input_idx = 0usize; for region in dirty_regions { let boundary_start = *chunk_offsets.get(region.first_chunk).ok_or_else(|| { @@ -301,19 +286,56 @@ pub async fn upload_ranges( stream_cas_range(&cas_client, original_hash, boundary_start, region.dirty_start, &mut cleaner).await?; } - // b) Dirty bytes from source, streamed in blocks. - // TODO: seek/read_exact are blocking I/O in an async context. Acceptable for local - // files (<1ms per 4MB block) but consider block_in_place for network-backed sources. - if region.dirty_end > region.dirty_start { - dirty_source.seek(SeekFrom::Start(region.dirty_start))?; - let mut remaining = (region.dirty_end - region.dirty_start) as usize; + // b) Dirty bytes from async readers. + // + // A merged DirtyRegion may span multiple inputs (when adjacent dirty ranges + // touch the same chunks). We consume readers in order, filling CAS gaps + // between them if the gap falls within the original file. + let mut cursor = region.dirty_start; + while input_idx < dirty_inputs.len() && dirty_inputs[input_idx].range.start < region.dirty_end { + let input = &mut dirty_inputs[input_idx]; + let input_start = input.range.start.max(region.dirty_start); + let input_end = input.range.end.min(region.dirty_end); + + // CAS gap before this input (within the original file). + if cursor < input_start { + if cursor < original_size { + let gap_end = input_start.min(original_size); + stream_cas_range(&cas_client, original_hash, cursor, gap_end, &mut cleaner).await?; + } + // Gap beyond original_size means the caller didn't provide bytes + // for part of the appended region. This would produce a corrupted file. + if input_start > original_size && cursor < input_start { + return Err(DataProcessingError::InternalError(format!( + "gap in dirty_inputs: no data for bytes [{cursor}, {input_start}) \ + (beyond original_size {original_size})" + ))); + } + } + + // Stream bytes from the async reader. + let bytes_to_read = (input_end - input_start) as usize; + let mut remaining = bytes_to_read; let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining)]; while remaining > 0 { let to_read = buf.len().min(remaining); - dirty_source.read_exact(&mut buf[..to_read])?; + input.reader.read_exact(&mut buf[..to_read]).await.map_err(|err| { + DataProcessingError::InternalError(format!( + "failed to read dirty input [{}, {}): {err}", + input.range.start, input.range.end + )) + })?; cleaner.add_data(&buf[..to_read]).await?; remaining -= to_read; } + + cursor = input_end; + // Only advance to next input if we fully consumed this one within the region. + if input.range.end <= region.dirty_end { + input_idx += 1; + } else { + break; + } } // c) Boundary suffix: stable bytes after the dirty range. @@ -428,7 +450,7 @@ pub async fn upload_ranges( session.register_composed_file(composed_mdb).await?; session.finalize().await?; - let total_dirty: u64 = effective_ranges.iter().map(|(s, e)| e - s).sum(); + let total_dirty: u64 = dirty_ranges.iter().map(|(s, e)| e - s).sum(); info!( "upload_ranges: hash={} size={} (original={}, {} dirty regions, {} dirty bytes)", combined_hash.hex(), @@ -463,14 +485,14 @@ async fn stream_cas_range( /// then adjacent/overlapping chunk ranges are merged to avoid uploading the same /// boundary chunks twice. fn build_dirty_regions( - effective_ranges: &[(u64, u64)], + dirty_ranges: &[(u64, u64)], chunk_offsets: &[u64], num_chunks: usize, original_size: u64, total_size: u64, ) -> Result> { - let mut raw = Vec::with_capacity(effective_ranges.len()); - for &(dirty_start, dirty_end) in effective_ranges { + let mut raw = Vec::with_capacity(dirty_ranges.len()); + for &(dirty_start, dirty_end) in dirty_ranges { // Find the first chunk whose end offset exceeds dirty_start. let mut first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start); debug_assert!(first_chunk <= num_chunks, "first_chunk {first_chunk} out of bounds ({num_chunks} chunks)"); @@ -519,36 +541,6 @@ fn build_dirty_regions( Ok(merged) } -/// Merge `range` with the last element of `ranges` if they overlap or touch, -/// otherwise append it. -/// -/// Three cases: -/// - **Overlap** (last.1 >= range.0): last and range share bytes -/// - **Touch** (last.1 == range.0): last ends exactly where range starts -/// - **Gap**: last ends before range starts, keep both separate -/// -/// ```text -/// overlap: last [=====] touch: last [=====] -/// range [=====] range [=====] -/// result [=========] result [===========] -/// -/// gap: last [=====] -/// range [=====] -/// result [=====] [=====] (two separate entries) -/// ``` -fn merge_or_push(ranges: &mut Vec<(u64, u64)>, range: (u64, u64)) { - if let Some(last) = ranges.last_mut() { - // Overlap or touch: merge by expanding last's bounds - if last.1 >= range.0 { - last.0 = last.0.min(range.0); - last.1 = last.1.max(range.1); - return; - } - } - // Gap: append as separate entry - ranges.push(range); -} - /// Extract segments and verification entries for chunks `[chunk_start, chunk_end)` /// from the original reconstruction plan, truncating segments at boundaries. /// @@ -638,6 +630,33 @@ mod tests { use crate::processing::file_download_session::FileDownloadSession; use crate::processing::file_upload_session::FileUploadSession; + /// Build `DirtyInput`s from a source buffer and range list. Each input gets + /// a `Cursor` over the corresponding slice of `data`. + fn make_dirty_inputs(ranges: &[(u64, u64)], data: &[u8]) -> Vec { + ranges + .iter() + .map(|&(start, end)| { + let slice = data[start as usize..end as usize].to_vec(); + DirtyInput { + range: start..end, + reader: Box::pin(Cursor::new(slice)), + } + }) + .collect() + } + + /// Build `DirtyInput`s with dummy readers (for validation error tests where + /// the reader is never consumed). + fn make_dummy_inputs(ranges: &[(u64, u64)]) -> Vec { + ranges + .iter() + .map(|&(start, end)| DirtyInput { + range: start..end, + reader: Box::pin(Cursor::new(Vec::new())), + }) + .collect() + } + // original: [=========================== 256 KB ===========================] // dirty: [== 1 KB ===] // result: [===stable===][re-uploaded][============stable=================] @@ -671,15 +690,12 @@ mod tests { let dirty_end = 101_000usize; modified_data[dirty_start..dirty_end].fill(0xBB); let total_size = modified_data.len() as u64; - - let mut dirty_source = Cursor::new(&modified_data); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(dirty_start as u64, dirty_end as u64)], - &mut dirty_source, + make_dirty_inputs(&[(dirty_start as u64, dirty_end as u64)], &modified_data), total_size, ) .await @@ -720,18 +736,10 @@ mod tests { // Truncate to 100 KB (no dirty ranges, pure truncation). let truncated_size = 100_000u64; - let mut source = Cursor::new(&original_data[..truncated_size as usize]); - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_size, - &[], // no dirty ranges, just truncation - &mut source, - truncated_size, - ) - .await - .unwrap(); + let result = + upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], truncated_size) + .await + .unwrap(); assert_eq!(result.file_size(), truncated_size); @@ -763,15 +771,12 @@ mod tests { let mut full_data = original_data.clone(); full_data.extend(random_data(99, 50 * 1024)); let total_size = full_data.len() as u64; - - let mut source = Cursor::new(&full_data); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(original_size, total_size)], // appended region is dirty - &mut source, + make_dirty_inputs(&[(original_size, total_size)], &full_data), total_size, ) .await @@ -806,15 +811,12 @@ mod tests { let mut modified_data = original_data.clone(); modified_data[..4096].fill(0xBB); let total_size = modified_data.len() as u64; - - let mut source = Cursor::new(&modified_data); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(0, 4096)], - &mut source, + make_dirty_inputs(&[(0, 4096)], &modified_data), total_size, ) .await @@ -850,15 +852,12 @@ mod tests { modified_data[10_000..12_000].fill(0xBB); // first dirty range modified_data[200_000..202_000].fill(0xCC); // second dirty range let total_size = modified_data.len() as u64; - - let mut source = Cursor::new(&modified_data); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(10_000, 12_000), (200_000, 202_000)], - &mut source, + make_dirty_inputs(&[(10_000, 12_000), (200_000, 202_000)], &modified_data), total_size, ) .await @@ -915,15 +914,12 @@ mod tests { let mut modified_data = original_data.clone(); modified_data[r1_start..r1_end].fill(0xBB); modified_data[r2_start..r2_end].fill(0xBB); - - let mut source = Cursor::new(&modified_data); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(r1_start as u64, r1_end as u64), (r2_start as u64, r2_end as u64)], - &mut source, + make_dirty_inputs(&[(r1_start as u64, r1_end as u64), (r2_start as u64, r2_end as u64)], &modified_data), modified_data.len() as u64, ) .await @@ -959,18 +955,10 @@ mod tests { let truncated_size: u64 = chunks[0].1; let truncated_data = &original_data[..truncated_size as usize]; - let mut source = Cursor::new(truncated_data); - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_size, - &[], - &mut source, - truncated_size, - ) - .await - .unwrap(); + let result = + upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], truncated_size) + .await + .unwrap(); assert_eq!(result.file_size(), truncated_size); @@ -982,8 +970,11 @@ mod tests { } // original: [======== 100 KB ========] - // staging: [======== 100 KB ========][000][=4K written=] + // inputs: [======== 100 KB ========][000][=4K written=] // ^ gap (zeros from seek past EOF) + // + // With the new API, the caller provides the full append region [original_size, total_size) + // as a single DirtyInput, including the sparse gap. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_append_with_gap_before_dirty_range() { let server = LocalTestServerBuilder::new().start().await; @@ -995,28 +986,21 @@ mod tests { let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - // Simulate: seek to original_size + 500, write 4096 bytes of pseudo-random data. - // The gap [original_size, original_size + 500) contains zeros from the sparse file. let gap = 500u64; let write_data = random_data(101, 4096); - let write_len = write_data.len() as u64; - let total_size = original_size + gap + write_len; + let total_size = original_size + gap + write_data.len() as u64; let mut full_data = original_data.clone(); - full_data.extend(vec![0x00u8; gap as usize]); // sparse hole = zeros + full_data.extend(vec![0x00u8; gap as usize]); full_data.extend(&write_data); - let dirty_start = original_size + gap; - let dirty_end = total_size; - - let mut source = Cursor::new(&full_data); + // The caller provides the entire append region (including the sparse gap). let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(dirty_start, dirty_end)], - &mut source, + make_dirty_inputs(&[(original_size, total_size)], &full_data), total_size, ) .await @@ -1054,15 +1038,12 @@ mod tests { // This is what hf-mount produces (sparse hole + appended bytes). let mut sparse_staging = vec![0u8; total_size as usize]; sparse_staging[original_size as usize..].copy_from_slice(&append_data); - - let mut source = Cursor::new(&sparse_staging); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(original_size, total_size)], - &mut source, + make_dirty_inputs(&[(original_size, total_size)], &sparse_staging), total_size, ) .await @@ -1158,14 +1139,12 @@ mod tests { let mut expected = original.clone(); expected[boundary as usize..dirty_end as usize].fill(0xFF); let size = original.len() as u64; - let mut source = Cursor::new(&expected); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, size, - &[(boundary, dirty_end)], - &mut source, + make_dirty_inputs(&[(boundary, dirty_end)], &expected), size, ) .await @@ -1190,11 +1169,7 @@ mod tests { let data = random_data(70, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - - let mut source = Cursor::new(&data); - let result = upload_ranges(config, cas_client, hash, size, &[], &mut source, size) - .await - .unwrap(); + let result = upload_ranges(config, cas_client, hash, size, vec![], size).await.unwrap(); assert_eq!(result.hash(), hash.hex()); assert_eq!(result.file_size(), size); @@ -1211,9 +1186,7 @@ mod tests { let data = random_data(71, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - - let mut source = Cursor::new(&data); - let err = upload_ranges(config, cas_client, hash, size, &[(100, size + 1)], &mut source, size).await; + let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, size + 1)]), size).await; assert!(err.is_err(), "dirty range past total_size should be rejected"); } @@ -1227,9 +1200,8 @@ mod tests { let data = random_data(60, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - - let mut source = Cursor::new(&data); - let err = upload_ranges(config, cas_client, hash, size, &[(100, 300), (200, 400)], &mut source, size).await; + let err = + upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, 300), (200, 400)]), size).await; assert!(err.is_err(), "overlapping ranges should be rejected"); } @@ -1243,9 +1215,7 @@ mod tests { let data = random_data(61, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - - let mut source = Cursor::new(&data); - let err = upload_ranges(config, cas_client, hash, size, &[(100, 100)], &mut source, size).await; + let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, 100)]), size).await; assert!(err.is_err(), "empty range (start == end) should be rejected"); } @@ -1259,12 +1229,34 @@ mod tests { let data = random_data(62, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - - let mut source = Cursor::new(&data); - let err = upload_ranges(config, cas_client, hash, size, &[(300, 400), (100, 200)], &mut source, size).await; + let err = + upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(300, 400), (100, 200)]), size).await; assert!(err.is_err(), "unsorted ranges should be rejected"); } + // total_size > original_size but dirty_inputs don't cover appended region -> rejected. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_rejects_append_without_dirty_inputs() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let data = random_data(63, 256 * 1024); + let hash = upload_file(&config, &data).await; + let size = data.len() as u64; + let bigger = size + 1000; + + // No dirty inputs but total_size > original_size. + let err = upload_ranges(config.clone(), cas_client.clone(), hash, size, vec![], bigger).await; + assert!(err.is_err(), "append without dirty_inputs covering appended bytes should be rejected"); + + // Dirty input stops before total_size. + let partial = make_dirty_inputs(&[(size, size + 500)], &vec![0xEEu8; bigger as usize]); + let err = upload_ranges(config, cas_client, hash, size, partial, bigger).await; + assert!(err.is_err(), "append with partial coverage should be rejected"); + } + #[test] fn test_build_dirty_regions_coalesces_adjacent() { // 5 chunks of 100 bytes each: offsets [0, 100, 200, 300, 400, 500] @@ -1313,40 +1305,71 @@ mod tests { assert!(result.is_err(), "should fail with inconsistent/empty chunk data"); } - // original: [=========================== 256 KB ===========================] - // staging: [0000000000000000000000000000] (all zeros, file never opened for write) - // result: [====== 100 KB from CAS =====] - // ^ cut here (mid-chunk) + // original: [chunk0][chunk1][chunk2][chunk3][...more chunks...] + // input: [========= single large write ==========] // - // The boundary chunk bytes must come from CAS, not from the zero-filled staging. + // A single DirtyInput that spans many chunks. Verifies that the reader is + // consumed correctly even when build_dirty_regions merges multiple chunk + // ranges into one DirtyRegion. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_upload_ranges_truncation_empty_staging() { + async fn test_single_input_spanning_many_chunks() { let server = LocalTestServerBuilder::new().start().await; let base_dir = TempDir::new().unwrap(); let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); - let original_data = random_data(77, 256 * 1024); + let original_data = random_data(99, 256 * 1024); let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - let truncated_size = 100_000u64; - - let zeros = vec![0u8; truncated_size as usize]; - let mut source = Cursor::new(&zeros); + // Overwrite a large middle section (likely spans many CDC chunks). + let mut modified = original_data.clone(); + let dirty_start = 10_000u64; + let dirty_end = 200_000u64; + modified[dirty_start as usize..dirty_end as usize].fill(0xFF); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[], - &mut source, - truncated_size, + make_dirty_inputs(&[(dirty_start, dirty_end)], &modified), + original_size, ) .await .unwrap(); + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), original_size).await; + assert_eq!(downloaded, modified, "large spanning input produced wrong content"); + + let clean_hash = upload_file(&config, &modified).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + + // original: [=========================== 256 KB ===========================] + // staging: [0000000000000000000000000000] (all zeros, file never opened for write) + // result: [====== 100 KB from CAS =====] + // ^ cut here (mid-chunk) + // + // The boundary chunk bytes must come from CAS, not from the zero-filled staging. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_truncation_empty_staging() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = random_data(77, 256 * 1024); + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let truncated_size = 100_000u64; + + let result = + upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], truncated_size) + .await + .unwrap(); + assert_eq!(result.file_size(), truncated_size); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; @@ -1390,15 +1413,12 @@ mod tests { let mut staging = vec![0u8; truncated_size as usize]; staging[dirty_start as usize..dirty_end as usize].fill(0xBB); - - let mut source = Cursor::new(&staging); let result = upload_ranges( config.clone(), cas_client.clone(), original_hash, original_size, - &[(dirty_start, dirty_end)], - &mut source, + make_dirty_inputs(&[(dirty_start, dirty_end)], &staging), truncated_size, ) .await @@ -1454,18 +1474,28 @@ mod tests { total_size: u64, ) { let original_hash = upload_file(config, original_data).await; - let mut source = Cursor::new(expected); - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_data.len() as u64, - dirty_ranges, - &mut source, - total_size, - ) - .await - .unwrap(); + let original_size = original_data.len() as u64; + + // Build dirty inputs from the caller's ranges. + let mut inputs = make_dirty_inputs(dirty_ranges, expected); + + // For appends, ensure the appended region is included as a dirty input. + if total_size > original_size { + let append_start = original_size; + let already_covered = dirty_ranges.iter().any(|&(s, _)| s <= append_start); + if !already_covered { + inputs.push(DirtyInput { + range: append_start..total_size, + reader: Box::pin(Cursor::new(expected[append_start as usize..total_size as usize].to_vec())), + }); + inputs.sort_by_key(|d| d.range.start); + } + } + + let result = + upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs, total_size) + .await + .unwrap(); assert_eq!(result.file_size(), total_size, "file size mismatch"); let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; From fc3e41afbd2233498fee0fba0faeb01dad93acae Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 19 Mar 2026 22:37:20 +0100 Subject: [PATCH 12/38] fix: clamp CAS prefix/suffix reads to original_size The prefix/suffix conditions used `boundary_end <= original_size` to guard CAS reads. This fails when a composed file (from a prior truncation) has chunks larger than the logical file size. For example, truncating a 4096-byte file to 17 bytes produces a composed file whose chunk is still reported as 4096 bytes by get_file_chunk_hashes. A subsequent mid-file edit on the 17-byte file sees boundary_end=4096 > original_size=17, skipping both prefix and suffix CAS reads. The composed file then contains only the dirty bytes (6 instead of 17). Fix: use `boundary_start < original_size` for prefix and `suffix_start < original_size` for suffix. Clamp the CAS read end to `original_size` to avoid reading past the logical file boundary. Also: - Replace O(n) reverse scan for last_chunk with partition_point (O(log n)) - Fix LocalTestServer (local_server) get_file_chunk_hashes to delegate to the local client instead of the HTTP remote_client - Add test_truncate_then_mid_edit reproducing the exact production scenario (upload 4096 bytes, truncate to 17, mid-edit: 6/17 without fix) --- .../simulation/local_server/server.rs | 2 +- xet_data/src/processing/range_upload.rs | 98 +++++++++++++++---- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/xet_client/src/cas_client/simulation/local_server/server.rs b/xet_client/src/cas_client/simulation/local_server/server.rs index 21ee816e5..4e0c2663e 100644 --- a/xet_client/src/cas_client/simulation/local_server/server.rs +++ b/xet_client/src/cas_client/simulation/local_server/server.rs @@ -489,7 +489,7 @@ impl Client for LocalTestServer { &self, file_id: &xet_core_structures::merklehash::MerkleHash, ) -> Result { - self.remote_client.get_file_chunk_hashes(file_id).await + self.client.get_file_chunk_hashes(file_id).await } } diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index f238a4b8a..37d623996 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -282,8 +282,13 @@ pub async fn upload_ranges( let mut cleaner = session.start_clean(None, middle_size, Sha256Policy::Skip, Ulid::new()).await; // a) Boundary prefix: stable bytes before the dirty range. - if region.dirty_start > boundary_start && boundary_end <= original_size { - stream_cas_range(&cas_client, original_hash, boundary_start, region.dirty_start, &mut cleaner).await?; + // + // We clamp the CAS read to original_size because chunk sizes from + // get_file_chunk_hashes may exceed the logical file size (e.g. after a + // truncation, the composed file inherits the original chunk layout). + if region.dirty_start > boundary_start && boundary_start < original_size { + let prefix_end = region.dirty_start.min(original_size); + stream_cas_range(&cas_client, original_hash, boundary_start, prefix_end, &mut cleaner).await?; } // b) Dirty bytes from async readers. @@ -339,9 +344,12 @@ pub async fn upload_ranges( } // c) Boundary suffix: stable bytes after the dirty range. + // + // Same clamping as prefix: CAS chunk may extend past original_size. let suffix_start = region.dirty_end.min(effective_boundary_end); - if suffix_start < effective_boundary_end && boundary_end <= original_size { - stream_cas_range(&cas_client, original_hash, suffix_start, effective_boundary_end, &mut cleaner).await?; + if suffix_start < effective_boundary_end && suffix_start < original_size { + let suffix_end = effective_boundary_end.min(original_size); + stream_cas_range(&cas_client, original_hash, suffix_start, suffix_end, &mut cleaner).await?; } let (info, chunks, _metrics) = cleaner.finish().await?; @@ -394,6 +402,7 @@ pub async fn upload_ranges( let mut all_verification = Vec::new(); let mut chunk_cursor = 0usize; let mut seg_cursor = 0usize; + let mut seg_chunk_cursor = 0usize; for composed in &composed_regions { // Stable region before this dirty region. @@ -404,6 +413,7 @@ pub async fn upload_ranges( chunk_cursor, composed.region.first_chunk, &mut seg_cursor, + &mut seg_chunk_cursor, ); all_chunks.extend_from_slice(&original_chunks[chunk_cursor..composed.region.first_chunk]); all_segments.extend(segments); @@ -421,7 +431,7 @@ pub async fn upload_ranges( // Stable suffix after the last dirty region. if chunk_cursor < compose_num_chunks { let (segments, verification_hashes) = - extract_segments(&original_mdb, &original_chunks, chunk_cursor, compose_num_chunks, &mut seg_cursor); + extract_segments(&original_mdb, &original_chunks, chunk_cursor, compose_num_chunks, &mut seg_cursor, &mut seg_chunk_cursor); all_chunks.extend_from_slice(&original_chunks[chunk_cursor..compose_num_chunks]); all_segments.extend(segments); all_verification.extend(verification_hashes); @@ -508,15 +518,12 @@ fn build_dirty_regions( // Find the last chunk (exclusive) that starts before dirty_end. debug_assert!(dirty_end <= total_size, "dirty_end ({dirty_end}) exceeds total_size ({total_size})"); let clamped_end = dirty_end.min(original_size); - let last_chunk = (0..num_chunks) - .rev() - .find(|&i| chunk_offsets[i] < clamped_end) - .map(|i| i + 1) - .ok_or_else(|| { - DataProcessingError::InternalError(format!( - "no chunk starts before clamped_end ({clamped_end}), chunks may be inconsistent" - )) - })?; + let last_chunk = chunk_offsets[..num_chunks].partition_point(|&o| o < clamped_end); + if last_chunk == 0 { + return Err(DataProcessingError::InternalError(format!( + "no chunk starts before clamped_end ({clamped_end}), chunks may be inconsistent" + ))); + } raw.push(DirtyRegion { dirty_start, dirty_end, @@ -554,15 +561,12 @@ fn extract_segments( chunk_start: usize, chunk_end: usize, seg_cursor: &mut usize, + seg_chunk_cursor: &mut usize, ) -> (Vec, Vec) { let mut segments = Vec::new(); let mut verification = Vec::new(); - // Compute the chunk-level cursor from the segment cursor. - let mut chunk_cursor: usize = original_mdb.segments[..*seg_cursor] - .iter() - .map(|s| (s.chunk_index_end - s.chunk_index_start) as usize) - .sum(); + let mut chunk_cursor = *seg_chunk_cursor; // Walk segments starting from seg_cursor, extracting the overlap with [chunk_start, chunk_end). // @@ -608,6 +612,7 @@ fn extract_segments( // If it extends beyond chunk_end, a later call may need its suffix. if seg_end <= chunk_end { *seg_cursor += 1; + *seg_chunk_cursor = seg_end; } } @@ -1346,6 +1351,61 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } + // original: b"AAAA_HEADER_AAAA|" (17 bytes, single CAS chunk) + // dirty: [SPARSE] (bytes [5, 11)) + // expected: b"AAAA_SPARSE_AAAA|" (17 bytes) + // + // Tests mid-file edit on a very small file (single chunk, smaller than + // typical CDC minimum). See test_truncate_then_mid_edit for the regression + // test that reproduces the real production bug. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_upload_ranges_small_file_mid_edit() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new( + TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap(), + ); + let cas_client: Arc = Arc::new(server); + + let original_data = b"AAAA_HEADER_AAAA|"; + let original_hash = upload_file(&config, original_data).await; + let original_size = original_data.len() as u64; + + let dirty_data = b"SPARSE"; + let dirty_inputs = vec![DirtyInput { + range: 5..11, + reader: Box::pin(Cursor::new(dirty_data.to_vec())), + }]; + + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + dirty_inputs, + original_size, + ) + .await + .unwrap(); + + assert_eq!(result.file_size(), original_size); + + let downloaded = download_file( + &config, + MerkleHash::from_hex(result.hash()).unwrap(), + original_size, + ) + .await; + assert_eq!(downloaded.len(), original_size as usize, "reconstructed size mismatch"); + assert_eq!(&downloaded[..5], b"AAAA_", "prefix from CAS"); + assert_eq!(&downloaded[5..11], b"SPARSE", "dirty range"); + assert_eq!(&downloaded[11..], b"_AAAA|", "suffix from CAS"); + + let expected = b"AAAA_SPARSE_AAAA|"; + let clean_hash = upload_file(&config, expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + // original: [=========================== 256 KB ===========================] // staging: [0000000000000000000000000000] (all zeros, file never opened for write) // result: [====== 100 KB from CAS =====] From dee06431038a638581dd9100802efaa172df37cf Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 20 Mar 2026 13:32:53 +0100 Subject: [PATCH 13/38] fix: correct already_covered check in assert_range_edit test helper The check `any(|&(s, _)| s <= append_start)` was too loose: it considered the append region covered if any dirty range started before it, regardless of whether that range actually extended to total_size. Fix to also check the end bound. Includes rustfmt formatting fixes. --- xet_data/src/processing/range_upload.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 37d623996..bdbb2f708 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -430,8 +430,14 @@ pub async fn upload_ranges( // Stable suffix after the last dirty region. if chunk_cursor < compose_num_chunks { - let (segments, verification_hashes) = - extract_segments(&original_mdb, &original_chunks, chunk_cursor, compose_num_chunks, &mut seg_cursor, &mut seg_chunk_cursor); + let (segments, verification_hashes) = extract_segments( + &original_mdb, + &original_chunks, + chunk_cursor, + compose_num_chunks, + &mut seg_cursor, + &mut seg_chunk_cursor, + ); all_chunks.extend_from_slice(&original_chunks[chunk_cursor..compose_num_chunks]); all_segments.extend(segments); all_verification.extend(verification_hashes); @@ -1362,9 +1368,7 @@ mod tests { async fn test_upload_ranges_small_file_mid_edit() { let server = LocalTestServerBuilder::new().start().await; let base_dir = TempDir::new().unwrap(); - let config = Arc::new( - TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap(), - ); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); let cas_client: Arc = Arc::new(server); let original_data = b"AAAA_HEADER_AAAA|"; @@ -1390,12 +1394,7 @@ mod tests { assert_eq!(result.file_size(), original_size); - let downloaded = download_file( - &config, - MerkleHash::from_hex(result.hash()).unwrap(), - original_size, - ) - .await; + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), original_size).await; assert_eq!(downloaded.len(), original_size as usize, "reconstructed size mismatch"); assert_eq!(&downloaded[..5], b"AAAA_", "prefix from CAS"); assert_eq!(&downloaded[5..11], b"SPARSE", "dirty range"); @@ -1542,7 +1541,7 @@ mod tests { // For appends, ensure the appended region is included as a dirty input. if total_size > original_size { let append_start = original_size; - let already_covered = dirty_ranges.iter().any(|&(s, _)| s <= append_start); + let already_covered = dirty_ranges.iter().any(|&(s, e)| s <= append_start && e >= total_size); if !already_covered { inputs.push(DirtyInput { range: append_start..total_size, From 61a300382e9ed132543673932d43ce2fd2394db2 Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 20 Mar 2026 13:49:15 +0100 Subject: [PATCH 14/38] fix: adapt to main API changes in tests - FileDownloadSession::new takes 1 arg (no progress tracker) - file_size() returns Option - finish() returns 3-tuple in test_range_downloads.rs - Fix redundant slicing clippy warning --- xet_data/src/processing/range_upload.rs | 30 ++++++++++++------------- xet_data/tests/test_range_downloads.rs | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 163e2e818..a4d9f0562 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -711,11 +711,11 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size, total_size); + assert_eq!(result.file_size, Some(total_size)); // 3. Download and verify the composed file. let composed_hash = MerkleHash::from_hex(result.hash()).unwrap(); - let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); + let session = FileDownloadSession::new(config.clone()).await.unwrap(); let file_info = crate::processing::XetFileInfo::new(composed_hash.hex(), total_size); let out_path = base_dir.path().join("output"); session.download_file(&file_info, &out_path).await.unwrap(); @@ -751,7 +751,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), truncated_size); + assert_eq!(result.file_size(), Some(truncated_size)); // Download and verify: first truncated_size bytes should match original. let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; @@ -792,7 +792,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), total_size); + assert_eq!(result.file_size(), Some(total_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded, full_data); @@ -832,7 +832,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), total_size); + assert_eq!(result.file_size(), Some(total_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), modified_data.len()); @@ -873,7 +873,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), total_size); + assert_eq!(result.file_size(), Some(total_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), modified_data.len()); @@ -970,7 +970,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), truncated_size); + assert_eq!(result.file_size(), Some(truncated_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; assert_eq!(&downloaded[..], truncated_data); @@ -1016,7 +1016,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), total_size); + assert_eq!(result.file_size(), Some(total_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), full_data.len(), "size mismatch"); @@ -1182,7 +1182,7 @@ mod tests { let result = upload_ranges(config, cas_client, hash, size, vec![], size).await.unwrap(); assert_eq!(result.hash(), hash.hex()); - assert_eq!(result.file_size(), size); + assert_eq!(result.file_size(), Some(size)); } // dirty_range end > total_size -> rejected. @@ -1391,7 +1391,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), original_size); + assert_eq!(result.file_size(), Some(original_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), original_size).await; assert_eq!(downloaded.len(), original_size as usize, "reconstructed size mismatch"); @@ -1428,7 +1428,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), truncated_size); + assert_eq!(result.file_size(), Some(truncated_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; assert_eq!(downloaded.len(), truncated_size as usize); @@ -1482,7 +1482,7 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), truncated_size); + assert_eq!(result.file_size(), Some(truncated_size)); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; assert_eq!(downloaded.len(), expected.len()); @@ -1515,7 +1515,7 @@ mod tests { } async fn download_file(config: &Arc, hash: MerkleHash, size: u64) -> Vec { - let session = FileDownloadSession::new(config.clone(), None).await.unwrap(); + let session = FileDownloadSession::new(config.clone()).await.unwrap(); let xfi = crate::processing::XetFileInfo::new(hash.hex(), size); let dir = TempDir::new().unwrap(); let out = dir.path().join("out"); @@ -1555,10 +1555,10 @@ mod tests { .await .unwrap(); - assert_eq!(result.file_size(), total_size, "file size mismatch"); + assert_eq!(result.file_size(), Some(total_size), "file size mismatch"); let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded.len(), expected.len(), "downloaded length mismatch"); - assert_eq!(&downloaded[..], &expected[..], "content mismatch"); + assert_eq!(&downloaded[..], expected, "content mismatch"); let clean_hash = upload_file(config, expected).await; assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); diff --git a/xet_data/tests/test_range_downloads.rs b/xet_data/tests/test_range_downloads.rs index ce5e67712..c4ba530ea 100644 --- a/xet_data/tests/test_range_downloads.rs +++ b/xet_data/tests/test_range_downloads.rs @@ -18,7 +18,7 @@ mod tests { .start_clean(Some(name.into()), data.len() as u64, Sha256Policy::Compute) .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); xfi } From 79a1ef332df4ba01d1fa22a6f91f9a324d722d6e Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 20 Mar 2026 14:14:37 +0100 Subject: [PATCH 15/38] fix: reject append with uncovered gap beyond original_size The append validation only checked that the last dirty input reaches total_size, but didn't verify coverage from original_size. A caller providing e.g. dirty_inputs = [(original_size + 100, total_size)] passed validation, but the 100-byte gap beyond original_size had no source (CAS stops at original_size, no input covers it), producing a corrupted file with missing bytes. Add validation that walks the append region [original_size, total_size) and rejects any gap not covered by a dirty input. --- xet_data/src/processing/range_upload.rs | 61 ++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index a4d9f0562..f1efd6971 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -119,7 +119,8 @@ pub async fn upload_ranges( ))); } - // Appended bytes must be covered by dirty_inputs (CAS has no data beyond original_size). + // Appended bytes must be fully covered by dirty_inputs (CAS has no data beyond original_size). + // Check that: (1) inputs reach total_size, and (2) no gap exists beyond original_size. if total_size > original_size { let last_input_end = dirty_ranges.last().map_or(0, |&(_, e)| e); if last_input_end < total_size { @@ -128,6 +129,19 @@ pub async fn upload_ranges( only cover up to byte {last_input_end} (must reach total_size)" ))); } + + // Verify no gaps beyond original_size between inputs. Walk the append region + // [original_size, total_size) and ensure it is fully covered. + let mut covered_up_to = original_size; + for &(start, end) in &dirty_ranges { + if start > covered_up_to && covered_up_to >= original_size { + return Err(DataError::InternalError(format!( + "gap in append region: bytes [{covered_up_to}, {start}) are beyond \ + original_size ({original_size}) and not covered by any dirty input" + ))); + } + covered_up_to = covered_up_to.max(end); + } } // 1. Fetch chunk hashes and reconstruction info in parallel. @@ -1263,8 +1277,51 @@ mod tests { // Dirty input stops before total_size. let partial = make_dirty_inputs(&[(size, size + 500)], &vec![0xEEu8; bigger as usize]); - let err = upload_ranges(config, cas_client, hash, size, partial, bigger).await; + let err = upload_ranges(config.clone(), cas_client.clone(), hash, size, partial, bigger).await; assert!(err.is_err(), "append with partial coverage should be rejected"); + + // Dirty input covers end but leaves gap after original_size. + let gap_start = make_dirty_inputs(&[(size + 100, bigger)], &vec![0xEEu8; bigger as usize]); + let err = upload_ranges(config, cas_client, hash, size, gap_start, bigger).await; + assert!(err.is_err(), "append with gap at start of append region should be rejected"); + } + + // Regression: dirty_inputs = [(original_size + 100, total_size)] passes the old + // "last input reaches total_size" check, but leaves a gap [original_size, original_size + 100) + // that is beyond original_size (CAS can't fill it) and not covered by any input. + // Without validation, the cleaner silently skips those bytes → corrupted file. + // + // Original: [################] (256 KB) + // Append: [--gap--][=====dirty=====] + // ^ ^ ^ + // original +100 total_size + // = 256KB = 256KB+100 = 256KB+50KB + // + // The gap [256KB, 256KB+100) has no source: CAS stops at 256KB, no input covers it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_rejects_append_with_gap_after_original_size() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = Arc::new(TranslatorConfig::test_server_config(server.http_endpoint(), base_dir.path()).unwrap()); + let cas_client: Arc = Arc::new(server); + + let original_data = random_data(42, 256 * 1024); + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + let total_size = original_size + 50_000; + let gap = 100u64; + + // Input starts at original_size + gap, leaving [original_size, original_size + gap) uncovered. + let append_data = vec![0xBBu8; (total_size - original_size - gap) as usize]; + let inputs = vec![DirtyInput { + range: (original_size + gap)..total_size, + reader: Box::pin(std::io::Cursor::new(append_data)), + }]; + + let err = upload_ranges(config, cas_client, original_hash, original_size, inputs, total_size).await; + assert!(err.is_err()); + let msg = format!("{}", err.unwrap_err()); + assert!(msg.contains("gap in append region"), "expected gap-in-append-region error, got: {msg}"); } #[test] From e055a58b1f2dbea488d837306fb3a1417e93d59a Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 20 Mar 2026 14:15:47 +0100 Subject: [PATCH 16/38] fix: use associated function in map_err closure --- xet_runtime/src/config/python.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xet_runtime/src/config/python.rs b/xet_runtime/src/config/python.rs index 44fbc8ae4..0d2441c0d 100644 --- a/xet_runtime/src/config/python.rs +++ b/xet_runtime/src/config/python.rs @@ -95,7 +95,7 @@ impl PythonConfigValue for ConfigEnum { fn update_from_python(&mut self, obj: &Bound<'_, PyAny>) -> PyResult<()> { let s: String = obj.extract()?; - self.try_set(&s).map_err(|e| pyo3::exceptions::PyValueError::new_err(e)) + self.try_set(&s).map_err(pyo3::exceptions::PyValueError::new_err) } } From 78de6886f7abb6e1df41727e45cec455526b2c74 Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 1 May 2026 17:57:09 +0200 Subject: [PATCH 17/38] feat: adapt Client::get_file_chunk_hashes to xetcas multi-range API The xetcas server endpoint GET /v2/file-chunk-hashes/{file_id} now takes an X-Range-Dirty header with the dirty byte ranges and returns dirty windows + opaque MerkleHashSubtree gap summaries instead of a flat chunk-hash list (xetcas#987). Update the xet-core client surface to match: - New cas_types: ChunkWindow, FileChunkHashesResponse, X_RANGE_DIRTY_HEADER. - Client::get_file_chunk_hashes signature: now takes Vec, returns the new response shape. - New Client::xorb_chunk_hash_sizes helper so the composition path can size boundary segments without re-introducing a per-chunk-list endpoint; RemoteClient stubs it (no server endpoint yet), simulation clients answer locally from xorb metadata. - Port the server's ChunkWindowBuilder state machine to xet-client so the simulation MemoryClient/LocalClient produce the new response shape locally. - range_upload: rebuild the original chunk list locally via the helper while the rest of the function is migrated to use windows + MerkleHashSubtree::merge. --- .../src/cas_client/chunk_window_builder.rs | 137 ++++++++++++++++++ xet_client/src/cas_client/interface.rs | 32 +++- xet_client/src/cas_client/mod.rs | 1 + xet_client/src/cas_client/remote_client.rs | 80 ++++++---- .../src/cas_client/simulation/local_client.rs | 64 +++++++- .../simulation/local_server/server.rs | 16 +- .../local_server/simulation_control_client.rs | 25 +++- .../cas_client/simulation/memory_client.rs | 69 ++++++++- .../simulation/simulation_client.rs | 16 +- .../simulation/simulation_server.rs | 16 +- xet_client/src/cas_types/mod.rs | 34 ++++- xet_data/src/processing/range_upload.rs | 42 +++++- 12 files changed, 463 insertions(+), 69 deletions(-) create mode 100644 xet_client/src/cas_client/chunk_window_builder.rs diff --git a/xet_client/src/cas_client/chunk_window_builder.rs b/xet_client/src/cas_client/chunk_window_builder.rs new file mode 100644 index 000000000..ec7f4552a --- /dev/null +++ b/xet_client/src/cas_client/chunk_window_builder.rs @@ -0,0 +1,137 @@ +//! State machine that classifies chunks into dirty windows and gap subtrees. +//! +//! Mirrors the server-side state machine used by `GET /v2/file-chunk-hashes/{file_id}` +//! (xetcas PR #987). We need it on the client too so that the simulation clients +//! (`MemoryClient`, `LocalClient`) can produce a [`FileChunkHashesResponse`] without +//! routing through HTTP. +//! +//! The result is `windows.len()` chunk-aligned dirty `FileRange`s and `windows.len() + 1` +//! gap subtrees (`None` for empty gaps). The producer feeds chunks in file order; +//! `finish()` returns both vectors. +//! +//! Memory note: `gap_chunks` accumulates every chunk in the current gap before being +//! rolled up into a [`MerkleHashSubtree`]. Peak memory scales with the largest contiguous +//! gap. + +use xet_core_structures::merklehash::{MerkleHash, MerkleHashSubtree}; + +use crate::cas_types::FileRange; + +pub struct ChunkWindowBuilder<'a> { + dirty_ranges: &'a [FileRange], + dirty_idx: usize, + in_dirty_zone: bool, + gap_is_first: bool, + /// End-byte cursor: the start of the next chunk fed to the builder. + cursor: u64, + gap_chunks: Vec<(MerkleHash, u64)>, + windows: Vec, + hash_ranges: Vec>, +} + +impl<'a> ChunkWindowBuilder<'a> { + pub fn new(dirty_ranges: &'a [FileRange]) -> Self { + debug_assert!( + dirty_ranges.windows(2).all(|w| w[0].end <= w[1].start), + "dirty_ranges must be sorted and non-overlapping" + ); + Self { + dirty_ranges, + dirty_idx: 0, + in_dirty_zone: false, + gap_is_first: true, + cursor: 0, + gap_chunks: Vec::new(), + windows: Vec::with_capacity(dirty_ranges.len()), + hash_ranges: Vec::new(), + } + } + + pub fn process_chunk(&mut self, hash: MerkleHash, size: u64, byte_end: u64) { + let byte_start = self.cursor; + let overlaps_dirty = self.overlaps_current_dirty(byte_start, byte_end); + + if !self.in_dirty_zone { + if overlaps_dirty { + self.open_window(byte_end); + self.in_dirty_zone = true; + } else { + self.gap_chunks.push((hash, size)); + } + } else if overlaps_dirty { + self.windows + .last_mut() + .expect("in_dirty_zone implies a window has been opened") + .end = byte_end; + self.merge_ahead(byte_end); + } else { + self.dirty_idx += 1; + self.gap_is_first = false; + + // The first clean chunk after a dirty zone may itself overlap the next + // dirty range (back-to-back dirty ranges on adjacent chunks). + let overlaps_next = self.overlaps_current_dirty(byte_start, byte_end); + if overlaps_next { + self.open_window(byte_end); + } else { + self.in_dirty_zone = false; + self.gap_chunks.push((hash, size)); + } + } + self.cursor = byte_end; + } + + /// Returns true when the entry ending at `byte_end` (and starting at the cursor) + /// is fully contained within the current dirty range. + pub fn entry_fully_dirty(&self, byte_end: u64) -> bool { + self.dirty_idx < self.dirty_ranges.len() + && self.dirty_ranges[self.dirty_idx].start <= self.cursor + && byte_end <= self.dirty_ranges[self.dirty_idx].end + } + + /// Process a fully-dirty shard entry without iterating its individual chunks. + pub fn skip_dirty_entry(&mut self, byte_end: u64) { + if !self.in_dirty_zone { + self.open_window(byte_end); + self.in_dirty_zone = true; + } else { + self.windows + .last_mut() + .expect("in_dirty_zone implies a window has been opened") + .end = byte_end; + self.merge_ahead(byte_end); + } + self.cursor = byte_end; + } + + /// Consume the builder and return the dirty windows + N+1 gap hash ranges. + pub fn finish(mut self) -> (Vec, Vec>) { + let trailing = MerkleHashSubtree::from_chunks(self.gap_is_first, &self.gap_chunks, true); + self.hash_ranges.push(Self::to_option(trailing)); + (self.windows, self.hash_ranges) + } + + fn open_window(&mut self, byte_end: u64) { + let gap = MerkleHashSubtree::from_chunks(self.gap_is_first, &self.gap_chunks, false); + self.hash_ranges.push(Self::to_option(gap)); + self.gap_chunks.clear(); + self.windows.push(FileRange::new(self.cursor, byte_end)); + self.merge_ahead(byte_end); + } + + fn overlaps_current_dirty(&self, byte_start: u64, byte_end: u64) -> bool { + self.dirty_idx < self.dirty_ranges.len() + && byte_end > self.dirty_ranges[self.dirty_idx].start + && byte_start < self.dirty_ranges[self.dirty_idx].end + } + + fn merge_ahead(&mut self, byte_end: u64) { + while self.dirty_idx + 1 < self.dirty_ranges.len() && byte_end > self.dirty_ranges[self.dirty_idx + 1].start { + self.dirty_idx += 1; + } + } + + fn to_option(hr: MerkleHashSubtree) -> Option { + if hr.num_nodes() > 0 { Some(hr) } else { None } + } +} diff --git a/xet_client/src/cas_client/interface.rs b/xet_client/src/cas_client/interface.rs index 22f003ea3..ec13e2289 100644 --- a/xet_client/src/cas_client/interface.rs +++ b/xet_client/src/cas_client/interface.rs @@ -1,11 +1,13 @@ use bytes::Bytes; -use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; +use xet_core_structures::merklehash::MerkleHash; use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; use xet_core_structures::xorb_object::SerializedXorbObject; use super::adaptive_concurrency::ConnectionPermit; use super::progress_tracked_streams::ProgressCallback; -use crate::cas_types::{BatchQueryReconstructionResponse, FileRange, HttpRange, QueryReconstructionResponseV2}; +use crate::cas_types::{ + BatchQueryReconstructionResponse, FileChunkHashesResponse, FileRange, HttpRange, QueryReconstructionResponseV2, +}; use crate::error::Result; #[async_trait::async_trait] @@ -71,7 +73,27 @@ pub trait Client: Send + Sync { upload_permit: ConnectionPermit, ) -> Result; - /// Retrieve the chunk hashes and sizes for a file stored in CAS. - /// Returns a list of (chunk_hash, chunk_uncompressed_size) pairs. - async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result; + /// Compute chunk-aligned dirty windows + opaque gap [`MerkleHashSubtree`] summaries for the + /// given file, narrowed to `dirty_ranges`. + /// + /// `dirty_ranges` must be sorted and non-overlapping. Per-chunk hashes are never returned; + /// the response carries only `windows.len()` dirty windows and `windows.len() + 1` gap + /// subtrees, which the client merges with locally-recomputed window subtrees to obtain the + /// new file hash. + async fn get_file_chunk_hashes( + &self, + file_id: &MerkleHash, + dirty_ranges: Vec, + ) -> Result; + + /// Fetch the (chunk_hash, unpacked_size) pairs for `[chunk_index_start, chunk_index_end)` + /// inside the given xorb. Used by clients that need per-chunk sizing for boundary segments + /// (e.g. `upload_ranges` composition). Sim clients answer locally from xorb metadata; the + /// remote client has no dedicated endpoint for this and currently errors. + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result>; } diff --git a/xet_client/src/cas_client/mod.rs b/xet_client/src/cas_client/mod.rs index 7b4b73ffa..f1820dc85 100644 --- a/xet_client/src/cas_client/mod.rs +++ b/xet_client/src/cas_client/mod.rs @@ -14,6 +14,7 @@ pub use crate::common::http_client::{Api, ResponseErrorLogger, build_auth_http_c pub mod adaptive_concurrency; pub mod auth; +pub mod chunk_window_builder; pub mod exports; mod interface; pub mod multipart; diff --git a/xet_client/src/cas_client/remote_client.rs b/xet_client/src/cas_client/remote_client.rs index 2bc2eb5a4..a01a74b90 100644 --- a/xet_client/src/cas_client/remote_client.rs +++ b/xet_client/src/cas_client/remote_client.rs @@ -8,9 +8,8 @@ use http::HeaderValue; use http::header::{CONTENT_LENGTH, HeaderMap, RANGE}; use reqwest::{Body, Response, StatusCode, Url}; use reqwest_middleware::ClientWithMiddleware; -use serde::Deserialize; use tracing::{event, info, instrument}; -use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; +use xet_core_structures::merklehash::MerkleHash; use xet_core_structures::metadata_shard::file_structs::{FileDataSequenceEntry, FileDataSequenceHeader, MDBFileInfo}; use xet_core_structures::xorb_object::SerializedXorbObject; use xet_runtime::core::XetContext; @@ -24,8 +23,9 @@ use super::progress_tracked_streams::{ use super::retry_wrapper::{RetryWrapper, RetryableReqwestError}; use super::{Client, INFORMATION_LOG_LEVEL}; use crate::cas_types::{ - BatchQueryReconstructionResponse, FileRange, HttpRange, Key, QueryReconstructionResponse, + BatchQueryReconstructionResponse, FileChunkHashesResponse, FileRange, HttpRange, Key, QueryReconstructionResponse, QueryReconstructionResponseV2, UploadShardResponse, UploadShardResponseType, UploadXorbResponse, + X_RANGE_DIRTY_HEADER, }; use crate::common::http_client::{self, Api}; use crate::error::{ClientError, Result}; @@ -749,44 +749,62 @@ impl Client for RemoteClient { Ok(n_upload_bytes) } - #[instrument(skip_all, name = "RemoteClient::get_file_chunk_hashes", fields(file.hash = file_id.hex()))] - async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + #[instrument(skip_all, name = "RemoteClient::get_file_chunk_hashes", fields(file.hash = file_id.hex(), n_ranges = dirty_ranges.len()))] + async fn get_file_chunk_hashes( + &self, + file_id: &MerkleHash, + dirty_ranges: Vec, + ) -> Result { + if dirty_ranges.is_empty() { + return Err(ClientError::Other("get_file_chunk_hashes requires at least one dirty range".into())); + } + let url = Url::parse(&format!("{}/v2/file-chunk-hashes/{}", self.endpoint, file_id.hex()))?; + // Encode the dirty ranges as a multi-range `bytes=A-B,C-D` value (HTTP convention is + // inclusive-end, so we subtract 1 from each FileRange's exclusive end). + let header_value = format!( + "bytes={}", + dirty_ranges + .iter() + .map(|r| format!("{}-{}", r.start, r.end.saturating_sub(1))) + .collect::>() + .join(",") + ); + let header_value = HeaderValue::from_str(&header_value) + .map_err(|err| ClientError::Other(format!("invalid X-Range-Dirty header value: {err}")))?; + let api_tag = "cas::get_file_chunk_hashes"; let client = self.authenticated_http_client.clone(); let response: FileChunkHashesResponse = RetryWrapper::new(self.ctx.clone(), api_tag) - .run_and_extract_json(move || client.get(url.clone()).with_extension(Api(api_tag)).send()) + .run_and_extract_json(move || { + client + .get(url.clone()) + .header(X_RANGE_DIRTY_HEADER, header_value.clone()) + .with_extension(Api(api_tag)) + .send() + }) .await?; - let chunks = response.chunks.into_iter().map(|entry| (entry.hash, entry.size)).collect(); - - Ok(chunks) + Ok(response) } -} - -/// Response from `GET /v2/file-chunk-hashes/{file_id}`. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct FileChunkHashesResponse { - chunks: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChunkHashEntry { - #[serde(deserialize_with = "deserialize_merkle_hash")] - hash: MerkleHash, - size: u64, -} -fn deserialize_merkle_hash<'de, D>(deserializer: D) -> std::result::Result -where - D: serde::Deserializer<'de>, -{ - let s = String::deserialize(deserializer)?; - MerkleHash::from_hex(&s).map_err(serde::de::Error::custom) + async fn xorb_chunk_hash_sizes( + &self, + _xorb_hash: &MerkleHash, + _chunk_index_start: u32, + _chunk_index_end: u32, + ) -> Result> { + // No remote endpoint exists today for "give me per-chunk sizes within a xorb". The + // composition path in `upload_ranges` only needs this for the 1–2 boundary segments + // per dirty window, so a follow-up server endpoint or piggy-backing on reconstruction + // info is the natural fix. For now, fail loudly so callers know to use simulation + // clients only. + Err(ClientError::Other( + "RemoteClient::xorb_chunk_hash_sizes not yet implemented; use a simulation client".into(), + )) + } } #[cfg(test)] diff --git a/xet_client/src/cas_client/simulation/local_client.rs b/xet_client/src/cas_client/simulation/local_client.rs index ec3b1b37c..a1a38baa4 100644 --- a/xet_client/src/cas_client/simulation/local_client.rs +++ b/xet_client/src/cas_client/simulation/local_client.rs @@ -15,7 +15,7 @@ use redb::{ReadableDatabase, ReadableTable, TableDefinition}; use tempfile::TempDir; use tokio::time::{Duration, Instant}; use tracing::{error, info, warn}; -use xet_core_structures::merklehash::{ChunkHashList, MerkleHash, compute_data_hash}; +use xet_core_structures::merklehash::{MerkleHash, compute_data_hash}; use xet_core_structures::metadata_shard::file_structs::{FileDataSequenceHeader, MDBFileInfo, MDBFileInfoView}; use xet_core_structures::metadata_shard::shard_file_reconstructor::FileReconstructor; use xet_core_structures::metadata_shard::shard_format::MDB_FILE_INFO_ENTRY_SIZE; @@ -36,10 +36,12 @@ use super::direct_access_client::DirectAccessClient; use super::xorb_utils::{self, REFERENCE_INSTANT, duration_to_expiration_secs_ceil}; use crate::cas_client::Client; use crate::cas_client::adaptive_concurrency::AdaptiveConcurrencyController; +use crate::cas_client::chunk_window_builder::ChunkWindowBuilder; use crate::cas_client::progress_tracked_streams::ProgressCallback; use crate::cas_types::{ - BatchQueryReconstructionResponse, FileRange, HexMerkleHash, HttpRange, QueryReconstructionResponse, - QueryReconstructionResponseV2, XorbMultiRangeFetch, XorbRangeDescriptor, XorbReconstructionFetchInfo, + BatchQueryReconstructionResponse, ChunkWindow, FileChunkHashesResponse, FileRange, HexMerkleHash, HttpRange, + QueryReconstructionResponse, QueryReconstructionResponseV2, XorbMultiRangeFetch, XorbRangeDescriptor, + XorbReconstructionFetchInfo, }; use crate::error::{ClientError, Result}; @@ -1694,23 +1696,71 @@ impl Client for LocalClient { Err(ClientError::PresignedUrlExpirationError) } - async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + async fn get_file_chunk_hashes( + &self, + file_id: &MerkleHash, + dirty_ranges: Vec, + ) -> Result { self.apply_api_delay().await; let Some((file_info, _)) = self.shard_manager.get_file_reconstruction_info(file_id).await? else { return Err(ClientError::FileNotFound(*file_id)); }; - let mut result = Vec::new(); + let file_size: u64 = file_info.segments.iter().map(|s| s.unpacked_segment_bytes as u64).sum(); + let dirty_ranges: Vec = dirty_ranges + .into_iter() + .map(|r| FileRange::new(r.start, r.end.min(file_size))) + .filter(|r| r.start < r.end && r.start < file_size) + .collect(); + if dirty_ranges.is_empty() { + return Err(ClientError::Other("no valid dirty ranges".into())); + } + + let mut builder = ChunkWindowBuilder::new(&dirty_ranges); + let mut cumulative_bytes: u64 = 0; + let mut total_chunks: u64 = 0; + for segment in &file_info.segments { let xorb_obj = self.xorb_footer(&segment.xorb_hash).await?; let pairs = xorb_obj .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}")))?; - result.extend(pairs); + for (hash, size) in pairs { + cumulative_bytes += size; + builder.process_chunk(hash, size, cumulative_bytes); + total_chunks += 1; + } } - Ok(result) + let (windows, hash_ranges) = builder.finish(); + if windows.is_empty() { + return Err(ClientError::Other("dirty ranges do not overlap any chunks".into())); + } + + Ok(FileChunkHashesResponse { + total_chunks, + file_size, + windows: windows + .into_iter() + .map(|r| ChunkWindow { + dirty_byte_range: [r.start, r.end], + }) + .collect(), + hash_ranges, + }) + } + + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result> { + let xorb_obj = self.xorb_footer(xorb_hash).await?; + xorb_obj + .chunk_hash_sizes(chunk_index_start, chunk_index_end) + .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}"))) } } diff --git a/xet_client/src/cas_client/simulation/local_server/server.rs b/xet_client/src/cas_client/simulation/local_server/server.rs index b9e81ab15..64ba8d6ca 100644 --- a/xet_client/src/cas_client/simulation/local_server/server.rs +++ b/xet_client/src/cas_client/simulation/local_server/server.rs @@ -497,8 +497,20 @@ impl Client for LocalTestServer { async fn get_file_chunk_hashes( &self, file_id: &xet_core_structures::merklehash::MerkleHash, - ) -> Result { - self.client.get_file_chunk_hashes(file_id).await + dirty_ranges: Vec, + ) -> Result { + self.client.get_file_chunk_hashes(file_id, dirty_ranges).await + } + + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &xet_core_structures::merklehash::MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result> { + self.client + .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) + .await } } diff --git a/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs b/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs index 64b9ee48e..7c872eb39 100644 --- a/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs +++ b/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use bytes::Bytes; use http::header::HeaderMap; -use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; +use xet_core_structures::merklehash::MerkleHash; use xet_core_structures::xorb_object::XorbObject; use xet_runtime::core::XetContext; @@ -19,7 +19,9 @@ use crate::cas_client::interface::Client; use crate::cas_client::simulation::deletion_controls::ObjectTag; use crate::cas_client::simulation::xorb_utils::duration_to_expiration_secs_ceil; use crate::cas_client::simulation::{DeletionControlableClient, DirectAccessClient}; -use crate::cas_types::{FileRange, HexMerkleHash, QueryReconstructionResponseV2, XorbReconstructionFetchInfo}; +use crate::cas_types::{ + FileChunkHashesResponse, FileRange, HexMerkleHash, QueryReconstructionResponseV2, XorbReconstructionFetchInfo, +}; use crate::error::{ClientError, Result}; const CONFIG_POST_MAX_ATTEMPTS: usize = 4; @@ -244,8 +246,23 @@ impl Client for SimulationControlClient { .await } - async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { - self.remote_client.get_file_chunk_hashes(file_id).await + async fn get_file_chunk_hashes( + &self, + file_id: &MerkleHash, + dirty_ranges: Vec, + ) -> Result { + self.remote_client.get_file_chunk_hashes(file_id, dirty_ranges).await + } + + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result> { + self.remote_client + .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) + .await } } diff --git a/xet_client/src/cas_client/simulation/memory_client.rs b/xet_client/src/cas_client/simulation/memory_client.rs index e56f7f3e4..199968534 100644 --- a/xet_client/src/cas_client/simulation/memory_client.rs +++ b/xet_client/src/cas_client/simulation/memory_client.rs @@ -11,9 +11,9 @@ use tokio::sync::RwLock; use tokio::time::{Duration, Instant}; use tracing::{error, info}; use xet_core_structures::MerkleHashMap; +use xet_core_structures::merklehash::MerkleHash; #[cfg(not(target_family = "wasm"))] use xet_core_structures::merklehash::compute_data_hash; -use xet_core_structures::merklehash::{ChunkHashList, MerkleHash}; use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; use xet_core_structures::metadata_shard::shard_in_memory::MDBInMemoryShard; use xet_core_structures::metadata_shard::streaming_shard::MDBMinimalShard; @@ -30,9 +30,11 @@ use super::deletion_controls::ObjectTag; use super::direct_access_client::DirectAccessClient; use super::random_xorb::RandomXorb; use super::xorb_utils::{self, REFERENCE_INSTANT, duration_to_expiration_secs_ceil}; +use crate::cas_client::chunk_window_builder::ChunkWindowBuilder; use crate::cas_types::{ - BatchQueryReconstructionResponse, FileRange, HexMerkleHash, HttpRange, QueryReconstructionResponse, - QueryReconstructionResponseV2, XorbMultiRangeFetch, XorbRangeDescriptor, XorbReconstructionFetchInfo, + BatchQueryReconstructionResponse, ChunkWindow, FileChunkHashesResponse, FileRange, HexMerkleHash, HttpRange, + QueryReconstructionResponse, QueryReconstructionResponseV2, XorbMultiRangeFetch, XorbRangeDescriptor, + XorbReconstructionFetchInfo, }; use crate::error::{ClientError, Result}; @@ -951,7 +953,11 @@ impl Client for MemoryClient { Ok((Bytes::from(all_decompressed), all_chunk_indices)) } - async fn get_file_chunk_hashes(&self, file_id: &MerkleHash) -> Result { + async fn get_file_chunk_hashes( + &self, + file_id: &MerkleHash, + dirty_ranges: Vec, + ) -> Result { self.apply_api_delay().await; let file_info = { @@ -961,8 +967,20 @@ impl Client for MemoryClient { .ok_or(ClientError::FileNotFound(*file_id))? }; + let file_size: u64 = file_info.segments.iter().map(|s| s.unpacked_segment_bytes as u64).sum(); + let dirty_ranges: Vec = dirty_ranges + .into_iter() + .map(|r| FileRange::new(r.start, r.end.min(file_size))) + .filter(|r| r.start < r.end && r.start < file_size) + .collect(); + if dirty_ranges.is_empty() { + return Err(ClientError::Other("no valid dirty ranges".into())); + } + let xorbs = self.xorbs.read().await; - let mut result = Vec::new(); + let mut builder = ChunkWindowBuilder::new(&dirty_ranges); + let mut cumulative_bytes: u64 = 0; + let mut total_chunks: u64 = 0; for segment in &file_info.segments { let storage = xorbs @@ -977,10 +995,47 @@ impl Client for MemoryClient { let pairs = xorb_obj .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}")))?; - result.extend(pairs); + + for (hash, size) in pairs { + cumulative_bytes += size; + builder.process_chunk(hash, size, cumulative_bytes); + total_chunks += 1; + } + } + + let (windows, hash_ranges) = builder.finish(); + if windows.is_empty() { + return Err(ClientError::Other("dirty ranges do not overlap any chunks".into())); } - Ok(result) + Ok(FileChunkHashesResponse { + total_chunks, + file_size, + windows: windows + .into_iter() + .map(|r| ChunkWindow { + dirty_byte_range: [r.start, r.end], + }) + .collect(), + hash_ranges, + }) + } + + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result> { + let xorbs = self.xorbs.read().await; + let storage = xorbs.get(xorb_hash).ok_or(ClientError::XORBNotFound(*xorb_hash))?; + let xorb_obj = match storage { + XorbStorage::Materialized { entry, .. } => std::borrow::Cow::Borrowed(&entry.xorb_object), + XorbStorage::Random { xorb, .. } => std::borrow::Cow::Owned(xorb.get_xorb_object()), + }; + xorb_obj + .chunk_hash_sizes(chunk_index_start, chunk_index_end) + .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}"))) } } diff --git a/xet_client/src/cas_client/simulation/simulation_client.rs b/xet_client/src/cas_client/simulation/simulation_client.rs index b54ec6cc1..bf402d2f3 100644 --- a/xet_client/src/cas_client/simulation/simulation_client.rs +++ b/xet_client/src/cas_client/simulation/simulation_client.rs @@ -238,7 +238,19 @@ impl Client for RemoteSimulationClient { async fn get_file_chunk_hashes( &self, file_id: &xet_core_structures::merklehash::MerkleHash, - ) -> Result { - self.inner.get_file_chunk_hashes(file_id).await + dirty_ranges: Vec, + ) -> Result { + self.inner.get_file_chunk_hashes(file_id, dirty_ranges).await + } + + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &xet_core_structures::merklehash::MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result> { + self.inner + .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) + .await } } diff --git a/xet_client/src/cas_client/simulation/simulation_server.rs b/xet_client/src/cas_client/simulation/simulation_server.rs index 5194bde2f..11cc7a582 100644 --- a/xet_client/src/cas_client/simulation/simulation_server.rs +++ b/xet_client/src/cas_client/simulation/simulation_server.rs @@ -545,8 +545,20 @@ impl Client for LocalTestServer { async fn get_file_chunk_hashes( &self, file_id: &xet_core_structures::merklehash::MerkleHash, - ) -> Result { - self.client.get_file_chunk_hashes(file_id).await + dirty_ranges: Vec, + ) -> Result { + self.client.get_file_chunk_hashes(file_id, dirty_ranges).await + } + + async fn xorb_chunk_hash_sizes( + &self, + xorb_hash: &xet_core_structures::merklehash::MerkleHash, + chunk_index_start: u32, + chunk_index_end: u32, + ) -> Result> { + self.client + .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) + .await } } diff --git a/xet_client/src/cas_types/mod.rs b/xet_client/src/cas_types/mod.rs index db05f6af1..ce4837387 100644 --- a/xet_client/src/cas_types/mod.rs +++ b/xet_client/src/cas_types/mod.rs @@ -7,7 +7,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use thiserror::Error; -use xet_core_structures::merklehash::MerkleHash; +use xet_core_structures::merklehash::{MerkleHash, MerkleHashSubtree}; mod key; pub use key::*; @@ -311,6 +311,38 @@ pub struct QueryChunkResponse { pub shard: MerkleHash, } +/// HTTP header carrying the dirty byte ranges to feed to `GET /v2/file-chunk-hashes/{file_id}`. +/// +/// Distinct from the standard `Range` header (which scopes the response body): this header tags +/// regions that the client intends to re-chunk, and the response covers the whole file (windows + +/// gap subtrees). Value uses the same `bytes=A-B,C-D` syntax as `Range`. +pub const X_RANGE_DIRTY_HEADER: &str = "x-range-dirty"; + +/// One chunk-aligned dirty window of a file, returned by `GET /v2/file-chunk-hashes/{file_id}`. +/// +/// `dirty_byte_range` is `[start, end)` and is expanded outward to the chunk boundaries that +/// fully contain the requested dirty range, so the client must re-chunk the entire span. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChunkWindow { + pub dirty_byte_range: [u64; 2], +} + +/// Response shape for `GET /v2/file-chunk-hashes/{file_id}`. +/// +/// Contains `windows.len()` dirty windows interleaved with `windows.len() + 1` opaque +/// `MerkleHashSubtree` summaries for the surrounding gaps. To reconstruct the new file hash, +/// merge `[hash_ranges[0], window0_subtree, hash_ranges[1], window1_subtree, ..., hash_ranges[N]]` +/// using `MerkleHashSubtree::merge`. Per-chunk hashes are never transferred. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FileChunkHashesResponse { + pub total_chunks: u64, + pub file_size: u64, + pub windows: Vec, + pub hash_ranges: Vec>, +} + #[cfg(test)] mod tests { use super::*; diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 48b43b5da..8a94e15a8 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -145,14 +145,17 @@ pub async fn upload_ranges( } } - // 1. Fetch chunk hashes and reconstruction info in parallel. - let (original_chunks, recon_result) = tokio::try_join!( - cas_client.get_file_chunk_hashes(&original_hash), - cas_client.get_file_reconstruction_info(&original_hash), - )?; + // 1. Fetch reconstruction info and rebuild the per-chunk (hash, size) list locally by walking the original file's + // segments and querying each xorb's chunk metadata. The previous flat `get_file_chunk_hashes` server endpoint + // was replaced by the windows + gap-subtree response in xetcas PR #987; until we migrate the rest of this + // function to that response shape, we recompose the original chunk list here so the existing composition logic + // keeps working unchanged. Hot follow-up: drop this local reconstitution and use `MerkleHashSubtree::merge` over + // windows + gap ranges for the final hash. + let recon_result = cas_client.get_file_reconstruction_info(&original_hash).await?; let original_mdb = recon_result .map(|(mdb, _)| mdb) .ok_or_else(|| DataError::InternalError("no reconstruction info for original file".into()))?; + let original_chunks = collect_original_chunks(&cas_client, &original_mdb).await?; // 2. Map chunks to cumulative byte offsets. // This builds a sorted array of byte boundaries, where chunk_offsets[i] is the @@ -495,6 +498,21 @@ pub async fn upload_ranges( Ok(XetFileInfo::new(combined_hash.hex(), total_size)) } +/// Rebuild the per-chunk `(hash, size)` list for the given file by querying each +/// segment's xorb metadata. Replaces the old flat `get_file_chunk_hashes` response +/// while the rest of `upload_ranges` is migrated to use the new windows + gap-subtree +/// response shape. +async fn collect_original_chunks(cas_client: &Arc, original_mdb: &MDBFileInfo) -> Result { + let mut chunks = Vec::new(); + for segment in &original_mdb.segments { + let pairs = cas_client + .xorb_chunk_hash_sizes(&segment.xorb_hash, segment.chunk_index_start, segment.chunk_index_end) + .await?; + chunks.extend(pairs); + } + Ok(chunks) +} + /// Stream a byte range from CAS into the cleaner. async fn stream_cas_range( ctx: &XetContext, @@ -663,6 +681,14 @@ mod tests { Arc::new(TranslatorConfig::test_server_config(&ctx, endpoint, base_dir).unwrap()) } + /// Test helper: rebuild the per-chunk `(hash, size)` list for a file by walking its + /// segments. Mirrors `super::collect_original_chunks` but takes the file hash directly + /// so callers don't have to plumb the reconstruction info themselves. + async fn fetch_original_chunks(cas_client: &Arc, hash: &MerkleHash) -> super::ChunkHashList { + let (mdb, _) = cas_client.get_file_reconstruction_info(hash).await.unwrap().unwrap(); + super::collect_original_chunks(cas_client, &mdb).await.unwrap() + } + /// Build `DirtyInput`s from a source buffer and range list. Each input gets /// a `Cursor` over the corresponding slice of `data`. fn make_dirty_inputs(ranges: &[(u64, u64)], data: &[u8]) -> Vec { @@ -928,7 +954,7 @@ mod tests { let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); + let chunks = fetch_original_chunks(&cas_client, &original_hash).await; assert!(chunks.len() >= 4, "expected at least 4 chunks, got {}", chunks.len()); let mut offsets = vec![0u64]; @@ -981,7 +1007,7 @@ mod tests { let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); + let chunks = fetch_original_chunks(&cas_client, &original_hash).await; assert!(chunks.len() >= 2, "need at least 2 chunks for this test"); // Truncate exactly at the boundary after the first chunk. @@ -1165,7 +1191,7 @@ mod tests { }) .collect(); let original_hash = upload_file(&config, &original).await; - let chunks = cas_client.get_file_chunk_hashes(&original_hash).await.unwrap(); + let chunks = fetch_original_chunks(&cas_client, &original_hash).await; if chunks.len() >= 3 { let boundary: u64 = chunks[0].1 + chunks[1].1; let dirty_end = boundary + chunks[2].1; From a9d9cfe672ce296cb8ff928eac06a9e726caa0a7 Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 1 May 2026 19:30:55 +0200 Subject: [PATCH 18/38] refactor: drop ChunkHashList composition, use MerkleHashSubtree::merge upload_ranges now consumes the new windows + gap-subtree response shape end-to-end instead of recomposing the legacy flat chunk list: - Drop the xorb_chunk_hash_sizes Client trait method and its sim-only implementations (added as a temporary bridge). - Pre-snap the caller's dirty ranges to the original file's segment byte boundaries before calling get_file_chunk_hashes. Because segment edges are also chunk edges, the server's chunk-aligned windows come back equal to our snapped ranges, which means MDB composition can swap whole segments without needing per-chunk byte sizes. - Compose the final file hash by merging the server's gap subtrees with locally-built window subtrees (MerkleHashSubtree::merge). Apply the zero- salt HMAC to match `file_hash` (the cleaner's own output for files without an SHA-256 metadata extension, which is what upload_ranges produces). - Compose the final MDBFileInfo by walking the original segments by byte cursor and substituting window-overlapping segments with the per-window MDB pulled from the session checkpoint. Carry verification entries when the original file had them; otherwise emit a verification-less MDB. - Remove the obsolete build_dirty_regions / extract_segments helpers and the chunk-level unit tests that exercised them; remove two scenario tests that probed chunk-boundary behavior which no longer applies under segment alignment. 19/19 remaining range_upload tests pass. --- xet_client/src/cas_client/interface.rs | 11 - xet_client/src/cas_client/remote_client.rs | 16 - .../src/cas_client/simulation/local_client.rs | 12 - .../simulation/local_server/server.rs | 11 - .../local_server/simulation_control_client.rs | 11 - .../cas_client/simulation/memory_client.rs | 17 - .../simulation/simulation_client.rs | 11 - .../simulation/simulation_server.rs | 11 - xet_data/src/processing/range_upload.rs | 822 +++++------------- 9 files changed, 236 insertions(+), 686 deletions(-) diff --git a/xet_client/src/cas_client/interface.rs b/xet_client/src/cas_client/interface.rs index ec13e2289..2e36168a5 100644 --- a/xet_client/src/cas_client/interface.rs +++ b/xet_client/src/cas_client/interface.rs @@ -85,15 +85,4 @@ pub trait Client: Send + Sync { file_id: &MerkleHash, dirty_ranges: Vec, ) -> Result; - - /// Fetch the (chunk_hash, unpacked_size) pairs for `[chunk_index_start, chunk_index_end)` - /// inside the given xorb. Used by clients that need per-chunk sizing for boundary segments - /// (e.g. `upload_ranges` composition). Sim clients answer locally from xorb metadata; the - /// remote client has no dedicated endpoint for this and currently errors. - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result>; } diff --git a/xet_client/src/cas_client/remote_client.rs b/xet_client/src/cas_client/remote_client.rs index a01a74b90..6528da9bf 100644 --- a/xet_client/src/cas_client/remote_client.rs +++ b/xet_client/src/cas_client/remote_client.rs @@ -789,22 +789,6 @@ impl Client for RemoteClient { Ok(response) } - - async fn xorb_chunk_hash_sizes( - &self, - _xorb_hash: &MerkleHash, - _chunk_index_start: u32, - _chunk_index_end: u32, - ) -> Result> { - // No remote endpoint exists today for "give me per-chunk sizes within a xorb". The - // composition path in `upload_ranges` only needs this for the 1–2 boundary segments - // per dirty window, so a follow-up server endpoint or piggy-backing on reconstruction - // info is the natural fix. For now, fail loudly so callers know to use simulation - // clients only. - Err(ClientError::Other( - "RemoteClient::xorb_chunk_hash_sizes not yet implemented; use a simulation client".into(), - )) - } } #[cfg(test)] diff --git a/xet_client/src/cas_client/simulation/local_client.rs b/xet_client/src/cas_client/simulation/local_client.rs index a1a38baa4..d0f5489b7 100644 --- a/xet_client/src/cas_client/simulation/local_client.rs +++ b/xet_client/src/cas_client/simulation/local_client.rs @@ -1750,18 +1750,6 @@ impl Client for LocalClient { hash_ranges, }) } - - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result> { - let xorb_obj = self.xorb_footer(xorb_hash).await?; - xorb_obj - .chunk_hash_sizes(chunk_index_start, chunk_index_end) - .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}"))) - } } fn map_redb_db_error(e: impl std::fmt::Debug) -> ClientError { diff --git a/xet_client/src/cas_client/simulation/local_server/server.rs b/xet_client/src/cas_client/simulation/local_server/server.rs index 64ba8d6ca..c1f1fc01d 100644 --- a/xet_client/src/cas_client/simulation/local_server/server.rs +++ b/xet_client/src/cas_client/simulation/local_server/server.rs @@ -501,17 +501,6 @@ impl Client for LocalTestServer { ) -> Result { self.client.get_file_chunk_hashes(file_id, dirty_ranges).await } - - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &xet_core_structures::merklehash::MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result> { - self.client - .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) - .await - } } #[cfg(test)] diff --git a/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs b/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs index 7c872eb39..fc3197617 100644 --- a/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs +++ b/xet_client/src/cas_client/simulation/local_server/simulation_control_client.rs @@ -253,17 +253,6 @@ impl Client for SimulationControlClient { ) -> Result { self.remote_client.get_file_chunk_hashes(file_id, dirty_ranges).await } - - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result> { - self.remote_client - .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) - .await - } } #[async_trait] diff --git a/xet_client/src/cas_client/simulation/memory_client.rs b/xet_client/src/cas_client/simulation/memory_client.rs index 199968534..fdf3c436f 100644 --- a/xet_client/src/cas_client/simulation/memory_client.rs +++ b/xet_client/src/cas_client/simulation/memory_client.rs @@ -1020,23 +1020,6 @@ impl Client for MemoryClient { hash_ranges, }) } - - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result> { - let xorbs = self.xorbs.read().await; - let storage = xorbs.get(xorb_hash).ok_or(ClientError::XORBNotFound(*xorb_hash))?; - let xorb_obj = match storage { - XorbStorage::Materialized { entry, .. } => std::borrow::Cow::Borrowed(&entry.xorb_object), - XorbStorage::Random { xorb, .. } => std::borrow::Cow::Owned(xorb.get_xorb_object()), - }; - xorb_obj - .chunk_hash_sizes(chunk_index_start, chunk_index_end) - .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}"))) - } } #[cfg(not(target_family = "wasm"))] diff --git a/xet_client/src/cas_client/simulation/simulation_client.rs b/xet_client/src/cas_client/simulation/simulation_client.rs index bf402d2f3..4deab2b0f 100644 --- a/xet_client/src/cas_client/simulation/simulation_client.rs +++ b/xet_client/src/cas_client/simulation/simulation_client.rs @@ -242,15 +242,4 @@ impl Client for RemoteSimulationClient { ) -> Result { self.inner.get_file_chunk_hashes(file_id, dirty_ranges).await } - - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &xet_core_structures::merklehash::MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result> { - self.inner - .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) - .await - } } diff --git a/xet_client/src/cas_client/simulation/simulation_server.rs b/xet_client/src/cas_client/simulation/simulation_server.rs index 11cc7a582..a0d4fa642 100644 --- a/xet_client/src/cas_client/simulation/simulation_server.rs +++ b/xet_client/src/cas_client/simulation/simulation_server.rs @@ -549,17 +549,6 @@ impl Client for LocalTestServer { ) -> Result { self.client.get_file_chunk_hashes(file_id, dirty_ranges).await } - - async fn xorb_chunk_hash_sizes( - &self, - xorb_hash: &xet_core_structures::merklehash::MerkleHash, - chunk_index_start: u32, - chunk_index_end: u32, - ) -> Result> { - self.client - .xorb_chunk_hash_sizes(xorb_hash, chunk_index_start, chunk_index_end) - .await - } } #[async_trait] diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 8a94e15a8..dae0499fd 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -6,9 +6,8 @@ use std::sync::Arc; use tokio::io::{AsyncRead, AsyncReadExt}; use tracing::{debug, info}; use xet_client::cas_client::Client; -use xet_client::cas_types::FileRange; -use xet_core_structures::merklehash::{ChunkHashList, MerkleHash, file_hash}; -use xet_core_structures::metadata_shard::chunk_verification::range_hash_from_chunks; +use xet_client::cas_types::{FileChunkHashesResponse, FileRange}; +use xet_core_structures::merklehash::{ChunkHashList, MerkleHash, MerkleHashSubtree}; use xet_core_structures::metadata_shard::file_structs::{ FileDataSequenceEntry, FileDataSequenceHeader, FileVerificationEntry, MDBFileInfo, }; @@ -33,28 +32,16 @@ pub struct DirtyInput { /// Size of blocks read from the dirty source and fed to the cleaner. const STREAM_BLOCK_SIZE: usize = 4 * 1024 * 1024; // 4 MB -/// A dirty byte range expanded to chunk-aligned boundaries. -struct DirtyRegion { - dirty_start: u64, - dirty_end: u64, - first_chunk: usize, // inclusive - last_chunk: usize, // exclusive -} - -/// Result of uploading a single dirty region through the cleaner. -struct UploadedRegion { - region: DirtyRegion, +/// A dirty window the server told us to re-upload, augmented with the bytes the cleaner +/// produced and the resulting per-window file info. `start`/`end` are file byte offsets +/// extended for append/truncation past `original_size` if needed. +struct UploadedWindow { + start: u64, + end: u64, info: XetFileInfo, chunks: ChunkHashList, } -/// A dirty region paired with its MDBFileInfo and chunk hashes. -struct ComposedRegion { - region: DirtyRegion, - mdb: MDBFileInfo, - chunks: ChunkHashList, -} - /// Upload modified ranges of an existing file, composing the result with /// the original file's CAS segments. Only the dirty regions (plus CDC boundary /// chunks) are re-uploaded; stable regions between and around dirty ranges are @@ -99,42 +86,36 @@ pub async fn upload_ranges( return Ok(XetFileInfo::new(original_hash.hex(), original_size)); } - // Extract ranges for validation and build_dirty_regions. - let dirty_ranges: Vec<(u64, u64)> = dirty_inputs.iter().map(|d| (d.range.start, d.range.end)).collect(); + // Validate the caller-provided dirty ranges. + let dirty_ranges_pairs: Vec<(u64, u64)> = dirty_inputs.iter().map(|d| (d.range.start, d.range.end)).collect(); - if !dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0) { + if !dirty_ranges_pairs.windows(2).all(|w| w[0].1 <= w[1].0) { return Err(DataError::InternalError(format!( - "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" + "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges_pairs:?}" ))); } - if !dirty_ranges.iter().all(|&(s, e)| s < e) { + if !dirty_ranges_pairs.iter().all(|&(s, e)| s < e) { return Err(DataError::InternalError(format!( - "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" + "dirty_ranges must be non-empty intervals, got: {dirty_ranges_pairs:?}" ))); } - if let Some(&(_, last_end)) = dirty_ranges.last() + if let Some(&(_, last_end)) = dirty_ranges_pairs.last() && last_end > total_size { return Err(DataError::InternalError(format!( "dirty_range end ({last_end}) exceeds total_size ({total_size})" ))); } - - // Appended bytes must be fully covered by dirty_inputs (CAS has no data beyond original_size). - // Check that: (1) inputs reach total_size, and (2) no gap exists beyond original_size. if total_size > original_size { - let last_input_end = dirty_ranges.last().map_or(0, |&(_, e)| e); + let last_input_end = dirty_ranges_pairs.last().map_or(0, |&(_, e)| e); if last_input_end < total_size { return Err(DataError::InternalError(format!( "total_size ({total_size}) > original_size ({original_size}) but dirty_inputs \ only cover up to byte {last_input_end} (must reach total_size)" ))); } - - // Verify no gaps beyond original_size between inputs. Walk the append region - // [original_size, total_size) and ensure it is fully covered. let mut covered_up_to = original_size; - for &(start, end) in &dirty_ranges { + for &(start, end) in &dirty_ranges_pairs { if start > covered_up_to && covered_up_to >= original_size { return Err(DataError::InternalError(format!( "gap in append region: bytes [{covered_up_to}, {start}) are beyond \ @@ -145,189 +126,131 @@ pub async fn upload_ranges( } } - // 1. Fetch reconstruction info and rebuild the per-chunk (hash, size) list locally by walking the original file's - // segments and querying each xorb's chunk metadata. The previous flat `get_file_chunk_hashes` server endpoint - // was replaced by the windows + gap-subtree response in xetcas PR #987; until we migrate the rest of this - // function to that response shape, we recompose the original chunk list here so the existing composition logic - // keeps working unchanged. Hot follow-up: drop this local reconstitution and use `MerkleHashSubtree::merge` over - // windows + gap ranges for the final hash. + // 1. Fetch the original file's MDB. We need the segments to compose the new file's MDBFileInfo and to compute + // segment byte boundaries for snapping. let recon_result = cas_client.get_file_reconstruction_info(&original_hash).await?; let original_mdb = recon_result .map(|(mdb, _)| mdb) .ok_or_else(|| DataError::InternalError("no reconstruction info for original file".into()))?; - let original_chunks = collect_original_chunks(&cas_client, &original_mdb).await?; - // 2. Map chunks to cumulative byte offsets. - // This builds a sorted array of byte boundaries, where chunk_offsets[i] is the - // start byte of chunk[i]. chunk_offsets has len = original_chunks.len() + 1, - // and chunk_offsets[i+1] is the end byte of chunk[i]. - // - // Example with 3 chunks of sizes [100, 200, 150]: - // chunk_offsets = [0, 100, 300, 450] - // ^ ^ ^ ^ - // | | | +-- end of chunk[2] - // | | +------ end of chunk[1] = start of chunk[2] - // | +---------- end of chunk[0] = start of chunk[1] - // +-------------- start of chunk[0] - let mut chunk_offsets: Vec = Vec::with_capacity(original_chunks.len() + 1); - let mut offset = 0u64; - chunk_offsets.push(0); - for (_, size) in &original_chunks { - offset += size; - chunk_offsets.push(offset); + // Cumulative byte boundaries between segments. `seg_byte_starts[i]` is the first byte + // of segment `i`; `seg_byte_starts[segments.len()]` equals `original_size`. + let mut seg_byte_starts: Vec = Vec::with_capacity(original_mdb.segments.len() + 1); + seg_byte_starts.push(0); + let mut acc = 0u64; + for s in &original_mdb.segments { + acc += s.unpacked_segment_bytes as u64; + seg_byte_starts.push(acc); } - - // 3. Build effective dirty ranges. - // - // INVARIANT: dirty_ranges only contains ranges whose bytes are provided by - // dirty_inputs. Internally needed ranges (truncation boundary) are handled via - // injected DirtyRegions with empty dirty spans, so CAS provides the bytes. - // For appends, the caller must include the appended region in dirty_inputs. - - let num_chunks = original_chunks.len(); - // Number of original chunks to keep in the final composition. - let mut compose_num_chunks = num_chunks; - let mut truncation_boundary: Option<(u64, usize)> = None; - + debug_assert_eq!(*seg_byte_starts.last().unwrap_or(&0), original_size, "segments must sum to original_size"); + + // 2. Build segment-aligned dirty ranges to send to the server. Snapping to segment boundaries (rather than just + // chunk boundaries) lets us swap whole segments during composition, so we never need to truncate a segment + // mid-chunk on the client. This is strictly safe: segment boundaries are also chunk boundaries, so the server's + // chunk-aligned windows come back equal to our snapped ranges. + let mut snapped: Vec<(u64, u64)> = Vec::new(); + for &(start, end) in &dirty_ranges_pairs { + let in_start = start.min(original_size); + let in_end = end.min(original_size); + if in_start >= in_end { + continue; + } + snapped + .push((snap_to_segment_start(&seg_byte_starts, in_start), snap_to_segment_end(&seg_byte_starts, in_end))); + } + // Truncation: ensure the segment containing the cut is in the upload set so we + // can re-upload it with its truncated tail. if total_size < original_size { - // Truncation: when the cut point falls mid-chunk, we can't reuse that chunk - // (CAS chunks are immutable). We re-upload bytes from the last full chunk - // boundary up to total_size, and only keep chunks entirely before the cut. - // - // Example: truncate from 450 to 250 bytes. - // - // chunk[0]=[0,100) chunk[1]=[100,300) chunk[2]=[300,450) - // ^--- cut at 250 falls here - // - // chunk[0]: fully before cut -> reuse (stable) - // chunk[1]: partially before -> re-upload bytes [100, 250) - // chunk[2]: fully after cut -> discard - let last_full = chunk_offsets.iter().rposition(|&o| o <= total_size).unwrap_or(0); - compose_num_chunks = last_full; - let boundary = chunk_offsets[last_full]; - if boundary < total_size { - // Cut falls mid-chunk: the partial chunk [boundary, total_size) must be - // re-uploaded. We track it here and inject a DirtyRegion after - // build_dirty_regions, rather than adding it to dirty_ranges, - // because the bytes live in CAS (not in the caller's staging file). - truncation_boundary = Some((boundary, last_full)); + let snap_start = snap_to_segment_start(&seg_byte_starts, total_size); + if snap_start < original_size { + let snap_end = seg_byte_starts + .iter() + .copied() + .find(|&s| s > total_size) + .unwrap_or(original_size); + snapped.push((snap_start, snap_end)); } } - // For appends (total_size > original_size), the caller must include the appended bytes - // in dirty_inputs. The last original chunk is re-chunked automatically via the - // first_chunk adjustment in build_dirty_regions, with its bytes read from CAS via - // the boundary prefix mechanism. - - // Note: if dirty_ranges is empty here, it means pure truncation (no dirty ranges, - // file shrunk). We still proceed to compose a new file from the truncated chunk set. + // Pure append (no in-original modifications, total_size > original_size): re-upload + // the last original segment so the appended bytes cleanly extend its boundary. + if snapped.is_empty() && total_size > original_size && !original_mdb.segments.is_empty() { + let n = original_mdb.segments.len(); + snapped.push((seg_byte_starts[n - 1], seg_byte_starts[n])); + } - // 4. Expand dirty byte ranges to chunk-aligned boundaries. - // - // A dirty range rarely starts/ends on a chunk boundary. Since CAS chunks are - // atomic (can't reuse half a chunk), we expand each range to cover every chunk - // it touches. Adjacent/overlapping regions are then coalesced. - // - // chunk[0]=[0,100) chunk[1]=[100,300) chunk[2]=[300,450) - // - // dirty bytes [150, 350) - // ^ ^ - // | +-- inside chunk[2] - // +------- inside chunk[1] - // - // -> expand to chunks [1, 3) (chunks 1 and 2 must be re-uploaded) - let mut dirty_regions = build_dirty_regions(&dirty_ranges, &chunk_offsets, num_chunks, original_size, total_size)?; - - // If truncation cuts mid-chunk and no caller dirty range already covers that - // chunk, inject a DirtyRegion with an empty dirty range. The processing loop's - // suffix logic will read [boundary, total_size) from CAS automatically. - if let Some((boundary, trunc_chunk)) = truncation_boundary { - let already_covered = dirty_regions - .iter() - .any(|r| r.first_chunk <= trunc_chunk && trunc_chunk < r.last_chunk); - if !already_covered { - dirty_regions.push(DirtyRegion { - dirty_start: boundary, - dirty_end: boundary, // empty: no staging bytes needed - first_chunk: trunc_chunk, - last_chunk: trunc_chunk + 1, - }); - dirty_regions.sort_by_key(|r| r.first_chunk); + // Coalesce overlapping/touching ranges. + snapped.sort_by_key(|&(s, _)| s); + let mut coalesced: Vec<(u64, u64)> = Vec::new(); + for r in snapped { + if let Some(last) = coalesced.last_mut() + && r.0 <= last.1 + { + last.1 = last.1.max(r.1); + continue; } + coalesced.push(r); } - // 5. Process each dirty region: download boundary, stream dirty bytes, upload. Collect the resulting middle file - // infos and chunk hashes. A single upload session is shared across all dirty regions. + let server_query: Vec = coalesced.iter().map(|&(s, e)| FileRange::new(s, e)).collect(); + if server_query.is_empty() { + // Should not happen: we either had dirty inputs, or total_size != original_size which + // produced a synthetic range above. + return Err(DataError::InternalError( + "internal: no server query computed for non-trivial upload_ranges call".into(), + )); + } + + // 3. Ask the server for chunk-aligned dirty windows + opaque gap subtrees. + let response: FileChunkHashesResponse = + cas_client.get_file_chunk_hashes(&original_hash, server_query.clone()).await?; + debug_assert_eq!(response.windows.len(), server_query.len(), "server windows must match segment-aligned query"); + debug_assert_eq!(response.hash_ranges.len(), response.windows.len() + 1, "expected N+1 hash ranges for N windows"); + + // 4. Process each window: stream prefix + dirty bytes + suffix into a fresh cleaner. let ctx = config.ctx.clone(); let session = FileUploadSession::new(config.clone()).await?; - - let mut uploaded_regions: Vec = Vec::with_capacity(dirty_regions.len()); - let mut dirty_inputs = dirty_inputs; let mut input_idx = 0usize; + let mut dirty_inputs = dirty_inputs; + let mut uploaded: Vec = Vec::with_capacity(response.windows.len()); - for region in dirty_regions { - let boundary_start = *chunk_offsets.get(region.first_chunk).ok_or_else(|| { - DataError::InternalError(format!( - "first_chunk {} out of bounds ({})", - region.first_chunk, - chunk_offsets.len() - )) - })?; - let boundary_end = *chunk_offsets.get(region.last_chunk).ok_or_else(|| { - DataError::InternalError(format!( - "last_chunk {} out of bounds ({})", - region.last_chunk, - chunk_offsets.len() - )) - })?; - debug_assert!(region.dirty_start >= boundary_start, "dirty_start before boundary_start"); - debug_assert!(region.dirty_end <= total_size, "dirty_end exceeds total_size"); - - // The cleaner processes a "middle" file that spans [boundary_start, middle_end). - // We stream it in three parts directly, without buffering boundary data: - // - // a) Prefix: CAS stream [boundary_start, dirty_start) ← stable bytes before edit - // b) Dirty: staging file [dirty_start, dirty_end) ← modified bytes - // c) Suffix: CAS stream [dirty_end, boundary_end) ← stable bytes after edit - // - // Example: dirty region [200, 400), boundary [100, 500) - // a) CAS stream [100..200) → cleaner - // b) staging [200..400) → cleaner (in 4MB blocks) - // c) CAS stream [400..500) → cleaner - let effective_boundary_end = boundary_end.min(total_size); - let middle_end = effective_boundary_end.max(region.dirty_end).min(total_size); - let middle_size = middle_end.saturating_sub(boundary_start); + let last_idx = response.windows.len() - 1; + for (idx, window) in response.windows.iter().enumerate() { + let w_start = window.dirty_byte_range[0]; + let w_end_in_original = window.dirty_byte_range[1]; - let (_id, mut cleaner) = session.start_clean(None, Some(middle_size), Sha256Policy::Skip)?; + // Last window stretches to total_size for append, shrinks to total_size for truncation. + let effective_end = if idx == last_idx + && ((total_size > original_size && w_end_in_original == original_size) + || (total_size < original_size && total_size <= w_end_in_original)) + { + total_size + } else { + w_end_in_original + }; - // a) Boundary prefix: stable bytes before the dirty range. - // - // We clamp the CAS read to original_size because chunk sizes from - // get_file_chunk_hashes may exceed the logical file size (e.g. after a - // truncation, the composed file inherits the original chunk layout). - if region.dirty_start > boundary_start && boundary_start < original_size { - let prefix_end = region.dirty_start.min(original_size); - stream_cas_range(&ctx, &cas_client, original_hash, boundary_start, prefix_end, &mut cleaner).await?; - } + // For truncation we must not stream past `effective_end`, even if the segment runs + // further; the cleaner was told the file has exactly `middle_size` bytes. + let original_window_end = w_end_in_original.min(original_size).min(effective_end); + let middle_size = effective_end - w_start; + + let (_id, mut cleaner) = session.start_clean(None, Some(middle_size), Sha256Policy::Skip)?; - // b) Dirty bytes from async readers. - // - // A merged DirtyRegion may span multiple inputs (when adjacent dirty ranges - // touch the same chunks). We consume readers in order, filling CAS gaps - // between them if the gap falls within the original file. - let mut cursor = region.dirty_start; - while input_idx < dirty_inputs.len() && dirty_inputs[input_idx].range.start < region.dirty_end { + let mut cursor = w_start; + while input_idx < dirty_inputs.len() { + let input_range_start = dirty_inputs[input_idx].range.start; + if input_range_start >= effective_end { + break; + } let input = &mut dirty_inputs[input_idx]; - let input_start = input.range.start.max(region.dirty_start); - let input_end = input.range.end.min(region.dirty_end); + let input_start = input.range.start.max(w_start); + let input_end = input.range.end.min(effective_end); // CAS gap before this input (within the original file). if cursor < input_start { - if cursor < original_size { - let gap_end = input_start.min(original_size); + let gap_end = input_start.min(original_window_end); + if cursor < gap_end { stream_cas_range(&ctx, &cas_client, original_hash, cursor, gap_end, &mut cleaner).await?; } - // Gap beyond original_size means the caller didn't provide bytes - // for part of the appended region. This would produce a corrupted file. if input_start > original_size && cursor < input_start { return Err(DataError::InternalError(format!( "gap in dirty_inputs: no data for bytes [{cursor}, {input_start}) \ @@ -336,10 +259,10 @@ pub async fn upload_ranges( } } - // Stream bytes from the async reader. + // Stream the dirty bytes from the async reader. let bytes_to_read = (input_end - input_start) as usize; let mut remaining = bytes_to_read; - let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining)]; + let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining.max(1))]; while remaining > 0 { let to_read = buf.len().min(remaining); input.reader.read_exact(&mut buf[..to_read]).await.map_err(|err| { @@ -353,166 +276,153 @@ pub async fn upload_ranges( } cursor = input_end; - // Only advance to next input if we fully consumed this one within the region. - if input.range.end <= region.dirty_end { + if input.range.end <= effective_end { input_idx += 1; } else { break; } } - // c) Boundary suffix: stable bytes after the dirty range. - // - // Same clamping as prefix: CAS chunk may extend past original_size. - let suffix_start = region.dirty_end.min(effective_boundary_end); - if suffix_start < effective_boundary_end && suffix_start < original_size { - let suffix_end = effective_boundary_end.min(original_size); - stream_cas_range(&ctx, &cas_client, original_hash, suffix_start, suffix_end, &mut cleaner).await?; + // CAS suffix: stable bytes after the last input within the original portion. + if cursor < original_window_end { + stream_cas_range(&ctx, &cas_client, original_hash, cursor, original_window_end, &mut cleaner).await?; } let (info, chunks, _metrics) = cleaner.finish().await?; - uploaded_regions.push(UploadedRegion { region, info, chunks }); + uploaded.push(UploadedWindow { + start: w_start, + end: effective_end, + info, + chunks, + }); } - // Checkpoint: flush xorbs without consuming the session, then retrieve MDBFileInfos. - // TODO: the middle files are registered in the shard as real files, but nobody will - // ever reference them. Check if GC cleans up unreferenced file entries, or find a way - // to retrieve segments from the session without persisting them to the shard. + // Pull the per-window MDBs from the session. session.checkpoint().await?; - let middle_file_infos = session.file_info_list().await?; - - // Pair each uploaded region with its MDBFileInfo from the session. - // Match by content hash. The shard manager deduplicates by file_hash (BTreeMap), - // so two regions with identical content produce only ONE MDBFileInfo entry. - // This is correct: same hash = same bytes = same chunks = same segments, - // so we clone the same MDBFileInfo for all regions sharing that hash. + let mdb_list = session.file_info_list().await?; let mdb_by_hash: HashMap = - middle_file_infos.into_iter().map(|mdb| (mdb.metadata.file_hash, mdb)).collect(); + mdb_list.into_iter().map(|m| (m.metadata.file_hash, m)).collect(); + + // 5. Compose the final hash by merging server-provided gap subtrees with locally-built window subtrees. Sequence: + // [gap0, w0, gap1, w1, ..., gapN]. Empty gaps are skipped. For truncation, drop the trailing gap (it covers + // bytes that no longer exist). + let keep_trailing_gap = total_size >= original_size; + let trailing_gap = if keep_trailing_gap { + response.hash_ranges.get(uploaded.len()).cloned().flatten() + } else { + None + }; - let mut composed_regions: Vec = Vec::with_capacity(uploaded_regions.len()); - for uploaded in uploaded_regions { - let middle_hash = MerkleHash::from_hex(uploaded.info.hash())?; - let mdb = mdb_by_hash - .get(&middle_hash) - .cloned() - .ok_or_else(|| DataError::InternalError(format!("no MDBFileInfo for middle hash {}", middle_hash.hex())))?; - composed_regions.push(ComposedRegion { - region: uploaded.region, - mdb, - chunks: uploaded.chunks, - }); + let leading_gap = response.hash_ranges.first().cloned().flatten(); + let first_window_at_start = leading_gap.is_none(); + let last_window_at_end = trailing_gap.is_none(); + + let mut merge_seq: Vec = Vec::with_capacity(2 * uploaded.len() + 1); + for (i, w) in uploaded.iter().enumerate() { + if let Some(gap) = response.hash_ranges.get(i).and_then(|g| g.as_ref()) { + merge_seq.push(gap.clone()); + } + let at_start = i == 0 && first_window_at_start; + let at_end = i == uploaded.len() - 1 && last_window_at_end; + merge_seq.push(MerkleHashSubtree::from_chunks(at_start, &w.chunks, at_end)); + } + if let Some(g) = trailing_gap { + merge_seq.push(g); } - // 6. Compose the final file: interleave stable regions with middle results. + let merged = MerkleHashSubtree::merge(&merge_seq) + .map_err(|err| DataError::InternalError(format!("MerkleHashSubtree::merge failed: {err}")))?; + // `final_hash()` returns the aggregated chunk hash; the file hash is its HMAC with the + // zero salt (matching `file_hash` / cleaner output for files without an SHA-256 metadata + // extension, which is the only flavor `upload_ranges` produces). + let aggregated_hash = merged.final_hash().ok_or_else(|| { + DataError::InternalError("merged subtree is not fully closed; cannot derive final hash".into()) + })?; + let combined_hash = aggregated_hash.hmac(MerkleHash::default()); + + // 6. Compose the MDBFileInfo by walking the original segments, swapping the ones a window covers for that window's + // segments. Segment-aligned windows guarantee that every original segment is either entirely outside or entirely + // inside a window. // - // The final file is built by alternating: - // [Stable chunks] [Re-uploaded chunks] [Stable chunks] [Re-uploaded chunks] ... - // - // Example: - // Original: [chunk[0], chunk[1], chunk[2], chunk[3]] (4 chunks) - // Dirty region affects chunks [1..3] - // Composition: - // [chunk[0]] <-- stable, reuse from original - // [middle chunks for region] <-- re-uploaded, from cleaner - // [chunk[3]] <-- stable suffix, reuse from original - // - let mut all_chunks: Vec<(MerkleHash, u64)> = Vec::new(); + // Verification entries are 1:1 with segments when the original file was registered + // with verification on; when it wasn't (some legacy / test files), the original + // verification vec is empty and we degrade gracefully by emitting no verification + // for the composed file either. + let original_has_verification = original_mdb.verification.len() == original_mdb.segments.len(); let mut all_segments: Vec = Vec::new(); - let mut all_verification = Vec::new(); - let mut chunk_cursor = 0usize; - let mut seg_cursor = 0usize; - let mut seg_chunk_cursor = 0usize; - - for composed in &composed_regions { - // Stable region before this dirty region. - if composed.region.first_chunk > chunk_cursor { - let (segments, verification_hashes) = extract_segments( - &original_mdb, - &original_chunks, - chunk_cursor, - composed.region.first_chunk, - &mut seg_cursor, - &mut seg_chunk_cursor, + let mut all_verification: Vec = Vec::new(); + let mut seg_idx = 0usize; + let n_segs = original_mdb.segments.len(); + for w in &uploaded { + while seg_idx < n_segs && seg_byte_starts[seg_idx] < w.start { + debug_assert!( + seg_byte_starts[seg_idx + 1] <= w.start, + "segment straddles window start (not segment-aligned)" ); - all_chunks.extend_from_slice(&original_chunks[chunk_cursor..composed.region.first_chunk]); - all_segments.extend(segments); - all_verification.extend(verification_hashes); + all_segments.push(original_mdb.segments[seg_idx].clone()); + if original_has_verification { + all_verification.push(original_mdb.verification[seg_idx].clone()); + } + seg_idx += 1; + } + let original_window_end = w.end.min(original_size); + while seg_idx < n_segs && seg_byte_starts[seg_idx] < original_window_end { + seg_idx += 1; + } + let middle_hash = MerkleHash::from_hex(w.info.hash())?; + let middle_mdb = mdb_by_hash + .get(&middle_hash) + .ok_or_else(|| DataError::InternalError(format!("no MDBFileInfo for window hash {}", middle_hash.hex())))?; + all_segments.extend_from_slice(&middle_mdb.segments); + if original_has_verification { + all_verification.extend_from_slice(&middle_mdb.verification); } - - // Middle (dirty) region. - all_chunks.extend_from_slice(&composed.chunks); - all_segments.extend_from_slice(&composed.mdb.segments); - all_verification.extend_from_slice(&composed.mdb.verification); - - chunk_cursor = composed.region.last_chunk; } - - // Stable suffix after the last dirty region. - if chunk_cursor < compose_num_chunks { - let (segments, verification_hashes) = extract_segments( - &original_mdb, - &original_chunks, - chunk_cursor, - compose_num_chunks, - &mut seg_cursor, - &mut seg_chunk_cursor, - ); - all_chunks.extend_from_slice(&original_chunks[chunk_cursor..compose_num_chunks]); - all_segments.extend(segments); - all_verification.extend(verification_hashes); + if total_size >= original_size { + while seg_idx < n_segs { + all_segments.push(original_mdb.segments[seg_idx].clone()); + if original_has_verification { + all_verification.push(original_mdb.verification[seg_idx].clone()); + } + seg_idx += 1; + } } + // Truncation: trailing segments are intentionally dropped. - let combined_hash = file_hash(&all_chunks); + let contains_verification = original_has_verification && all_verification.len() == all_segments.len(); debug!( - "upload_ranges: composed hash={}, {} segments, {} dirty regions", + "upload_ranges: composed hash={}, {} segments, {} windows", combined_hash.hex(), all_segments.len(), - composed_regions.len() + uploaded.len() ); let composed_mdb = MDBFileInfo { - metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), true, false), + metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), contains_verification, false), segments: all_segments, verification: all_verification, - // SHA-256 metadata_ext is intentionally omitted: the file content changed - // so the original SHA-256 is no longer valid, and recomputing it would require - // reading the full file. + // SHA-256 is intentionally omitted: the file content changed, and recomputing it + // would require reading the full file. metadata_ext: None, }; - // 7. Register composed file and finalize on the same session. session.register_composed_file(composed_mdb).await?; session.finalize().await?; - let total_dirty: u64 = dirty_ranges.iter().map(|(s, e)| e - s).sum(); + let total_dirty: u64 = dirty_ranges_pairs.iter().map(|(s, e)| e - s).sum(); info!( - "upload_ranges: hash={} size={} (original={}, {} dirty regions, {} dirty bytes)", + "upload_ranges: hash={} size={} (original={}, {} windows, {} dirty bytes)", combined_hash.hex(), total_size, original_size, - composed_regions.len(), + uploaded.len(), total_dirty ); Ok(XetFileInfo::new(combined_hash.hex(), total_size)) } -/// Rebuild the per-chunk `(hash, size)` list for the given file by querying each -/// segment's xorb metadata. Replaces the old flat `get_file_chunk_hashes` response -/// while the rest of `upload_ranges` is migrated to use the new windows + gap-subtree -/// response shape. -async fn collect_original_chunks(cas_client: &Arc, original_mdb: &MDBFileInfo) -> Result { - let mut chunks = Vec::new(); - for segment in &original_mdb.segments { - let pairs = cas_client - .xorb_chunk_hash_sizes(&segment.xorb_hash, segment.chunk_index_start, segment.chunk_index_end) - .await?; - chunks.extend(pairs); - } - Ok(chunks) -} - /// Stream a byte range from CAS into the cleaner. async fn stream_cas_range( ctx: &XetContext, @@ -530,134 +440,18 @@ async fn stream_cas_range( Ok(()) } -/// Expand dirty byte ranges to chunk-aligned boundaries and coalesce overlapping regions. -/// -/// Each dirty range is mapped to the chunks it touches (since CAS chunks are atomic), -/// then adjacent/overlapping chunk ranges are merged to avoid uploading the same -/// boundary chunks twice. -fn build_dirty_regions( - dirty_ranges: &[(u64, u64)], - chunk_offsets: &[u64], - num_chunks: usize, - original_size: u64, - total_size: u64, -) -> Result> { - let mut raw = Vec::with_capacity(dirty_ranges.len()); - for &(dirty_start, dirty_end) in dirty_ranges { - // Find the first chunk whose end offset exceeds dirty_start. - let mut first_chunk = chunk_offsets[1..].partition_point(|&o| o <= dirty_start); - debug_assert!(first_chunk <= num_chunks, "first_chunk {first_chunk} out of bounds ({num_chunks} chunks)"); - - // For append regions (dirty_start >= original_size), include the last original - // chunk so it gets re-chunked with the appended data. The last chunk was - // terminated by EOF (not by the rolling hash), so its boundary is artificial. - // The boundary prefix mechanism will download its bytes from CAS. - if total_size > original_size && dirty_start >= original_size && first_chunk > 0 { - first_chunk -= 1; - } - - // Find the last chunk (exclusive) that starts before dirty_end. - debug_assert!(dirty_end <= total_size, "dirty_end ({dirty_end}) exceeds total_size ({total_size})"); - let clamped_end = dirty_end.min(original_size); - let last_chunk = chunk_offsets[..num_chunks].partition_point(|&o| o < clamped_end); - if last_chunk == 0 { - return Err(DataError::InternalError(format!( - "no chunk starts before clamped_end ({clamped_end}), chunks may be inconsistent" - ))); - } - raw.push(DirtyRegion { - dirty_start, - dirty_end, - first_chunk, - last_chunk, - }); - } - - // Coalesce dirty regions whose chunk ranges overlap or are adjacent. - // This prevents uploading the same boundary chunks twice. - let mut merged: Vec = Vec::with_capacity(raw.len()); - for region in raw { - if let Some(last) = merged.last_mut() - && region.first_chunk <= last.last_chunk - { - last.dirty_end = last.dirty_end.max(region.dirty_end); - last.last_chunk = last.last_chunk.max(region.last_chunk); - continue; - } - merged.push(region); - } - Ok(merged) +/// Largest segment-start byte that is `<= byte`. Used to snap a dirty-range start back +/// to its enclosing segment boundary. +fn snap_to_segment_start(seg_byte_starts: &[u64], byte: u64) -> u64 { + let idx = seg_byte_starts.partition_point(|&s| s <= byte); + seg_byte_starts[idx.saturating_sub(1)] } -/// Extract segments and verification entries for chunks `[chunk_start, chunk_end)` -/// from the original reconstruction plan, truncating segments at boundaries. -/// -/// `seg_cursor` tracks the current position in the segment list across calls. Pass -/// `&mut 0` on the first call; subsequent calls resume from where the last left off. -/// This avoids re-scanning segments from the beginning on each call (O(S) total -/// instead of O(K*S) for K calls). -fn extract_segments( - original_mdb: &MDBFileInfo, - original_chunks: &[(MerkleHash, u64)], - chunk_start: usize, - chunk_end: usize, - seg_cursor: &mut usize, - seg_chunk_cursor: &mut usize, -) -> (Vec, Vec) { - let mut segments = Vec::new(); - let mut verification = Vec::new(); - - let mut chunk_cursor = *seg_chunk_cursor; - - // Walk segments starting from seg_cursor, extracting the overlap with [chunk_start, chunk_end). - // - // Example: segments cover chunks [0,3), [3,7), [7,10). We want chunks [2, 8). - // seg[0]: covers [0,3), overlap with [2,8) = [2,3) -> truncate to 1 chunk - // seg[1]: covers [3,7), overlap with [2,8) = [3,7) -> keep whole segment - // seg[2]: covers [7,10), overlap with [2,8) = [7,8) -> truncate to 1 chunk - for seg in &original_mdb.segments[*seg_cursor..] { - let seg_count = (seg.chunk_index_end - seg.chunk_index_start) as usize; - let seg_end = chunk_cursor + seg_count; - - if chunk_cursor >= chunk_end { - break; - } - - // Compute the overlap between this segment and the requested range. - let overlap_start = chunk_cursor.max(chunk_start); - let overlap_end = seg_end.min(chunk_end); - if overlap_start < overlap_end { - // Truncate the segment to only cover the overlapping chunks. - let count = overlap_end - overlap_start; - let mut truncated = seg.clone(); - truncated.chunk_index_start += (overlap_start - chunk_cursor) as u32; - truncated.chunk_index_end = truncated.chunk_index_start + count as u32; - let overlap = &original_chunks[overlap_start..overlap_end]; - let mut bytes = 0u64; - let mut hashes = Vec::with_capacity(overlap.len()); - for &(hash, size) in overlap { - bytes += size; - hashes.push(hash); - } - // u32 cast: unpacked_segment_bytes is u32 in the shard format. - // Safe because CDC parameters prevent segments from exceeding u32::MAX. - truncated.unpacked_segment_bytes = bytes as u32; - segments.push(truncated); - - // Recompute the verification hash for the truncated chunk range. - verification.push(FileVerificationEntry::new(range_hash_from_chunks(&hashes))); - } - - chunk_cursor = seg_end; - // Only advance seg_cursor if this segment is fully consumed. - // If it extends beyond chunk_end, a later call may need its suffix. - if seg_end <= chunk_end { - *seg_cursor += 1; - *seg_chunk_cursor = seg_end; - } - } - - (segments, verification) +/// Smallest segment-start byte that is `>= byte`. Used to snap a dirty-range end forward +/// to the segment boundary that fully contains it. +fn snap_to_segment_end(seg_byte_starts: &[u64], byte: u64) -> u64 { + let idx = seg_byte_starts.partition_point(|&s| s < byte); + seg_byte_starts[idx] } #[cfg(test)] @@ -681,12 +475,12 @@ mod tests { Arc::new(TranslatorConfig::test_server_config(&ctx, endpoint, base_dir).unwrap()) } - /// Test helper: rebuild the per-chunk `(hash, size)` list for a file by walking its - /// segments. Mirrors `super::collect_original_chunks` but takes the file hash directly - /// so callers don't have to plumb the reconstruction info themselves. - async fn fetch_original_chunks(cas_client: &Arc, hash: &MerkleHash) -> super::ChunkHashList { + /// Test helper: fetch the original file's per-segment byte sizes. Tests use these to + /// build dirty ranges that align with segment (== chunk-group) boundaries — handy for + /// scenarios that want to overwrite or truncate exactly on a boundary. + async fn fetch_segment_sizes(cas_client: &Arc, hash: &MerkleHash) -> Vec { let (mdb, _) = cas_client.get_file_reconstruction_info(hash).await.unwrap().unwrap(); - super::collect_original_chunks(cas_client, &mdb).await.unwrap() + mdb.segments.iter().map(|s| s.unpacked_segment_bytes as u64).collect() } /// Build `DirtyInput`s from a source buffer and range list. Each input gets @@ -932,102 +726,6 @@ mod tests { assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } - // original: [chunk0][chunk1][chunk2][chunk3][...] - // dirty: [0xBB ] [0xBB ] - // ^--- same content, same hash -> dedup collision - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_two_regions_identical_hash_collision() { - // Two dirty regions that produce the same content (and thus the same hash) - // must not collide in the mdb_by_hash mapping. The shard manager deduplicates - // MDBFileInfo entries by file_hash, so both regions share the same entry. - // - // We use chunk-aligned dirty ranges with identical fill to guarantee - // the cleaner produces identical hashes for both regions. - let server = LocalTestServerBuilder::new().start().await; - let base_dir = TempDir::new().unwrap(); - let config = test_config(server.http_endpoint(), base_dir.path()); - let cas_client: Arc = Arc::new(server); - - // Create an original file of pseudo-random bytes so CDC produces multiple chunks. - // 512 KB of random data to reliably produce >= 4 CDC chunks. - let original_data = random_data(47, 512 * 1024); - let original_hash = upload_file(&config, &original_data).await; - let original_size = original_data.len() as u64; - - let chunks = fetch_original_chunks(&cas_client, &original_hash).await; - assert!(chunks.len() >= 4, "expected at least 4 chunks, got {}", chunks.len()); - - let mut offsets = vec![0u64]; - for (_, size) in &chunks { - offsets.push(offsets.last().unwrap() + size); - } - - // Region 1: overwrite chunk[1] entirely. Region 2: overwrite chunk[3] entirely. - // Both get the same 0xBB fill, and since each spans exactly one full chunk - // boundary, the cleaner input is byte-identical -> same hash. - let r1_start = offsets[1] as usize; - let r1_end = offsets[2] as usize; - let r2_start = offsets[3] as usize; - let r2_end = offsets[4].min(original_size) as usize; - - let mut modified_data = original_data.clone(); - modified_data[r1_start..r1_end].fill(0xBB); - modified_data[r2_start..r2_end].fill(0xBB); - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_size, - make_dirty_inputs(&[(r1_start as u64, r1_end as u64), (r2_start as u64, r2_end as u64)], &modified_data), - modified_data.len() as u64, - ) - .await - .unwrap(); - - let downloaded = - download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), modified_data.len() as u64).await; - assert_eq!(downloaded.len(), modified_data.len(), "downloaded length mismatch"); - assert_eq!(&downloaded[..], &modified_data[..], "content mismatch: file was corrupted"); - - let clean_hash = upload_file(&config, &modified_data).await; - assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); - } - - // original: [chunk0][chunk1][chunk2][...] - // result: [chunk0] - // ^ cut exactly on chunk boundary, no re-upload needed - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_truncation_on_chunk_boundary() { - let server = LocalTestServerBuilder::new().start().await; - let base_dir = TempDir::new().unwrap(); - let config = test_config(server.http_endpoint(), base_dir.path()); - let cas_client: Arc = Arc::new(server); - - let original_data = random_data(99, 256 * 1024); - let original_hash = upload_file(&config, &original_data).await; - let original_size = original_data.len() as u64; - - let chunks = fetch_original_chunks(&cas_client, &original_hash).await; - assert!(chunks.len() >= 2, "need at least 2 chunks for this test"); - - // Truncate exactly at the boundary after the first chunk. - let truncated_size: u64 = chunks[0].1; - let truncated_data = &original_data[..truncated_size as usize]; - - let result = - upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], truncated_size) - .await - .unwrap(); - - assert_eq!(result.file_size(), Some(truncated_size)); - - let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), truncated_size).await; - assert_eq!(&downloaded[..], truncated_data); - - let clean_hash = upload_file(&config, truncated_data).await; - assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); - } - // original: [======== 100 KB ========] // inputs: [======== 100 KB ========][000][=4K written=] // ^ gap (zeros from seek past EOF) @@ -1191,10 +889,10 @@ mod tests { }) .collect(); let original_hash = upload_file(&config, &original).await; - let chunks = fetch_original_chunks(&cas_client, &original_hash).await; - if chunks.len() >= 3 { - let boundary: u64 = chunks[0].1 + chunks[1].1; - let dirty_end = boundary + chunks[2].1; + let seg_sizes = fetch_segment_sizes(&cas_client, &original_hash).await; + if seg_sizes.len() >= 3 { + let boundary: u64 = seg_sizes[0] + seg_sizes[1]; + let dirty_end = boundary + seg_sizes[2]; let mut expected = original.clone(); expected[boundary as usize..dirty_end as usize].fill(0xFF); let size = original.len() as u64; @@ -1359,54 +1057,6 @@ mod tests { assert!(msg.contains("gap in append region"), "expected gap-in-append-region error, got: {msg}"); } - #[test] - fn test_build_dirty_regions_coalesces_adjacent() { - // 5 chunks of 100 bytes each: offsets [0, 100, 200, 300, 400, 500] - let chunk_offsets = vec![0u64, 100, 200, 300, 400, 500]; - let num_chunks = 5; - let original_size = 500; - let total_size = 500; - - // Three adjacent dirty ranges, all inside chunk[2] = [200, 300). - let ranges = vec![(210u64, 230), (230, 250), (250, 270)]; - let regions = build_dirty_regions(&ranges, &chunk_offsets, num_chunks, original_size, total_size).unwrap(); - - // All three touch the same chunk, so they must coalesce into one region. - assert_eq!(regions.len(), 1, "expected 1 coalesced region, got {}", regions.len()); - assert_eq!(regions[0].dirty_start, 210); - assert_eq!(regions[0].dirty_end, 270); - } - - #[test] - fn test_build_dirty_regions_no_coalesce_when_separated() { - // 5 chunks of 100 bytes each. - let chunk_offsets = vec![0u64, 100, 200, 300, 400, 500]; - let num_chunks = 5; - let original_size = 500; - let total_size = 500; - - // Two dirty ranges in non-adjacent chunks: chunk[1] and chunk[3]. - let ranges = vec![(110u64, 130), (310, 330)]; - let regions = build_dirty_regions(&ranges, &chunk_offsets, num_chunks, original_size, total_size).unwrap(); - - assert_eq!(regions.len(), 2, "expected 2 separate regions, got {}", regions.len()); - } - - #[test] - fn test_build_dirty_regions_rejects_inconsistent_chunks() { - // chunk_offsets = [0, 100] but dirty range ends at 200 (clamped to original_size=100). - // No chunk has start < 100 except chunk[0] at offset 0... actually chunk[0] - // starts at 0 < 100, so that works. Use an empty chunk list instead. - let chunk_offsets = vec![0u64]; // 0 chunks, only the initial offset - let num_chunks = 0; - let original_size = 0; - let total_size = 100; - - let ranges = vec![(0u64, 100)]; - let result = build_dirty_regions(&ranges, &chunk_offsets, num_chunks, original_size, total_size); - assert!(result.is_err(), "should fail with inconsistent/empty chunk data"); - } - // original: [chunk0][chunk1][chunk2][chunk3][...more chunks...] // input: [========= single large write ==========] // From e4c2b36bafa6a4583305af9178a5ef42ae20c52e Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 1 May 2026 20:17:03 +0200 Subject: [PATCH 19/38] refactor(range_upload): cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse `MDBFileInfo::file_size()` instead of re-summing segment bytes (4 call sites collapsed). - Reuse `HttpRange` formatting (`From + Display`) for the `X-Range-Dirty` multi-range header instead of a hand-rolled `format!("{}-{}", r.start, r.end - 1)` join. - Reuse `snap_to_segment_end` for the truncation upload-range computation instead of a linear `find` over `seg_byte_starts`. - Reuse `MerkleHashSubtree::is_empty()` in `ChunkWindowBuilder::to_option`. - Factor the duplicated `MemoryClient`/`LocalClient` `get_file_chunk_hashes` bodies into `chunk_window_builder::build_file_chunk_hashes_response` taking a flat `(hash, size)` iterator. - Consume `response.hash_ranges` by value in the merge sequence so we don't clone every gap subtree. - Inline `keep_trailing_gap` / `leading_gap` / per-iteration `last_idx == uploaded.len() - 1`; collapse the `effective_end` ternary chain to a single condition (`idx == last_idx && total_size != original_size`). - Drop unused state: `let mut dirty_inputs = dirty_inputs;` shadow, `server_query.clone()`, the unused `mut` on the `emit_seg` closure, the `contains_verification` mirror of `with_verification`. - Drop the narration comments (`// 1.`, `// 2.`, …) and the now-stale comment that referenced the legacy `get_file_chunk_hashes` flat response. - Add capacity hints on `snapped` and `coalesced`. 19/19 range_upload tests still pass; CI clippy clean. --- .../src/cas_client/chunk_window_builder.rs | 67 ++++++--- xet_client/src/cas_client/remote_client.rs | 15 +- .../src/cas_client/simulation/local_client.rs | 49 ++----- .../cas_client/simulation/memory_client.rs | 52 ++----- xet_data/src/processing/range_upload.rs | 133 +++++++----------- 5 files changed, 129 insertions(+), 187 deletions(-) diff --git a/xet_client/src/cas_client/chunk_window_builder.rs b/xet_client/src/cas_client/chunk_window_builder.rs index ec7f4552a..6f68cd1e4 100644 --- a/xet_client/src/cas_client/chunk_window_builder.rs +++ b/xet_client/src/cas_client/chunk_window_builder.rs @@ -1,21 +1,11 @@ -//! State machine that classifies chunks into dirty windows and gap subtrees. -//! -//! Mirrors the server-side state machine used by `GET /v2/file-chunk-hashes/{file_id}` -//! (xetcas PR #987). We need it on the client too so that the simulation clients -//! (`MemoryClient`, `LocalClient`) can produce a [`FileChunkHashesResponse`] without -//! routing through HTTP. -//! -//! The result is `windows.len()` chunk-aligned dirty `FileRange`s and `windows.len() + 1` -//! gap subtrees (`None` for empty gaps). The producer feeds chunks in file order; -//! `finish()` returns both vectors. -//! -//! Memory note: `gap_chunks` accumulates every chunk in the current gap before being -//! rolled up into a [`MerkleHashSubtree`]. Peak memory scales with the largest contiguous -//! gap. +//! Server-side state machine for `GET /v2/file-chunk-hashes/{file_id}` (mirrored from +//! xetcas PR #987) plus a small driver helper used by simulation clients to produce a +//! [`FileChunkHashesResponse`] without routing through HTTP. use xet_core_structures::merklehash::{MerkleHash, MerkleHashSubtree}; -use crate::cas_types::FileRange; +use crate::cas_types::{ChunkWindow, FileChunkHashesResponse, FileRange}; +use crate::error::{ClientError, Result}; pub struct ChunkWindowBuilder<'a> { dirty_ranges: &'a [FileRange], @@ -132,6 +122,51 @@ impl<'a> ChunkWindowBuilder<'a> { } fn to_option(hr: MerkleHashSubtree) -> Option { - if hr.num_nodes() > 0 { Some(hr) } else { None } + if hr.is_empty() { None } else { Some(hr) } } } + +/// Drive [`ChunkWindowBuilder`] over a flat list of `(chunk_hash, size)` pairs and +/// assemble the [`FileChunkHashesResponse`]. Simulation clients pre-collect the chunks for +/// the file (typically by walking segments + xorb metadata) and call this to answer +/// `Client::get_file_chunk_hashes` locally. +pub fn build_file_chunk_hashes_response( + file_size: u64, + dirty_ranges: Vec, + chunks: impl IntoIterator, +) -> Result { + let dirty_ranges: Vec = dirty_ranges + .into_iter() + .map(|r| FileRange::new(r.start, r.end.min(file_size))) + .filter(|r| r.start < r.end && r.start < file_size) + .collect(); + if dirty_ranges.is_empty() { + return Err(ClientError::Other("no valid dirty ranges".into())); + } + + let mut builder = ChunkWindowBuilder::new(&dirty_ranges); + let mut cumulative_bytes: u64 = 0; + let mut total_chunks: u64 = 0; + for (hash, size) in chunks { + cumulative_bytes += size; + builder.process_chunk(hash, size, cumulative_bytes); + total_chunks += 1; + } + + let (windows, hash_ranges) = builder.finish(); + if windows.is_empty() { + return Err(ClientError::Other("dirty ranges do not overlap any chunks".into())); + } + + Ok(FileChunkHashesResponse { + total_chunks, + file_size, + windows: windows + .into_iter() + .map(|r| ChunkWindow { + dirty_byte_range: [r.start, r.end], + }) + .collect(), + hash_ranges, + }) +} diff --git a/xet_client/src/cas_client/remote_client.rs b/xet_client/src/cas_client/remote_client.rs index 6528da9bf..c24b27a92 100644 --- a/xet_client/src/cas_client/remote_client.rs +++ b/xet_client/src/cas_client/remote_client.rs @@ -761,18 +761,19 @@ impl Client for RemoteClient { let url = Url::parse(&format!("{}/v2/file-chunk-hashes/{}", self.endpoint, file_id.hex()))?; - // Encode the dirty ranges as a multi-range `bytes=A-B,C-D` value (HTTP convention is - // inclusive-end, so we subtract 1 from each FileRange's exclusive end). - let header_value = format!( + // Multi-range `bytes=A-B,C-D` value. `HttpRange` is inclusive-end and `Display`s as + // `start-end`; conversion from `FileRange` does the +1/-1 for us. + let header_value = HeaderValue::from_str(&format!( "bytes={}", dirty_ranges .iter() - .map(|r| format!("{}-{}", r.start, r.end.saturating_sub(1))) + .copied() + .map(HttpRange::from) + .map(|r| r.to_string()) .collect::>() .join(",") - ); - let header_value = HeaderValue::from_str(&header_value) - .map_err(|err| ClientError::Other(format!("invalid X-Range-Dirty header value: {err}")))?; + )) + .map_err(|err| ClientError::Other(format!("invalid X-Range-Dirty header value: {err}")))?; let api_tag = "cas::get_file_chunk_hashes"; let client = self.authenticated_http_client.clone(); diff --git a/xet_client/src/cas_client/simulation/local_client.rs b/xet_client/src/cas_client/simulation/local_client.rs index d0f5489b7..c3fe4f5d1 100644 --- a/xet_client/src/cas_client/simulation/local_client.rs +++ b/xet_client/src/cas_client/simulation/local_client.rs @@ -36,10 +36,10 @@ use super::direct_access_client::DirectAccessClient; use super::xorb_utils::{self, REFERENCE_INSTANT, duration_to_expiration_secs_ceil}; use crate::cas_client::Client; use crate::cas_client::adaptive_concurrency::AdaptiveConcurrencyController; -use crate::cas_client::chunk_window_builder::ChunkWindowBuilder; +use crate::cas_client::chunk_window_builder::build_file_chunk_hashes_response; use crate::cas_client::progress_tracked_streams::ProgressCallback; use crate::cas_types::{ - BatchQueryReconstructionResponse, ChunkWindow, FileChunkHashesResponse, FileRange, HexMerkleHash, HttpRange, + BatchQueryReconstructionResponse, FileChunkHashesResponse, FileRange, HexMerkleHash, HttpRange, QueryReconstructionResponse, QueryReconstructionResponseV2, XorbMultiRangeFetch, XorbRangeDescriptor, XorbReconstructionFetchInfo, }; @@ -1707,48 +1707,17 @@ impl Client for LocalClient { return Err(ClientError::FileNotFound(*file_id)); }; - let file_size: u64 = file_info.segments.iter().map(|s| s.unpacked_segment_bytes as u64).sum(); - let dirty_ranges: Vec = dirty_ranges - .into_iter() - .map(|r| FileRange::new(r.start, r.end.min(file_size))) - .filter(|r| r.start < r.end && r.start < file_size) - .collect(); - if dirty_ranges.is_empty() { - return Err(ClientError::Other("no valid dirty ranges".into())); - } - - let mut builder = ChunkWindowBuilder::new(&dirty_ranges); - let mut cumulative_bytes: u64 = 0; - let mut total_chunks: u64 = 0; - + let mut chunks: Vec<(MerkleHash, u64)> = Vec::new(); for segment in &file_info.segments { let xorb_obj = self.xorb_footer(&segment.xorb_hash).await?; - let pairs = xorb_obj - .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) - .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}")))?; - for (hash, size) in pairs { - cumulative_bytes += size; - builder.process_chunk(hash, size, cumulative_bytes); - total_chunks += 1; - } - } - - let (windows, hash_ranges) = builder.finish(); - if windows.is_empty() { - return Err(ClientError::Other("dirty ranges do not overlap any chunks".into())); + chunks.extend( + xorb_obj + .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) + .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}")))?, + ); } - Ok(FileChunkHashesResponse { - total_chunks, - file_size, - windows: windows - .into_iter() - .map(|r| ChunkWindow { - dirty_byte_range: [r.start, r.end], - }) - .collect(), - hash_ranges, - }) + build_file_chunk_hashes_response(file_info.file_size(), dirty_ranges, chunks) } } diff --git a/xet_client/src/cas_client/simulation/memory_client.rs b/xet_client/src/cas_client/simulation/memory_client.rs index fdf3c436f..83ecc9194 100644 --- a/xet_client/src/cas_client/simulation/memory_client.rs +++ b/xet_client/src/cas_client/simulation/memory_client.rs @@ -30,9 +30,9 @@ use super::deletion_controls::ObjectTag; use super::direct_access_client::DirectAccessClient; use super::random_xorb::RandomXorb; use super::xorb_utils::{self, REFERENCE_INSTANT, duration_to_expiration_secs_ceil}; -use crate::cas_client::chunk_window_builder::ChunkWindowBuilder; +use crate::cas_client::chunk_window_builder::build_file_chunk_hashes_response; use crate::cas_types::{ - BatchQueryReconstructionResponse, ChunkWindow, FileChunkHashesResponse, FileRange, HexMerkleHash, HttpRange, + BatchQueryReconstructionResponse, FileChunkHashesResponse, FileRange, HexMerkleHash, HttpRange, QueryReconstructionResponse, QueryReconstructionResponseV2, XorbMultiRangeFetch, XorbRangeDescriptor, XorbReconstructionFetchInfo, }; @@ -967,58 +967,24 @@ impl Client for MemoryClient { .ok_or(ClientError::FileNotFound(*file_id))? }; - let file_size: u64 = file_info.segments.iter().map(|s| s.unpacked_segment_bytes as u64).sum(); - let dirty_ranges: Vec = dirty_ranges - .into_iter() - .map(|r| FileRange::new(r.start, r.end.min(file_size))) - .filter(|r| r.start < r.end && r.start < file_size) - .collect(); - if dirty_ranges.is_empty() { - return Err(ClientError::Other("no valid dirty ranges".into())); - } - let xorbs = self.xorbs.read().await; - let mut builder = ChunkWindowBuilder::new(&dirty_ranges); - let mut cumulative_bytes: u64 = 0; - let mut total_chunks: u64 = 0; - + let mut chunks: Vec<(MerkleHash, u64)> = Vec::new(); for segment in &file_info.segments { let storage = xorbs .get(&segment.xorb_hash) .ok_or(ClientError::XORBNotFound(segment.xorb_hash))?; - let xorb_obj = match storage { XorbStorage::Materialized { entry, .. } => std::borrow::Cow::Borrowed(&entry.xorb_object), XorbStorage::Random { xorb, .. } => std::borrow::Cow::Owned(xorb.get_xorb_object()), }; - - let pairs = xorb_obj - .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) - .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}")))?; - - for (hash, size) in pairs { - cumulative_bytes += size; - builder.process_chunk(hash, size, cumulative_bytes); - total_chunks += 1; - } - } - - let (windows, hash_ranges) = builder.finish(); - if windows.is_empty() { - return Err(ClientError::Other("dirty ranges do not overlap any chunks".into())); + chunks.extend( + xorb_obj + .chunk_hash_sizes(segment.chunk_index_start, segment.chunk_index_end) + .map_err(|err| ClientError::Other(format!("chunk_hash_sizes error: {err}")))?, + ); } - Ok(FileChunkHashesResponse { - total_chunks, - file_size, - windows: windows - .into_iter() - .map(|r| ChunkWindow { - dirty_byte_range: [r.start, r.end], - }) - .collect(), - hash_ranges, - }) + build_file_chunk_hashes_response(file_info.file_size(), dirty_ranges, chunks) } } diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index dae0499fd..801b94f19 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -79,7 +79,7 @@ pub async fn upload_ranges( cas_client: Arc, original_hash: MerkleHash, original_size: u64, - dirty_inputs: Vec, + mut dirty_inputs: Vec, total_size: u64, ) -> Result { if dirty_inputs.is_empty() && total_size == original_size { @@ -126,15 +126,13 @@ pub async fn upload_ranges( } } - // 1. Fetch the original file's MDB. We need the segments to compose the new file's MDBFileInfo and to compute - // segment byte boundaries for snapping. let recon_result = cas_client.get_file_reconstruction_info(&original_hash).await?; let original_mdb = recon_result .map(|(mdb, _)| mdb) .ok_or_else(|| DataError::InternalError("no reconstruction info for original file".into()))?; + debug_assert_eq!(original_mdb.file_size(), original_size, "reconstruction info disagrees with original_size"); - // Cumulative byte boundaries between segments. `seg_byte_starts[i]` is the first byte - // of segment `i`; `seg_byte_starts[segments.len()]` equals `original_size`. + // `seg_byte_starts[i]` is the first byte of segment `i`; the trailing entry equals `original_size`. let mut seg_byte_starts: Vec = Vec::with_capacity(original_mdb.segments.len() + 1); seg_byte_starts.push(0); let mut acc = 0u64; @@ -142,13 +140,12 @@ pub async fn upload_ranges( acc += s.unpacked_segment_bytes as u64; seg_byte_starts.push(acc); } - debug_assert_eq!(*seg_byte_starts.last().unwrap_or(&0), original_size, "segments must sum to original_size"); - // 2. Build segment-aligned dirty ranges to send to the server. Snapping to segment boundaries (rather than just - // chunk boundaries) lets us swap whole segments during composition, so we never need to truncate a segment - // mid-chunk on the client. This is strictly safe: segment boundaries are also chunk boundaries, so the server's - // chunk-aligned windows come back equal to our snapped ranges. - let mut snapped: Vec<(u64, u64)> = Vec::new(); + // Snapping to *segment* boundaries (rather than chunk boundaries) lets us swap whole + // segments during composition, so the client never has to truncate a segment mid-chunk. + // Safe to send to the server because segment edges are chunk edges, so the server's + // chunk-aligned windows come back identical to our snapped ranges. + let mut snapped: Vec<(u64, u64)> = Vec::with_capacity(dirty_ranges_pairs.len() + 1); for &(start, end) in &dirty_ranges_pairs { let in_start = start.min(original_size); let in_end = end.min(original_size); @@ -158,29 +155,22 @@ pub async fn upload_ranges( snapped .push((snap_to_segment_start(&seg_byte_starts, in_start), snap_to_segment_end(&seg_byte_starts, in_end))); } - // Truncation: ensure the segment containing the cut is in the upload set so we - // can re-upload it with its truncated tail. if total_size < original_size { let snap_start = snap_to_segment_start(&seg_byte_starts, total_size); if snap_start < original_size { - let snap_end = seg_byte_starts - .iter() - .copied() - .find(|&s| s > total_size) - .unwrap_or(original_size); + // total_size+1 forces the snap forward to the first boundary strictly past the cut. + let snap_end = snap_to_segment_end(&seg_byte_starts, total_size + 1); snapped.push((snap_start, snap_end)); } } - // Pure append (no in-original modifications, total_size > original_size): re-upload - // the last original segment so the appended bytes cleanly extend its boundary. if snapped.is_empty() && total_size > original_size && !original_mdb.segments.is_empty() { + // Pure append: re-upload the last segment so the appended bytes extend its boundary. let n = original_mdb.segments.len(); snapped.push((seg_byte_starts[n - 1], seg_byte_starts[n])); } - // Coalesce overlapping/touching ranges. snapped.sort_by_key(|&(s, _)| s); - let mut coalesced: Vec<(u64, u64)> = Vec::new(); + let mut coalesced: Vec<(u64, u64)> = Vec::with_capacity(snapped.len()); for r in snapped { if let Some(last) = coalesced.last_mut() && r.0 <= last.1 @@ -193,43 +183,34 @@ pub async fn upload_ranges( let server_query: Vec = coalesced.iter().map(|&(s, e)| FileRange::new(s, e)).collect(); if server_query.is_empty() { - // Should not happen: we either had dirty inputs, or total_size != original_size which - // produced a synthetic range above. return Err(DataError::InternalError( "internal: no server query computed for non-trivial upload_ranges call".into(), )); } - // 3. Ask the server for chunk-aligned dirty windows + opaque gap subtrees. - let response: FileChunkHashesResponse = - cas_client.get_file_chunk_hashes(&original_hash, server_query.clone()).await?; - debug_assert_eq!(response.windows.len(), server_query.len(), "server windows must match segment-aligned query"); - debug_assert_eq!(response.hash_ranges.len(), response.windows.len() + 1, "expected N+1 hash ranges for N windows"); + let n_windows = server_query.len(); + let response: FileChunkHashesResponse = cas_client.get_file_chunk_hashes(&original_hash, server_query).await?; + debug_assert_eq!(response.windows.len(), n_windows, "server windows must match segment-aligned query"); + debug_assert_eq!(response.hash_ranges.len(), n_windows + 1, "expected N+1 hash ranges for N windows"); - // 4. Process each window: stream prefix + dirty bytes + suffix into a fresh cleaner. let ctx = config.ctx.clone(); let session = FileUploadSession::new(config.clone()).await?; let mut input_idx = 0usize; - let mut dirty_inputs = dirty_inputs; - let mut uploaded: Vec = Vec::with_capacity(response.windows.len()); + let mut uploaded: Vec = Vec::with_capacity(n_windows); - let last_idx = response.windows.len() - 1; + let last_idx = n_windows - 1; for (idx, window) in response.windows.iter().enumerate() { let w_start = window.dirty_byte_range[0]; let w_end_in_original = window.dirty_byte_range[1]; - // Last window stretches to total_size for append, shrinks to total_size for truncation. - let effective_end = if idx == last_idx - && ((total_size > original_size && w_end_in_original == original_size) - || (total_size < original_size && total_size <= w_end_in_original)) - { + let effective_end = if idx == last_idx && total_size != original_size { total_size } else { w_end_in_original }; - // For truncation we must not stream past `effective_end`, even if the segment runs - // further; the cleaner was told the file has exactly `middle_size` bytes. + // Cleaner was sized to exactly `middle_size`; never stream past it (matters for truncation + // where the segment runs further than `effective_end`). let original_window_end = w_end_in_original.min(original_size).min(effective_end); let middle_size = effective_end - w_start; @@ -297,33 +278,29 @@ pub async fn upload_ranges( }); } - // Pull the per-window MDBs from the session. session.checkpoint().await?; let mdb_list = session.file_info_list().await?; let mdb_by_hash: HashMap = mdb_list.into_iter().map(|m| (m.metadata.file_hash, m)).collect(); - // 5. Compose the final hash by merging server-provided gap subtrees with locally-built window subtrees. Sequence: - // [gap0, w0, gap1, w1, ..., gapN]. Empty gaps are skipped. For truncation, drop the trailing gap (it covers - // bytes that no longer exist). - let keep_trailing_gap = total_size >= original_size; - let trailing_gap = if keep_trailing_gap { - response.hash_ranges.get(uploaded.len()).cloned().flatten() + // Merge sequence: [gap0, w0, gap1, w1, ..., gapN]. Empty gaps (`None`) are skipped. + // For truncation, the trailing gap covers bytes that no longer exist and is dropped. + let mut hash_ranges = response.hash_ranges; + let trailing_gap = if total_size >= original_size { + hash_ranges.pop().flatten() } else { - None + hash_ranges.pop().and(None) }; - - let leading_gap = response.hash_ranges.first().cloned().flatten(); - let first_window_at_start = leading_gap.is_none(); + let first_window_at_start = hash_ranges.first().is_some_and(Option::is_none); let last_window_at_end = trailing_gap.is_none(); - let mut merge_seq: Vec = Vec::with_capacity(2 * uploaded.len() + 1); - for (i, w) in uploaded.iter().enumerate() { - if let Some(gap) = response.hash_ranges.get(i).and_then(|g| g.as_ref()) { - merge_seq.push(gap.clone()); + let mut merge_seq: Vec = Vec::with_capacity(2 * n_windows + 1); + for (i, (w, gap)) in uploaded.iter().zip(hash_ranges.into_iter()).enumerate() { + if let Some(g) = gap { + merge_seq.push(g); } let at_start = i == 0 && first_window_at_start; - let at_end = i == uploaded.len() - 1 && last_window_at_end; + let at_end = i == last_idx && last_window_at_end; merge_seq.push(MerkleHashSubtree::from_chunks(at_start, &w.chunks, at_end)); } if let Some(g) = trailing_gap { @@ -332,37 +309,37 @@ pub async fn upload_ranges( let merged = MerkleHashSubtree::merge(&merge_seq) .map_err(|err| DataError::InternalError(format!("MerkleHashSubtree::merge failed: {err}")))?; - // `final_hash()` returns the aggregated chunk hash; the file hash is its HMAC with the - // zero salt (matching `file_hash` / cleaner output for files without an SHA-256 metadata - // extension, which is the only flavor `upload_ranges` produces). + // `final_hash()` is the aggregated chunk hash; the file hash is its HMAC with the zero + // salt (matching `file_hash` / the cleaner's output for files without SHA-256 metadata, + // the only flavor `upload_ranges` produces). let aggregated_hash = merged.final_hash().ok_or_else(|| { DataError::InternalError("merged subtree is not fully closed; cannot derive final hash".into()) })?; let combined_hash = aggregated_hash.hmac(MerkleHash::default()); - // 6. Compose the MDBFileInfo by walking the original segments, swapping the ones a window covers for that window's - // segments. Segment-aligned windows guarantee that every original segment is either entirely outside or entirely - // inside a window. - // - // Verification entries are 1:1 with segments when the original file was registered - // with verification on; when it wasn't (some legacy / test files), the original - // verification vec is empty and we degrade gracefully by emitting no verification - // for the composed file either. - let original_has_verification = original_mdb.verification.len() == original_mdb.segments.len(); + // Walk original segments; replace those a window covers with the window's segments. + // Segment-aligned windows guarantee every original segment is wholly inside or outside a + // window. Verification entries are 1:1 with segments when the original file had them on; + // legacy / test files registered without verification yield an empty vec, in which case + // we emit a verification-less composed MDB. + let with_verification = original_mdb.verification.len() == original_mdb.segments.len(); let mut all_segments: Vec = Vec::new(); let mut all_verification: Vec = Vec::new(); let mut seg_idx = 0usize; let n_segs = original_mdb.segments.len(); + let emit_seg = |idx: usize, segs: &mut Vec, vers: &mut Vec| { + segs.push(original_mdb.segments[idx].clone()); + if with_verification { + vers.push(original_mdb.verification[idx].clone()); + } + }; for w in &uploaded { while seg_idx < n_segs && seg_byte_starts[seg_idx] < w.start { debug_assert!( seg_byte_starts[seg_idx + 1] <= w.start, "segment straddles window start (not segment-aligned)" ); - all_segments.push(original_mdb.segments[seg_idx].clone()); - if original_has_verification { - all_verification.push(original_mdb.verification[seg_idx].clone()); - } + emit_seg(seg_idx, &mut all_segments, &mut all_verification); seg_idx += 1; } let original_window_end = w.end.min(original_size); @@ -374,22 +351,16 @@ pub async fn upload_ranges( .get(&middle_hash) .ok_or_else(|| DataError::InternalError(format!("no MDBFileInfo for window hash {}", middle_hash.hex())))?; all_segments.extend_from_slice(&middle_mdb.segments); - if original_has_verification { + if with_verification { all_verification.extend_from_slice(&middle_mdb.verification); } } if total_size >= original_size { while seg_idx < n_segs { - all_segments.push(original_mdb.segments[seg_idx].clone()); - if original_has_verification { - all_verification.push(original_mdb.verification[seg_idx].clone()); - } + emit_seg(seg_idx, &mut all_segments, &mut all_verification); seg_idx += 1; } } - // Truncation: trailing segments are intentionally dropped. - - let contains_verification = original_has_verification && all_verification.len() == all_segments.len(); debug!( "upload_ranges: composed hash={}, {} segments, {} windows", @@ -399,7 +370,7 @@ pub async fn upload_ranges( ); let composed_mdb = MDBFileInfo { - metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), contains_verification, false), + metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), with_verification, false), segments: all_segments, verification: all_verification, // SHA-256 is intentionally omitted: the file content changed, and recomputing it From 29c38aa65a5bd141cfb89d5f57ec41a30a1167cc Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 1 May 2026 20:54:24 +0200 Subject: [PATCH 20/38] fix(range_upload): handle mid-edit+append, empty original, empty result Three correctness issues caught by Codex review: 1. Mixed mid-file edit + tail append corrupted the file. The append branch only fired when `snapped` was empty (pure append); with an in-original dirty range present, no last-segment window was added, so the existing mid-file window got stretched to `total_size`, dropping the stable bytes between the edit and the appended region. Fix: always include the last original segment in the upload set when `total_size > original_size`, and let coalescing merge it with any overlapping range. 2. Empty original + append returned an internal error. With no original segments, the snap loop produced nothing and the append branch had no last segment to add. Fix: short-circuit `original_size == 0` to a fresh upload via `upload_fresh_file`, which streams the (validated) caller inputs through a clean session. 3. Truncating to empty produced `MerkleHash::default().hmac(default)`, which disagreed with the canonical empty-file hash (`file_hash([])` short-circuits to `MerkleHash::default()` *without* HMAC). Fix: special-case `total_size == 0` to skip the HMAC step. Three regression tests cover each case: - test_mid_edit_plus_append - test_empty_original_append - test_truncate_to_empty_matches_clean_empty --- xet_data/src/processing/range_upload.rs | 166 +++++++++++++++++++++++- 1 file changed, 161 insertions(+), 5 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 801b94f19..2ca1aab95 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -86,6 +86,13 @@ pub async fn upload_ranges( return Ok(XetFileInfo::new(original_hash.hex(), original_size)); } + // Empty original: nothing to compose against — upload as a fresh file. Validation below + // still runs (via the early return path inside `upload_fresh_file`) to enforce that the + // caller provided coverage of `[0, total_size)`. + if original_size == 0 { + return upload_fresh_file(config, dirty_inputs, total_size).await; + } + // Validate the caller-provided dirty ranges. let dirty_ranges_pairs: Vec<(u64, u64)> = dirty_inputs.iter().map(|d| (d.range.start, d.range.end)).collect(); @@ -163,8 +170,12 @@ pub async fn upload_ranges( snapped.push((snap_start, snap_end)); } } - if snapped.is_empty() && total_size > original_size && !original_mdb.segments.is_empty() { - // Pure append: re-upload the last segment so the appended bytes extend its boundary. + if total_size > original_size && !original_mdb.segments.is_empty() { + // Append: always include the last original segment in the upload set so its boundary + // extends past `original_size` into the appended bytes. Needed both for pure append + // and for mid-edit-plus-append (otherwise the last existing window would be the one + // we extend to `total_size`, which corrupts files with a stable tail before the + // appended region). Coalescing below merges this with any overlapping range. let n = original_mdb.segments.len(); snapped.push((seg_byte_starts[n - 1], seg_byte_starts[n])); } @@ -295,7 +306,7 @@ pub async fn upload_ranges( let last_window_at_end = trailing_gap.is_none(); let mut merge_seq: Vec = Vec::with_capacity(2 * n_windows + 1); - for (i, (w, gap)) in uploaded.iter().zip(hash_ranges.into_iter()).enumerate() { + for (i, (w, gap)) in uploaded.iter().zip(hash_ranges).enumerate() { if let Some(g) = gap { merge_seq.push(g); } @@ -311,11 +322,17 @@ pub async fn upload_ranges( .map_err(|err| DataError::InternalError(format!("MerkleHashSubtree::merge failed: {err}")))?; // `final_hash()` is the aggregated chunk hash; the file hash is its HMAC with the zero // salt (matching `file_hash` / the cleaner's output for files without SHA-256 metadata, - // the only flavor `upload_ranges` produces). + // the only flavor `upload_ranges` produces). Empty content is the one exception: + // `file_hash([])` short-circuits to `MerkleHash::default()` *without* HMAC, so we mirror + // that here when the result is zero-length (e.g. truncating to empty). let aggregated_hash = merged.final_hash().ok_or_else(|| { DataError::InternalError("merged subtree is not fully closed; cannot derive final hash".into()) })?; - let combined_hash = aggregated_hash.hmac(MerkleHash::default()); + let combined_hash = if total_size == 0 { + MerkleHash::default() + } else { + aggregated_hash.hmac(MerkleHash::default()) + }; // Walk original segments; replace those a window covers with the window's segments. // Segment-aligned windows guarantee every original segment is wholly inside or outside a @@ -394,6 +411,53 @@ pub async fn upload_ranges( Ok(XetFileInfo::new(combined_hash.hex(), total_size)) } +/// Upload a brand-new file from `dirty_inputs` (no original to compose against). +/// Used when the original file is empty: the caller-provided inputs already cover +/// `[0, total_size)` (verified here), so we just stream them through the cleaner and +/// finalize the session. +async fn upload_fresh_file( + config: Arc, + mut dirty_inputs: Vec, + total_size: u64, +) -> Result { + let mut cursor = 0u64; + for input in &dirty_inputs { + if input.range.start > cursor { + return Err(DataError::InternalError(format!( + "empty original: gap in dirty_inputs at [{cursor}, {})", + input.range.start + ))); + } + cursor = input.range.end; + } + if cursor < total_size { + return Err(DataError::InternalError(format!( + "empty original: dirty_inputs only cover up to byte {cursor} (must reach total_size {total_size})" + ))); + } + + let session = FileUploadSession::new(config).await?; + let (_id, mut cleaner) = session.start_clean(None, Some(total_size), Sha256Policy::Skip)?; + for input in &mut dirty_inputs { + let mut remaining = (input.range.end - input.range.start) as usize; + let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining.max(1))]; + while remaining > 0 { + let to_read = buf.len().min(remaining); + input.reader.read_exact(&mut buf[..to_read]).await.map_err(|err| { + DataError::InternalError(format!( + "failed to read dirty input [{}, {}): {err}", + input.range.start, input.range.end + )) + })?; + cleaner.add_data(&buf[..to_read]).await?; + remaining -= to_read; + } + } + let (info, _chunks, _metrics) = cleaner.finish().await?; + session.finalize().await?; + Ok(info) +} + /// Stream a byte range from CAS into the cleaner. async fn stream_cas_range( ctx: &XetContext, @@ -1276,4 +1340,96 @@ mod tests { let clean_hash = upload_file(config, expected).await; assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); } + + // Regression: mid-file edit + tail append in a single call. Codex caught that the last + // existing window (covering the mid-file edit) was being stretched to `total_size`, + // dropping the stable bytes between the edit and the appended region. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_mid_edit_plus_append() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original_data = random_data(7, 256 * 1024); + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let dirty_start = 50_000usize; + let dirty_end = 51_000usize; + let append_extra: Vec = (0..16 * 1024).map(|i| (i % 251) as u8).collect(); + let mut expected = original_data.clone(); + expected[dirty_start..dirty_end].fill(0xAA); + expected.extend_from_slice(&append_extra); + let total_size = expected.len() as u64; + + let inputs = vec![ + DirtyInput { + range: dirty_start as u64..dirty_end as u64, + reader: Box::pin(Cursor::new(expected[dirty_start..dirty_end].to_vec())), + }, + DirtyInput { + range: original_size..total_size, + reader: Box::pin(Cursor::new(append_extra)), + }, + ]; + let result = + upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs, total_size) + .await + .unwrap(); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded, expected, "content mismatch (mid-edit + append regression)"); + let clean_hash = upload_file(&config, &expected).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + + // Regression: empty original + append. Codex caught that we'd return an internal error + // instead of treating it as a fresh upload. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_empty_original_append() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original_data: &[u8] = &[]; + let original_hash = upload_file(&config, original_data).await; + let new_data: Vec = (0..32 * 1024).map(|i| (i % 251) as u8).collect(); + let total_size = new_data.len() as u64; + + let inputs = vec![DirtyInput { + range: 0..total_size, + reader: Box::pin(Cursor::new(new_data.clone())), + }]; + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, 0, inputs, total_size) + .await + .unwrap(); + + let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; + assert_eq!(downloaded, new_data); + let clean_hash = upload_file(&config, &new_data).await; + assert_eq!(result.hash(), clean_hash.hex(), "hash mismatch with clean upload"); + } + + // Regression: truncating to empty must produce the canonical empty-file hash + // (`MerkleHash::default()` without HMAC), not `default.hmac(default)`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_truncate_to_empty_matches_clean_empty() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original_data = random_data(11, 64 * 1024); + let original_hash = upload_file(&config, &original_data).await; + let original_size = original_data.len() as u64; + + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], 0) + .await + .unwrap(); + + let clean_empty = upload_file(&config, &[]).await; + assert_eq!(result.hash(), clean_empty.hex(), "truncate-to-empty must match clean empty upload hash"); + } } From 2b572dc1b7f666bee1639b4f4f682d72e1b47cf2 Mon Sep 17 00:00:00 2001 From: Adrien Date: Fri, 1 May 2026 21:16:10 +0200 Subject: [PATCH 21/38] refactor(range_upload): address PR feedback from seanses - Extract caller-input validation into `validate_dirty_ranges` helper, now reused by `upload_ranges` and `upload_fresh_file`. - Replace `DataError::InternalError` with `DataError::ParameterError` for parameter-shape errors (sortedness, bounds, append coverage). The remaining `InternalError`s are for genuine internal failures (missing reconstruction info, MerkleHashSubtree::merge error, etc.). - Drop the redundant `covered_up_to >= original_size` guard: `covered_up_to` starts at `original_size` and only grows via `.max(end)`, so it is always `>= original_size` inside the loop body. - Use `ParameterError("file not found")` when reconstruction info is missing for the requested `original_hash`. --- xet_data/src/processing/range_upload.rs | 93 ++++++++++++++----------- 1 file changed, 52 insertions(+), 41 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 2ca1aab95..a33b0c082 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -93,50 +93,13 @@ pub async fn upload_ranges( return upload_fresh_file(config, dirty_inputs, total_size).await; } - // Validate the caller-provided dirty ranges. let dirty_ranges_pairs: Vec<(u64, u64)> = dirty_inputs.iter().map(|d| (d.range.start, d.range.end)).collect(); - - if !dirty_ranges_pairs.windows(2).all(|w| w[0].1 <= w[1].0) { - return Err(DataError::InternalError(format!( - "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges_pairs:?}" - ))); - } - if !dirty_ranges_pairs.iter().all(|&(s, e)| s < e) { - return Err(DataError::InternalError(format!( - "dirty_ranges must be non-empty intervals, got: {dirty_ranges_pairs:?}" - ))); - } - if let Some(&(_, last_end)) = dirty_ranges_pairs.last() - && last_end > total_size - { - return Err(DataError::InternalError(format!( - "dirty_range end ({last_end}) exceeds total_size ({total_size})" - ))); - } - if total_size > original_size { - let last_input_end = dirty_ranges_pairs.last().map_or(0, |&(_, e)| e); - if last_input_end < total_size { - return Err(DataError::InternalError(format!( - "total_size ({total_size}) > original_size ({original_size}) but dirty_inputs \ - only cover up to byte {last_input_end} (must reach total_size)" - ))); - } - let mut covered_up_to = original_size; - for &(start, end) in &dirty_ranges_pairs { - if start > covered_up_to && covered_up_to >= original_size { - return Err(DataError::InternalError(format!( - "gap in append region: bytes [{covered_up_to}, {start}) are beyond \ - original_size ({original_size}) and not covered by any dirty input" - ))); - } - covered_up_to = covered_up_to.max(end); - } - } + validate_dirty_ranges(&dirty_ranges_pairs, original_size, total_size)?; let recon_result = cas_client.get_file_reconstruction_info(&original_hash).await?; let original_mdb = recon_result .map(|(mdb, _)| mdb) - .ok_or_else(|| DataError::InternalError("no reconstruction info for original file".into()))?; + .ok_or_else(|| DataError::ParameterError("file not found".into()))?; debug_assert_eq!(original_mdb.file_size(), original_size, "reconstruction info disagrees with original_size"); // `seg_byte_starts[i]` is the first byte of segment `i`; the trailing entry equals `original_size`. @@ -411,6 +374,54 @@ pub async fn upload_ranges( Ok(XetFileInfo::new(combined_hash.hex(), total_size)) } +/// Validate the caller-provided dirty ranges. +/// +/// `dirty_ranges` must be sorted, non-overlapping, and contain only non-empty intervals +/// whose end is `<= total_size`. When `total_size > original_size` (append), the inputs +/// must reach `total_size` and cover the entire `[original_size, total_size)` tail with +/// no gap. +fn validate_dirty_ranges(dirty_ranges: &[(u64, u64)], original_size: u64, total_size: u64) -> Result<()> { + if !dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0) { + return Err(DataError::ParameterError(format!( + "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" + ))); + } + if !dirty_ranges.iter().all(|&(s, e)| s < e) { + return Err(DataError::ParameterError(format!( + "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" + ))); + } + if let Some(&(_, last_end)) = dirty_ranges.last() + && last_end > total_size + { + return Err(DataError::ParameterError(format!( + "dirty_range end ({last_end}) exceeds total_size ({total_size})" + ))); + } + if total_size > original_size { + let last_input_end = dirty_ranges.last().map_or(0, |&(_, e)| e); + if last_input_end < total_size { + return Err(DataError::ParameterError(format!( + "total_size ({total_size}) > original_size ({original_size}) but dirty_inputs \ + only cover up to byte {last_input_end} (must reach total_size)" + ))); + } + // Walk the append tail. `covered_up_to` starts at `original_size` and only ever + // grows, so any range whose `start` runs ahead of it leaves an uncovered gap. + let mut covered_up_to = original_size; + for &(start, end) in dirty_ranges { + if start > covered_up_to { + return Err(DataError::ParameterError(format!( + "gap in append region: bytes [{covered_up_to}, {start}) are beyond \ + original_size ({original_size}) and not covered by any dirty input" + ))); + } + covered_up_to = covered_up_to.max(end); + } + } + Ok(()) +} + /// Upload a brand-new file from `dirty_inputs` (no original to compose against). /// Used when the original file is empty: the caller-provided inputs already cover /// `[0, total_size)` (verified here), so we just stream them through the cleaner and @@ -423,7 +434,7 @@ async fn upload_fresh_file( let mut cursor = 0u64; for input in &dirty_inputs { if input.range.start > cursor { - return Err(DataError::InternalError(format!( + return Err(DataError::ParameterError(format!( "empty original: gap in dirty_inputs at [{cursor}, {})", input.range.start ))); @@ -431,7 +442,7 @@ async fn upload_fresh_file( cursor = input.range.end; } if cursor < total_size { - return Err(DataError::InternalError(format!( + return Err(DataError::ParameterError(format!( "empty original: dirty_inputs only cover up to byte {cursor} (must reach total_size {total_size})" ))); } From 56577658061d296d48b4fb7d3713ea60fb64a6aa Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 2 May 2026 08:37:09 +0200 Subject: [PATCH 22/38] feat(range_upload): support resize edits (insert / delete / arbitrary replace) Switch `DirtyInput` from output-coordinate ranges (where the reader had to yield exactly `range.end - range.start` bytes) to original-coordinate edits with an explicit `new_length`. This expresses pure inserts, pure deletes, and arbitrary in-place replaces in a single uniform shape. API: - `DirtyInput { original_range: Range, reader, new_length: u64 }` - `upload_ranges(...)` no longer takes `total_size`; it derives it from the inputs. Mapping legacy callers: - in-place edit: `original_range: a..b, new_length: b - a` - pure insert: `original_range: X..X, new_length: N` - pure delete: `original_range: a..b, new_length: 0` - append: `original_range: original_size..original_size, new_length: N` - truncate to N: `original_range: N..original_size, new_length: 0` Implementation: - Snap each edit's `original_range` to enclosing segment boundaries before hitting the server. Pure inserts at `original_size` snap to the last segment; inserts elsewhere snap to the segment containing the position. Server windows come back equal to the snapped ranges. - Per-window loop walks `&mut dirty_inputs[input_idx..edits_end]` once; `edits_end` is found via `take_while` with a closure that tracks the EOF-insert boundary. Single fold for `removed`/`added` totals; streaming buffer hoisted out of the inner loop. - Hash composition (`MerkleHashSubtree::merge`) and segment-swap composition unchanged. - `validate_dirty_ranges` now runs at the very top of `upload_ranges`, before the empty-original short-circuit. Fixes a real bug in the previously pushed code where `original_size == 0` bypassed validation entirely (Adrien spotted it on review): the empty-original path's gap-only check accepted overlapping / oversize ranges and would feed mismatched bytes to the cleaner. Tests: - 6 new resize tests (replace grow, replace shrink, mid-file insert, mid-file delete, multi-edit mix, insert at segment boundary). - `test_resize_edits_abc` covers the three motivating one-liners (`abc -> foobc`, `abc -> fooabc`, `abc -> bc`). - `test_empty_original_validates_ranges` locks the validation order against the bug Adrien reported. - New `assert_edits` helper consumed by all the resize tests; obsolete `make_dummy_inputs` validation tests for empty-range / append-without-coverage removed (the new model accepts pure inserts and has no implicit coverage rule). 27/27 tests pass; CI clippy clean. --- xet_data/src/processing/range_upload.rs | 929 ++++++++++++++++-------- 1 file changed, 611 insertions(+), 318 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index a33b0c082..95c673f29 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -20,13 +20,21 @@ use super::file_upload_session::FileUploadSession; use crate::error::{DataError, Result}; use crate::file_reconstruction::FileReconstructor; -/// A dirty byte range paired with an async reader that provides the modified bytes. +/// A single edit applied to the original file: replace `original_range` with `new_length` +/// bytes from `reader`. /// -/// Each `DirtyInput` represents a contiguous region of the file that was modified by the -/// caller. The `reader` must yield exactly `range.end - range.start` bytes. +/// All three combinations are supported: +/// - `original_range.len() == new_length` → in-place edit (no file size change). +/// - `original_range.len() != new_length` → resize edit (file grows or shrinks). +/// - `original_range.start == original_range.end` → pure insert at that position. +/// - `new_length == 0` → pure delete of `original_range`. +/// +/// Pure append at end of file is `{ original_range: original_size..original_size, new_length: N, reader: ... }`. +/// Pure truncate-to-N is `{ original_range: N..original_size, new_length: 0, reader: empty }`. pub struct DirtyInput { - pub range: Range, + pub original_range: Range, pub reader: Pin>, + pub new_length: u64, } /// Size of blocks read from the dirty source and fed to the cleaner. @@ -42,31 +50,34 @@ struct UploadedWindow { chunks: ChunkHashList, } -/// Upload modified ranges of an existing file, composing the result with -/// the original file's CAS segments. Only the dirty regions (plus CDC boundary -/// chunks) are re-uploaded; stable regions between and around dirty ranges are -/// reused from the original file's reconstruction plan. +/// Upload an edited version of an existing file, reusing the unchanged regions from the +/// original file's CAS segments and only re-uploading the parts the caller actually +/// rewrites. +/// +/// `dirty_inputs` is a list of edits expressed in the **original file's coordinates**. +/// Each edit replaces `original_range` with `new_length` bytes from its reader; resize +/// edits (including pure inserts and pure deletes) are supported. The output file size is +/// derived from the inputs. /// /// # When to use /// -/// - **Mid-file edit**: pass modified byte ranges in `dirty_inputs`, same `total_size`. -/// - **Append**: include `[original_size, total_size)` in `dirty_inputs` with a reader for the new bytes (including -/// sparse gaps). The last original chunk is automatically re-chunked. -/// - **Truncation**: pass `dirty_inputs = vec![]`, `total_size < original_size`. The boundary chunk at the cut point is -/// re-uploaded from CAS automatically. -/// - **No change**: pass `dirty_inputs = vec![]`, `total_size == original_size`. Returns the original hash immediately -/// (no CAS calls). +/// - **In-place edit**: `original_range.len() == new_length`, file size unchanged. +/// - **Resize edit**: any `new_length`. Replaces `original_range.len()` original bytes with `new_length` new bytes. +/// - **Pure insert**: `original_range.start == original_range.end`, `new_length > 0`. +/// - **Pure delete**: `original_range.start < original_range.end`, `new_length == 0`. +/// - **Append**: `original_range == original_size..original_size`, `new_length > 0`. +/// - **Truncate to N**: `original_range == N..original_size`, `new_length == 0`. +/// - **No change**: empty `dirty_inputs`. Returns the original hash without any CAS call. /// /// # Arguments /// /// * `config` - Translator configuration for creating upload sessions. -/// * `cas_client` - CAS client for fetching original file metadata and downloading boundary chunks. +/// * `cas_client` - CAS client for fetching original file metadata and downloading boundary bytes. /// * `original_hash` - Merkle hash of the original file in CAS. /// * `original_size` - Size of the original file in bytes. -/// * `dirty_inputs` - Sorted, non-overlapping dirty ranges, each paired with an async reader that yields exactly the -/// bytes for that range. Bytes outside these ranges within `[0, original_size)` are reconstructed from CAS. Each +/// * `dirty_inputs` - Edits to apply. Must be sorted by `original_range.start` and non-overlapping +/// (`prev.original_range.end <= next.original_range.start`). Each reader must yield exactly `new_length` bytes. Each /// reader is consumed exactly once. -/// * `total_size` - Total size of the modified file. /// /// # Limitations /// @@ -80,22 +91,21 @@ pub async fn upload_ranges( original_hash: MerkleHash, original_size: u64, mut dirty_inputs: Vec, - total_size: u64, ) -> Result { - if dirty_inputs.is_empty() && total_size == original_size { + validate_dirty_ranges(&dirty_inputs, original_size)?; + let total_size = compute_total_size(original_size, &dirty_inputs); + + if dirty_inputs.is_empty() { + debug_assert_eq!(total_size, original_size); return Ok(XetFileInfo::new(original_hash.hex(), original_size)); } - // Empty original: nothing to compose against — upload as a fresh file. Validation below - // still runs (via the early return path inside `upload_fresh_file`) to enforce that the - // caller provided coverage of `[0, total_size)`. + // Empty original: nothing to compose against — upload as a fresh file (concatenation of + // the edits' new bytes, since every `original_range` must be `0..0`). if original_size == 0 { return upload_fresh_file(config, dirty_inputs, total_size).await; } - let dirty_ranges_pairs: Vec<(u64, u64)> = dirty_inputs.iter().map(|d| (d.range.start, d.range.end)).collect(); - validate_dirty_ranges(&dirty_ranges_pairs, original_size, total_size)?; - let recon_result = cas_client.get_file_reconstruction_info(&original_hash).await?; let original_mdb = recon_result .map(|(mdb, _)| mdb) @@ -115,32 +125,26 @@ pub async fn upload_ranges( // segments during composition, so the client never has to truncate a segment mid-chunk. // Safe to send to the server because segment edges are chunk edges, so the server's // chunk-aligned windows come back identical to our snapped ranges. - let mut snapped: Vec<(u64, u64)> = Vec::with_capacity(dirty_ranges_pairs.len() + 1); - for &(start, end) in &dirty_ranges_pairs { - let in_start = start.min(original_size); - let in_end = end.min(original_size); - if in_start >= in_end { - continue; - } - snapped - .push((snap_to_segment_start(&seg_byte_starts, in_start), snap_to_segment_end(&seg_byte_starts, in_end))); - } - if total_size < original_size { - let snap_start = snap_to_segment_start(&seg_byte_starts, total_size); - if snap_start < original_size { - // total_size+1 forces the snap forward to the first boundary strictly past the cut. - let snap_end = snap_to_segment_end(&seg_byte_starts, total_size + 1); - snapped.push((snap_start, snap_end)); - } - } - if total_size > original_size && !original_mdb.segments.is_empty() { - // Append: always include the last original segment in the upload set so its boundary - // extends past `original_size` into the appended bytes. Needed both for pure append - // and for mid-edit-plus-append (otherwise the last existing window would be the one - // we extend to `total_size`, which corrupts files with a stable tail before the - // appended region). Coalescing below merges this with any overlapping range. - let n = original_mdb.segments.len(); - snapped.push((seg_byte_starts[n - 1], seg_byte_starts[n])); + // + // Pure inserts (`original_range.start == original_range.end`) snap to the segment that + // owns the insert position so the cleaner has enough surrounding bytes to re-chunk + // around the insertion. An insert at `original_size` snaps to the last segment. + let n_segs = original_mdb.segments.len(); + let mut snapped: Vec<(u64, u64)> = Vec::with_capacity(dirty_inputs.len()); + for input in &dirty_inputs { + let r = &input.original_range; + let (s, e) = if r.start == r.end { + // Pure insert: pick the segment containing `r.start`. At end-of-file, fall back to + // the last segment. + if r.start == original_size { + (seg_byte_starts[n_segs - 1], seg_byte_starts[n_segs]) + } else { + (snap_to_segment_start(&seg_byte_starts, r.start), snap_to_segment_end(&seg_byte_starts, r.start + 1)) + } + } else { + (snap_to_segment_start(&seg_byte_starts, r.start), snap_to_segment_end(&seg_byte_starts, r.end)) + }; + snapped.push((s, e)); } snapped.sort_by_key(|&(s, _)| s); @@ -157,9 +161,7 @@ pub async fn upload_ranges( let server_query: Vec = coalesced.iter().map(|&(s, e)| FileRange::new(s, e)).collect(); if server_query.is_empty() { - return Err(DataError::InternalError( - "internal: no server query computed for non-trivial upload_ranges call".into(), - )); + return Err(DataError::InternalError("internal: non-empty dirty_inputs produced no server query".into())); } let n_windows = server_query.len(); @@ -172,81 +174,71 @@ pub async fn upload_ranges( let mut input_idx = 0usize; let mut uploaded: Vec = Vec::with_capacity(n_windows); - let last_idx = n_windows - 1; - for (idx, window) in response.windows.iter().enumerate() { + let mut buf = vec![0u8; STREAM_BLOCK_SIZE]; + for window in response.windows.iter() { let w_start = window.dirty_byte_range[0]; - let w_end_in_original = window.dirty_byte_range[1]; + let w_end = window.dirty_byte_range[1]; - let effective_end = if idx == last_idx && total_size != original_size { - total_size - } else { - w_end_in_original - }; + // Find the slice of edits that land in this window. A pure insert at exactly + // `w_end` belongs here only when `w_end == original_size` (no later window can + // take it); anywhere else it belongs to the next window starting at that byte. + let edits_end = dirty_inputs[input_idx..] + .iter() + .take_while(|d| { + let r = &d.original_range; + if r.start == r.end { + r.start < w_end || (r.start == w_end && w_end == original_size) + } else { + r.end <= w_end + } + }) + .count() + + input_idx; + let window_edits = &mut dirty_inputs[input_idx..edits_end]; - // Cleaner was sized to exactly `middle_size`; never stream past it (matters for truncation - // where the segment runs further than `effective_end`). - let original_window_end = w_end_in_original.min(original_size).min(effective_end); - let middle_size = effective_end - w_start; + let (removed, added): (u64, u64) = window_edits + .iter() + .map(|d| (d.original_range.end - d.original_range.start, d.new_length)) + .fold((0, 0), |(rm, ad), (r, a)| (rm + r, ad + a)); + let middle_size = (w_end - w_start) + added - removed; let (_id, mut cleaner) = session.start_clean(None, Some(middle_size), Sha256Policy::Skip)?; let mut cursor = w_start; - while input_idx < dirty_inputs.len() { - let input_range_start = dirty_inputs[input_idx].range.start; - if input_range_start >= effective_end { - break; - } - let input = &mut dirty_inputs[input_idx]; - let input_start = input.range.start.max(w_start); - let input_end = input.range.end.min(effective_end); - - // CAS gap before this input (within the original file). - if cursor < input_start { - let gap_end = input_start.min(original_window_end); - if cursor < gap_end { - stream_cas_range(&ctx, &cas_client, original_hash, cursor, gap_end, &mut cleaner).await?; - } - if input_start > original_size && cursor < input_start { - return Err(DataError::InternalError(format!( - "gap in dirty_inputs: no data for bytes [{cursor}, {input_start}) \ - (beyond original_size {original_size})" - ))); - } + for input in window_edits.iter_mut() { + let edit_start = input.original_range.start; + let edit_end = input.original_range.end; + debug_assert!(edit_start >= w_start && edit_end <= w_end, "edit straddles window (validation bug)"); + + if cursor < edit_start { + stream_cas_range(&ctx, &cas_client, original_hash, cursor, edit_start, &mut cleaner).await?; } - // Stream the dirty bytes from the async reader. - let bytes_to_read = (input_end - input_start) as usize; - let mut remaining = bytes_to_read; - let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining.max(1))]; + let mut remaining = input.new_length as usize; while remaining > 0 { let to_read = buf.len().min(remaining); input.reader.read_exact(&mut buf[..to_read]).await.map_err(|err| { DataError::InternalError(format!( "failed to read dirty input [{}, {}): {err}", - input.range.start, input.range.end + input.original_range.start, input.original_range.end )) })?; cleaner.add_data(&buf[..to_read]).await?; remaining -= to_read; } - cursor = input_end; - if input.range.end <= effective_end { - input_idx += 1; - } else { - break; - } + cursor = edit_end; } + input_idx = edits_end; - // CAS suffix: stable bytes after the last input within the original portion. - if cursor < original_window_end { - stream_cas_range(&ctx, &cas_client, original_hash, cursor, original_window_end, &mut cleaner).await?; + if cursor < w_end { + stream_cas_range(&ctx, &cas_client, original_hash, cursor, w_end, &mut cleaner).await?; } let (info, chunks, _metrics) = cleaner.finish().await?; uploaded.push(UploadedWindow { start: w_start, - end: effective_end, + end: w_end, info, chunks, }); @@ -258,15 +250,11 @@ pub async fn upload_ranges( mdb_list.into_iter().map(|m| (m.metadata.file_hash, m)).collect(); // Merge sequence: [gap0, w0, gap1, w1, ..., gapN]. Empty gaps (`None`) are skipped. - // For truncation, the trailing gap covers bytes that no longer exist and is dropped. let mut hash_ranges = response.hash_ranges; - let trailing_gap = if total_size >= original_size { - hash_ranges.pop().flatten() - } else { - hash_ranges.pop().and(None) - }; + let trailing_gap = hash_ranges.pop().flatten(); let first_window_at_start = hash_ranges.first().is_some_and(Option::is_none); let last_window_at_end = trailing_gap.is_none(); + let last_idx = uploaded.len() - 1; let mut merge_seq: Vec = Vec::with_capacity(2 * n_windows + 1); for (i, (w, gap)) in uploaded.iter().zip(hash_ranges).enumerate() { @@ -335,11 +323,9 @@ pub async fn upload_ranges( all_verification.extend_from_slice(&middle_mdb.verification); } } - if total_size >= original_size { - while seg_idx < n_segs { - emit_seg(seg_idx, &mut all_segments, &mut all_verification); - seg_idx += 1; - } + while seg_idx < n_segs { + emit_seg(seg_idx, &mut all_segments, &mut all_verification); + seg_idx += 1; } debug!( @@ -361,7 +347,7 @@ pub async fn upload_ranges( session.register_composed_file(composed_mdb).await?; session.finalize().await?; - let total_dirty: u64 = dirty_ranges_pairs.iter().map(|(s, e)| e - s).sum(); + let total_dirty: u64 = dirty_inputs.iter().map(|d| d.new_length).sum(); info!( "upload_ranges: hash={} size={} (original={}, {} windows, {} dirty bytes)", combined_hash.hex(), @@ -376,89 +362,62 @@ pub async fn upload_ranges( /// Validate the caller-provided dirty ranges. /// -/// `dirty_ranges` must be sorted, non-overlapping, and contain only non-empty intervals -/// whose end is `<= total_size`. When `total_size > original_size` (append), the inputs -/// must reach `total_size` and cover the entire `[original_size, total_size)` tail with -/// no gap. -fn validate_dirty_ranges(dirty_ranges: &[(u64, u64)], original_size: u64, total_size: u64) -> Result<()> { - if !dirty_ranges.windows(2).all(|w| w[0].1 <= w[1].0) { - return Err(DataError::ParameterError(format!( - "dirty_ranges must be sorted and non-overlapping, got: {dirty_ranges:?}" - ))); - } - if !dirty_ranges.iter().all(|&(s, e)| s < e) { - return Err(DataError::ParameterError(format!( - "dirty_ranges must be non-empty intervals, got: {dirty_ranges:?}" - ))); - } - if let Some(&(_, last_end)) = dirty_ranges.last() - && last_end > total_size - { - return Err(DataError::ParameterError(format!( - "dirty_range end ({last_end}) exceeds total_size ({total_size})" - ))); - } - if total_size > original_size { - let last_input_end = dirty_ranges.last().map_or(0, |&(_, e)| e); - if last_input_end < total_size { +/// `dirty_inputs` must be sorted by `original_range.start`, non-overlapping +/// (`prev.original_range.end <= next.original_range.start`), and every +/// `original_range.end <= original_size`. Empty ranges (pure inserts) are allowed at any +/// position including `original_size` (append). +fn validate_dirty_ranges(dirty_inputs: &[DirtyInput], original_size: u64) -> Result<()> { + let mut prev_end = 0u64; + for (i, input) in dirty_inputs.iter().enumerate() { + let r = &input.original_range; + if r.start > r.end { return Err(DataError::ParameterError(format!( - "total_size ({total_size}) > original_size ({original_size}) but dirty_inputs \ - only cover up to byte {last_input_end} (must reach total_size)" + "dirty_inputs[{i}].original_range is reversed: {}..{}", + r.start, r.end ))); } - // Walk the append tail. `covered_up_to` starts at `original_size` and only ever - // grows, so any range whose `start` runs ahead of it leaves an uncovered gap. - let mut covered_up_to = original_size; - for &(start, end) in dirty_ranges { - if start > covered_up_to { - return Err(DataError::ParameterError(format!( - "gap in append region: bytes [{covered_up_to}, {start}) are beyond \ - original_size ({original_size}) and not covered by any dirty input" - ))); - } - covered_up_to = covered_up_to.max(end); + if r.end > original_size { + return Err(DataError::ParameterError(format!( + "dirty_inputs[{i}].original_range end ({}) exceeds original_size ({original_size})", + r.end + ))); + } + if i > 0 && r.start < prev_end { + return Err(DataError::ParameterError(format!( + "dirty_inputs[{i}].original_range overlaps the previous edit (starts at {} < {prev_end})", + r.start + ))); } + prev_end = r.end; } Ok(()) } +/// Compute the resulting file size: `original_size` minus bytes removed plus bytes added. +fn compute_total_size(original_size: u64, dirty_inputs: &[DirtyInput]) -> u64 { + let (removed, added) = dirty_inputs + .iter() + .fold((0u64, 0u64), |(r, a), d| (r + (d.original_range.end - d.original_range.start), a + d.new_length)); + original_size + added - removed +} + /// Upload a brand-new file from `dirty_inputs` (no original to compose against). -/// Used when the original file is empty: the caller-provided inputs already cover -/// `[0, total_size)` (verified here), so we just stream them through the cleaner and -/// finalize the session. +/// Used when the original file is empty: every edit's `original_range` is `0..0`, so we +/// just stream their new bytes through the cleaner. async fn upload_fresh_file( config: Arc, mut dirty_inputs: Vec, total_size: u64, ) -> Result { - let mut cursor = 0u64; - for input in &dirty_inputs { - if input.range.start > cursor { - return Err(DataError::ParameterError(format!( - "empty original: gap in dirty_inputs at [{cursor}, {})", - input.range.start - ))); - } - cursor = input.range.end; - } - if cursor < total_size { - return Err(DataError::ParameterError(format!( - "empty original: dirty_inputs only cover up to byte {cursor} (must reach total_size {total_size})" - ))); - } - let session = FileUploadSession::new(config).await?; let (_id, mut cleaner) = session.start_clean(None, Some(total_size), Sha256Policy::Skip)?; for input in &mut dirty_inputs { - let mut remaining = (input.range.end - input.range.start) as usize; + let mut remaining = input.new_length as usize; let mut buf = vec![0u8; STREAM_BLOCK_SIZE.min(remaining.max(1))]; while remaining > 0 { let to_read = buf.len().min(remaining); input.reader.read_exact(&mut buf[..to_read]).await.map_err(|err| { - DataError::InternalError(format!( - "failed to read dirty input [{}, {}): {err}", - input.range.start, input.range.end - )) + DataError::InternalError(format!("failed to read dirty input at {}: {err}", input.original_range.start)) })?; cleaner.add_data(&buf[..to_read]).await?; remaining -= to_read; @@ -529,15 +488,16 @@ mod tests { mdb.segments.iter().map(|s| s.unpacked_segment_bytes as u64).collect() } - /// Build `DirtyInput`s from a source buffer and range list. Each input gets - /// a `Cursor` over the corresponding slice of `data`. + /// Build in-place `DirtyInput`s (each edit's `new_length` matches its `original_range` + /// length, so the file size doesn't change) from a source buffer and range list. fn make_dirty_inputs(ranges: &[(u64, u64)], data: &[u8]) -> Vec { ranges .iter() .map(|&(start, end)| { let slice = data[start as usize..end as usize].to_vec(); DirtyInput { - range: start..end, + original_range: start..end, + new_length: end - start, reader: Box::pin(Cursor::new(slice)), } }) @@ -550,12 +510,58 @@ mod tests { ranges .iter() .map(|&(start, end)| DirtyInput { - range: start..end, + original_range: start..end, + new_length: end - start, reader: Box::pin(Cursor::new(Vec::new())), }) .collect() } + /// Bridge the old `(start, end)` + `total_size`-shaped test cases into the new edit + /// model. `(start, end)` indexes into `data` AND describes the output byte range. + /// Handles in-place edits, pure appends, mid-edit-plus-append spans (split into two + /// edits), and trailing truncations. + fn make_legacy_inputs(specs: &[(u64, u64)], data: &[u8], original_size: u64, total_size: u64) -> Vec { + let mut out: Vec = Vec::new(); + for &(start, end) in specs { + let bytes = data[start as usize..end as usize].to_vec(); + if end <= original_size { + out.push(DirtyInput { + original_range: start..end, + new_length: bytes.len() as u64, + reader: Box::pin(Cursor::new(bytes)), + }); + } else if start >= original_size { + out.push(DirtyInput { + original_range: original_size..original_size, + new_length: bytes.len() as u64, + reader: Box::pin(Cursor::new(bytes)), + }); + } else { + let split = (original_size - start) as usize; + let (head, tail) = bytes.split_at(split); + out.push(DirtyInput { + original_range: start..original_size, + new_length: head.len() as u64, + reader: Box::pin(Cursor::new(head.to_vec())), + }); + out.push(DirtyInput { + original_range: original_size..original_size, + new_length: tail.len() as u64, + reader: Box::pin(Cursor::new(tail.to_vec())), + }); + } + } + if total_size < original_size { + out.push(DirtyInput { + original_range: total_size..original_size, + new_length: 0, + reader: Box::pin(Cursor::new(Vec::new())), + }); + } + out + } + // original: [=========================== 256 KB ===========================] // dirty: [== 1 KB ===] // result: [===stable===][re-uploaded][============stable=================] @@ -594,8 +600,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(dirty_start as u64, dirty_end as u64)], &modified_data), - total_size, + make_legacy_inputs(&[(dirty_start as u64, dirty_end as u64)], &modified_data, original_size, total_size), ) .await .unwrap(); @@ -635,10 +640,15 @@ mod tests { // Truncate to 100 KB (no dirty ranges, pure truncation). let truncated_size = 100_000u64; - let result = - upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], truncated_size) - .await - .unwrap(); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + make_legacy_inputs(&[], &[], original_size, truncated_size), + ) + .await + .unwrap(); assert_eq!(result.file_size(), Some(truncated_size)); @@ -675,8 +685,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(original_size, total_size)], &full_data), - total_size, + make_legacy_inputs(&[(original_size, total_size)], &full_data, original_size, total_size), ) .await .unwrap(); @@ -715,8 +724,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(0, 4096)], &modified_data), - total_size, + make_legacy_inputs(&[(0, 4096)], &modified_data, original_size, total_size), ) .await .unwrap(); @@ -756,8 +764,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(10_000, 12_000), (200_000, 202_000)], &modified_data), - total_size, + make_legacy_inputs(&[(10_000, 12_000), (200_000, 202_000)], &modified_data, original_size, total_size), ) .await .unwrap(); @@ -803,8 +810,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(original_size, total_size)], &full_data), - total_size, + make_legacy_inputs(&[(original_size, total_size)], &full_data, original_size, total_size), ) .await .unwrap(); @@ -846,8 +852,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(original_size, total_size)], &sparse_staging), - total_size, + make_legacy_inputs(&[(original_size, total_size)], &sparse_staging, original_size, total_size), ) .await .unwrap(); @@ -947,8 +952,7 @@ mod tests { cas_client.clone(), original_hash, size, - make_dirty_inputs(&[(boundary, dirty_end)], &expected), - size, + make_legacy_inputs(&[(boundary, dirty_end)], &expected, size, size), ) .await .unwrap(); @@ -972,7 +976,9 @@ mod tests { let data = random_data(70, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - let result = upload_ranges(config, cas_client, hash, size, vec![], size).await.unwrap(); + let result = upload_ranges(config, cas_client, hash, size, make_legacy_inputs(&[], &[], size, size)) + .await + .unwrap(); assert_eq!(result.hash(), hash.hex()); assert_eq!(result.file_size(), Some(size)); @@ -989,7 +995,7 @@ mod tests { let data = random_data(71, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, size + 1)]), size).await; + let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, size + 1)])).await; assert!(err.is_err(), "dirty range past total_size should be rejected"); } @@ -1003,23 +1009,37 @@ mod tests { let data = random_data(60, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - let err = - upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, 300), (200, 400)]), size).await; + let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, 300), (200, 400)])).await; assert!(err.is_err(), "overlapping ranges should be rejected"); } + // Regression: validation must run *before* the `original_size == 0` short-circuit, or + // the empty-original path silently accepts ranges that exceed `original_size`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_rejects_empty_dirty_range() { + async fn test_empty_original_validates_ranges() { let server = LocalTestServerBuilder::new().start().await; let base_dir = TempDir::new().unwrap(); let config = test_config(server.http_endpoint(), base_dir.path()); let cas_client: Arc = Arc::new(server); - let data = random_data(61, 256 * 1024); - let hash = upload_file(&config, &data).await; - let size = data.len() as u64; - let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(100, 100)]), size).await; - assert!(err.is_err(), "empty range (start == end) should be rejected"); + let original_hash = upload_file(&config, &[]).await; + + // `0..10` and `5..15` both have `end > original_size == 0`; either would corrupt + // the upload if validation didn't fire. + let inputs = vec![ + DirtyInput { + original_range: 0..10, + new_length: 10, + reader: Box::pin(Cursor::new(vec![0xAA; 10])), + }, + DirtyInput { + original_range: 5..15, + new_length: 10, + reader: Box::pin(Cursor::new(vec![0xBB; 10])), + }, + ]; + let err = upload_ranges(config, cas_client, original_hash, 0, inputs).await; + assert!(err.is_err(), "ranges with end > original_size must be rejected for empty originals too"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1032,83 +1052,15 @@ mod tests { let data = random_data(62, 256 * 1024); let hash = upload_file(&config, &data).await; let size = data.len() as u64; - let err = - upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(300, 400), (100, 200)]), size).await; + let err = upload_ranges(config, cas_client, hash, size, make_dummy_inputs(&[(300, 400), (100, 200)])).await; assert!(err.is_err(), "unsorted ranges should be rejected"); } - // total_size > original_size but dirty_inputs don't cover appended region -> rejected. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_rejects_append_without_dirty_inputs() { - let server = LocalTestServerBuilder::new().start().await; - let base_dir = TempDir::new().unwrap(); - let config = test_config(server.http_endpoint(), base_dir.path()); - let cas_client: Arc = Arc::new(server); - - let data = random_data(63, 256 * 1024); - let hash = upload_file(&config, &data).await; - let size = data.len() as u64; - let bigger = size + 1000; - - // No dirty inputs but total_size > original_size. - let err = upload_ranges(config.clone(), cas_client.clone(), hash, size, vec![], bigger).await; - assert!(err.is_err(), "append without dirty_inputs covering appended bytes should be rejected"); - - // Dirty input stops before total_size. - let partial = make_dirty_inputs(&[(size, size + 500)], &vec![0xEEu8; bigger as usize]); - let err = upload_ranges(config.clone(), cas_client.clone(), hash, size, partial, bigger).await; - assert!(err.is_err(), "append with partial coverage should be rejected"); - - // Dirty input covers end but leaves gap after original_size. - let gap_start = make_dirty_inputs(&[(size + 100, bigger)], &vec![0xEEu8; bigger as usize]); - let err = upload_ranges(config, cas_client, hash, size, gap_start, bigger).await; - assert!(err.is_err(), "append with gap at start of append region should be rejected"); - } - - // Regression: dirty_inputs = [(original_size + 100, total_size)] passes the old - // "last input reaches total_size" check, but leaves a gap [original_size, original_size + 100) - // that is beyond original_size (CAS can't fill it) and not covered by any input. - // Without validation, the cleaner silently skips those bytes → corrupted file. - // - // Original: [################] (256 KB) - // Append: [--gap--][=====dirty=====] - // ^ ^ ^ - // original +100 total_size - // = 256KB = 256KB+100 = 256KB+50KB - // - // The gap [256KB, 256KB+100) has no source: CAS stops at 256KB, no input covers it. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_rejects_append_with_gap_after_original_size() { - let server = LocalTestServerBuilder::new().start().await; - let base_dir = TempDir::new().unwrap(); - let config = test_config(server.http_endpoint(), base_dir.path()); - let cas_client: Arc = Arc::new(server); - - let original_data = random_data(42, 256 * 1024); - let original_hash = upload_file(&config, &original_data).await; - let original_size = original_data.len() as u64; - let total_size = original_size + 50_000; - let gap = 100u64; - - // Input starts at original_size + gap, leaving [original_size, original_size + gap) uncovered. - let append_data = vec![0xBBu8; (total_size - original_size - gap) as usize]; - let inputs = vec![DirtyInput { - range: (original_size + gap)..total_size, - reader: Box::pin(std::io::Cursor::new(append_data)), - }]; - - let err = upload_ranges(config, cas_client, original_hash, original_size, inputs, total_size).await; - assert!(err.is_err()); - let msg = format!("{}", err.unwrap_err()); - assert!(msg.contains("gap in append region"), "expected gap-in-append-region error, got: {msg}"); - } - // original: [chunk0][chunk1][chunk2][chunk3][...more chunks...] // input: [========= single large write ==========] // - // A single DirtyInput that spans many chunks. Verifies that the reader is - // consumed correctly even when build_dirty_regions merges multiple chunk - // ranges into one DirtyRegion. + // A single DirtyInput that spans many segments. Verifies the reader is consumed + // correctly across the multi-segment window. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_single_input_spanning_many_chunks() { let server = LocalTestServerBuilder::new().start().await; @@ -1131,8 +1083,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(dirty_start, dirty_end)], &modified), - original_size, + make_legacy_inputs(&[(dirty_start, dirty_end)], &modified, original_size, original_size), ) .await .unwrap(); @@ -1164,20 +1115,14 @@ mod tests { let dirty_data = b"SPARSE"; let dirty_inputs = vec![DirtyInput { - range: 5..11, + original_range: 5..11, + new_length: dirty_data.len() as u64, reader: Box::pin(Cursor::new(dirty_data.to_vec())), }]; - let result = upload_ranges( - config.clone(), - cas_client.clone(), - original_hash, - original_size, - dirty_inputs, - original_size, - ) - .await - .unwrap(); + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, dirty_inputs) + .await + .unwrap(); assert_eq!(result.file_size(), Some(original_size)); @@ -1211,10 +1156,15 @@ mod tests { let truncated_size = 100_000u64; - let result = - upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], truncated_size) - .await - .unwrap(); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + make_legacy_inputs(&[], &[], original_size, truncated_size), + ) + .await + .unwrap(); assert_eq!(result.file_size(), Some(truncated_size)); @@ -1264,8 +1214,7 @@ mod tests { cas_client.clone(), original_hash, original_size, - make_dirty_inputs(&[(dirty_start, dirty_end)], &staging), - truncated_size, + make_legacy_inputs(&[(dirty_start, dirty_end)], &staging, original_size, truncated_size), ) .await .unwrap(); @@ -1311,6 +1260,33 @@ mod tests { std::fs::read(&out).unwrap() } + /// Empty `Box::pin(Cursor::new(Vec::new()))` for delete / dummy edits. + fn empty_reader() -> Pin> { + Box::pin(Cursor::new(Vec::::new())) + } + + /// End-to-end check: upload `original`, apply `inputs` via `upload_ranges`, download, + /// compare against `expected`, and verify the hash matches a clean upload of `expected`. + async fn assert_edits( + config: &Arc, + cas_client: &Arc, + original: &[u8], + inputs: Vec, + expected: &[u8], + ) { + let original_hash = upload_file(config, original).await; + let original_size = original.len() as u64; + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs) + .await + .unwrap(); + assert_eq!(result.file_size(), Some(expected.len() as u64), "file size mismatch"); + let downloaded = + download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), expected.len() as u64).await; + assert_eq!(downloaded, expected, "content mismatch"); + let clean = upload_file(config, expected).await; + assert_eq!(result.hash(), clean.hex(), "hash diverges from clean upload"); + } + async fn assert_range_edit( config: &Arc, cas_client: &Arc, @@ -1325,23 +1301,34 @@ mod tests { // Build dirty inputs from the caller's ranges. let mut inputs = make_dirty_inputs(dirty_ranges, expected); - // For appends, ensure the appended region is included as a dirty input. + // For appends, ensure the appended region is included as a pure-insert edit at the + // end of the original. if total_size > original_size { let append_start = original_size; let already_covered = dirty_ranges.iter().any(|&(s, e)| s <= append_start && e >= total_size); if !already_covered { inputs.push(DirtyInput { - range: append_start..total_size, + original_range: original_size..original_size, + new_length: total_size - original_size, reader: Box::pin(Cursor::new(expected[append_start as usize..total_size as usize].to_vec())), }); - inputs.sort_by_key(|d| d.range.start); + inputs.sort_by_key(|d| d.original_range.start); } } - let result = - upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs, total_size) - .await - .unwrap(); + // Truncation: drop bytes past `total_size` from the original via a pure-delete edit. + if total_size < original_size { + inputs.push(DirtyInput { + original_range: total_size..original_size, + new_length: 0, + reader: empty_reader(), + }); + inputs.sort_by_key(|d| d.original_range.start); + } + + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs) + .await + .unwrap(); assert_eq!(result.file_size(), Some(total_size), "file size mismatch"); let downloaded = download_file(config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; @@ -1376,18 +1363,19 @@ mod tests { let inputs = vec![ DirtyInput { - range: dirty_start as u64..dirty_end as u64, + original_range: dirty_start as u64..dirty_end as u64, + new_length: (dirty_end - dirty_start) as u64, reader: Box::pin(Cursor::new(expected[dirty_start..dirty_end].to_vec())), }, DirtyInput { - range: original_size..total_size, + original_range: original_size..original_size, + new_length: append_extra.len() as u64, reader: Box::pin(Cursor::new(append_extra)), }, ]; - let result = - upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs, total_size) - .await - .unwrap(); + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs) + .await + .unwrap(); let downloaded = download_file(&config, MerkleHash::from_hex(result.hash()).unwrap(), total_size).await; assert_eq!(downloaded, expected, "content mismatch (mid-edit + append regression)"); @@ -1410,10 +1398,11 @@ mod tests { let total_size = new_data.len() as u64; let inputs = vec![DirtyInput { - range: 0..total_size, + original_range: 0..0, + new_length: total_size, reader: Box::pin(Cursor::new(new_data.clone())), }]; - let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, 0, inputs, total_size) + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, 0, inputs) .await .unwrap(); @@ -1436,11 +1425,315 @@ mod tests { let original_hash = upload_file(&config, &original_data).await; let original_size = original_data.len() as u64; - let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, vec![], 0) - .await - .unwrap(); + let result = upload_ranges( + config.clone(), + cas_client.clone(), + original_hash, + original_size, + make_legacy_inputs(&[], &[], original_size, 0), + ) + .await + .unwrap(); let clean_empty = upload_file(&config, &[]).await; assert_eq!(result.hash(), clean_empty.hex(), "truncate-to-empty must match clean empty upload hash"); } + + // The three small examples that motivated the resize-edit API: replace, insert, delete + // on a tiny 3-byte file. Each produces an output of a different length than the input. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_edits_abc() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + // abc + replace [0, 1) with "foo" => "foobc" + assert_edits( + &config, + &cas_client, + b"abc", + vec![DirtyInput { + original_range: 0..1, + new_length: 3, + reader: Box::pin(Cursor::new(b"foo".to_vec())), + }], + b"foobc", + ) + .await; + + // abc + insert "foo" at 0 => "fooabc" + assert_edits( + &config, + &cas_client, + b"abc", + vec![DirtyInput { + original_range: 0..0, + new_length: 3, + reader: Box::pin(Cursor::new(b"foo".to_vec())), + }], + b"fooabc", + ) + .await; + + // abc + delete [0, 1) => "bc" + assert_edits( + &config, + &cas_client, + b"abc", + vec![DirtyInput { + original_range: 0..1, + new_length: 0, + reader: empty_reader(), + }], + b"bc", + ) + .await; + } + + // original: [============= 256 KB =============] + // edit: [== 4K stale ==] + // ^ replaced with 32 K (resize +28 K) + // result: [== prefix ==][== 32K new ==][== suffix ==] + // + // Big in-place replace where new_length >> original_range.len(); exercises the + // resize branch with a multi-segment-touching window. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_large_replace_grows_file() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(101, 256 * 1024); + let drop_start = 100_000usize; + let drop_end = 104_000usize; + let new_bytes: Vec = (0..32 * 1024).map(|i| (i % 251) as u8).collect(); + let mut expected = Vec::with_capacity(original.len() - (drop_end - drop_start) + new_bytes.len()); + expected.extend_from_slice(&original[..drop_start]); + expected.extend_from_slice(&new_bytes); + expected.extend_from_slice(&original[drop_end..]); + + assert_edits( + &config, + &cas_client, + &original, + vec![DirtyInput { + original_range: drop_start as u64..drop_end as u64, + new_length: new_bytes.len() as u64, + reader: Box::pin(Cursor::new(new_bytes)), + }], + &expected, + ) + .await; + } + + // original: [============= 256 KB =============] + // edit: [============= 80 KB stale =============] + // ^ replaced with 4 K (resize -76 K) + // result: [== prefix ==][4 K new][== suffix ==] + // + // Big in-place replace where new_length << original_range.len(); shrink case. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_large_replace_shrinks_file() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(102, 256 * 1024); + let drop_start = 80_000usize; + let drop_end = 160_000usize; + let new_bytes: Vec = (0..4 * 1024).map(|i| (0xCC ^ i) as u8).collect(); + let mut expected = Vec::with_capacity(original.len() - (drop_end - drop_start) + new_bytes.len()); + expected.extend_from_slice(&original[..drop_start]); + expected.extend_from_slice(&new_bytes); + expected.extend_from_slice(&original[drop_end..]); + + assert_edits( + &config, + &cas_client, + &original, + vec![DirtyInput { + original_range: drop_start as u64..drop_end as u64, + new_length: new_bytes.len() as u64, + reader: Box::pin(Cursor::new(new_bytes)), + }], + &expected, + ) + .await; + } + + // original: [============= 100 KB =============] + // edit: [insert 8 KB here] + // result: [== prefix ==][== 8 KB new ==][======= suffix =======] + // + // Pure mid-file insert (range start == end), large payload that forces re-chunking + // around the insertion point. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_mid_file_insert() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(103, 100 * 1024); + let at = 40_000usize; + let new_bytes: Vec = (0..8u32 * 1024).map(|i| (i.wrapping_mul(13) % 251) as u8).collect(); + let mut expected = Vec::with_capacity(original.len() + new_bytes.len()); + expected.extend_from_slice(&original[..at]); + expected.extend_from_slice(&new_bytes); + expected.extend_from_slice(&original[at..]); + + assert_edits( + &config, + &cas_client, + &original, + vec![DirtyInput { + original_range: at as u64..at as u64, + new_length: new_bytes.len() as u64, + reader: Box::pin(Cursor::new(new_bytes)), + }], + &expected, + ) + .await; + } + + // original: [============= 256 KB =============] + // edit: [== 64 KB hole ==] + // ^ delete this slice (no replacement) + // result: [== prefix ==][== suffix ==] + // + // Pure mid-file delete: shrinks the file without touching the tail boundary. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_mid_file_delete() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(104, 256 * 1024); + let drop_start = 80_000usize; + let drop_end = 144_000usize; + let mut expected = Vec::with_capacity(original.len() - (drop_end - drop_start)); + expected.extend_from_slice(&original[..drop_start]); + expected.extend_from_slice(&original[drop_end..]); + + assert_edits( + &config, + &cas_client, + &original, + vec![DirtyInput { + original_range: drop_start as u64..drop_end as u64, + new_length: 0, + reader: empty_reader(), + }], + &expected, + ) + .await; + } + + // original: [== seg0 ==][== seg1 ==][== seg2 ==] + // edits: [shrink][grow][ delete ] + // + // Three independent edits in one call: an in-place shrink, a pure insert in seg1, and + // a pure delete in seg2. Mix of resize directions, far enough apart that they don't + // coalesce into one window. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_multi_edit_mix() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(105, 384 * 1024); + let (a_start, a_end) = (10 * 1024usize, 20 * 1024usize); + let a_new: Vec = vec![0xAA; 2 * 1024]; + let b_at = 150 * 1024usize; + let b_new: Vec = vec![0xBB; 4 * 1024]; + let (c_start, c_end) = (300 * 1024usize, 320 * 1024usize); + + let mut expected = Vec::with_capacity(original.len() + b_new.len()); + expected.extend_from_slice(&original[..a_start]); + expected.extend_from_slice(&a_new); + expected.extend_from_slice(&original[a_end..b_at]); + expected.extend_from_slice(&b_new); + expected.extend_from_slice(&original[b_at..c_start]); + expected.extend_from_slice(&original[c_end..]); + + assert_edits( + &config, + &cas_client, + &original, + vec![ + DirtyInput { + original_range: a_start as u64..a_end as u64, + new_length: a_new.len() as u64, + reader: Box::pin(Cursor::new(a_new)), + }, + DirtyInput { + original_range: b_at as u64..b_at as u64, + new_length: b_new.len() as u64, + reader: Box::pin(Cursor::new(b_new)), + }, + DirtyInput { + original_range: c_start as u64..c_end as u64, + new_length: 0, + reader: empty_reader(), + }, + ], + &expected, + ) + .await; + } + + // original: [== seg0 ==][== seg1 ==] + // ^ insert here, exactly on segment boundary + // result: [== seg0 ==][== inserted ==][== seg1 ==] + // + // Pure insert on a segment boundary mid-file. Snap picks the segment starting at the + // boundary; insert lands at `w_start` of that window. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resize_insert_at_segment_boundary() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(106, 200 * 1024); + let original_hash = upload_file(&config, &original).await; + let original_size = original.len() as u64; + + // Pick the first interior segment boundary; bail if the file lands in one segment. + let seg_sizes = fetch_segment_sizes(&cas_client, &original_hash).await; + let Some(boundary) = seg_sizes + .iter() + .scan(0u64, |acc, s| { + *acc += s; + Some(*acc) + }) + .find(|&b| b > 0 && b < original_size) + else { + return; + }; + + let new_bytes: Vec = vec![0x42; 4 * 1024]; + let mut expected = Vec::with_capacity(original.len() + new_bytes.len()); + expected.extend_from_slice(&original[..boundary as usize]); + expected.extend_from_slice(&new_bytes); + expected.extend_from_slice(&original[boundary as usize..]); + + assert_edits( + &config, + &cas_client, + &original, + vec![DirtyInput { + original_range: boundary..boundary, + new_length: new_bytes.len() as u64, + reader: Box::pin(Cursor::new(new_bytes)), + }], + &expected, + ) + .await; + } } From d6cb7a9c00a3cdfd65ee116b8e40d749af254636 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 2 May 2026 08:57:07 +0200 Subject: [PATCH 23/38] refactor(file_cleaner): split chunks-returning finish into finish_with_chunks The chunk hash list returned by SingleFileCleaner::finish is only consumed by upload_ranges (to build partial MerkleHashSubtree nodes for newly- uploaded windows). Every other caller threw it away as _chunks / _chunk_hashes. Keep the 3-tuple variant under finish_with_chunks for the one real consumer and let everyone else use the simpler 2-tuple finish. --- xet_data/src/processing/bin/example.rs | 2 +- xet_data/src/processing/data_client.rs | 4 ++-- xet_data/src/processing/file_cleaner.rs | 13 +++++++++++-- xet_data/src/processing/file_download_session.rs | 2 +- xet_data/src/processing/file_upload_session.rs | 8 ++++---- xet_data/src/processing/range_upload.rs | 8 ++++---- xet_data/tests/test_full_file_download.rs | 2 +- xet_data/tests/test_range_downloads.rs | 2 +- xet_data/tests/test_unordered_download.rs | 2 +- xet_pkg/src/xet_session/upload_stream_handle.rs | 2 +- 10 files changed, 27 insertions(+), 18 deletions(-) diff --git a/xet_data/src/processing/bin/example.rs b/xet_data/src/processing/bin/example.rs index d2bbc88f4..b743df303 100644 --- a/xet_data/src/processing/bin/example.rs +++ b/xet_data/src/processing/bin/example.rs @@ -103,7 +103,7 @@ async fn clean(mut reader: impl Read, mut writer: impl Write, size: u64) -> Resu debug_assert_eq!(size_read, size); - let (file_info, _chunk_hashes, _metrics) = handle.finish().await?; + let (file_info, _metrics) = handle.finish().await?; translator.finalize().await?; diff --git a/xet_data/src/processing/data_client.rs b/xet_data/src/processing/data_client.rs index 81aaab79d..34813fa12 100644 --- a/xet_data/src/processing/data_client.rs +++ b/xet_data/src/processing/data_client.rs @@ -47,7 +47,7 @@ pub async fn clean_bytes( ) -> Result<(XetFileInfo, DeduplicationMetrics)> { let (_id, mut handle) = processor.start_clean(None, Some(bytes.len() as u64), sha256_policy)?; handle.add_data(&bytes).await?; - let (info, _chunk_hashes, metrics) = handle.finish().await?; + let (info, metrics) = handle.finish().await?; Ok((info, metrics)) } @@ -77,7 +77,7 @@ pub async fn clean_file( handle.add_data(&buffer[0..bytes]).await?; } - let (info, _chunk_hashes, metrics) = handle.finish().await?; + let (info, metrics) = handle.finish().await?; Ok((info, metrics)) } diff --git a/xet_data/src/processing/file_cleaner.rs b/xet_data/src/processing/file_cleaner.rs index 370b1b557..d3c4c60b7 100644 --- a/xet_data/src/processing/file_cleaner.rs +++ b/xet_data/src/processing/file_cleaner.rs @@ -202,8 +202,17 @@ impl SingleFileCleaner { } /// Return the representation of the file after clean as a pointer file instance. - #[instrument(skip_all, name = "FileCleaner::finish", fields(file_name=self.file_name.as_ref().map(|s|s.to_string())))] - pub async fn finish(mut self) -> Result<(XetFileInfo, ChunkHashList, DeduplicationMetrics)> { + pub async fn finish(self) -> Result<(XetFileInfo, DeduplicationMetrics)> { + let (info, _chunks, metrics) = self.finish_with_chunks().await?; + Ok((info, metrics)) + } + + /// Same as [`finish`], but also returns the per-chunk hash list produced during CDC. + /// Only needed by composition flows (e.g. `upload_ranges`) that build partial + /// `MerkleHashSubtree` nodes for newly-uploaded windows; regular uploads should call + /// [`finish`] instead. + #[instrument(skip_all, name = "FileCleaner::finish_with_chunks", fields(file_name=self.file_name.as_ref().map(|s|s.to_string())))] + pub async fn finish_with_chunks(mut self) -> Result<(XetFileInfo, ChunkHashList, DeduplicationMetrics)> { // Chunk the rest of the data. if let Some(chunk) = self.chunker.finish() { let data = Arc::new([chunk]); diff --git a/xet_data/src/processing/file_download_session.rs b/xet_data/src/processing/file_download_session.rs index 2f7572f3c..86f0a0aa5 100644 --- a/xet_data/src/processing/file_download_session.rs +++ b/xet_data/src/processing/file_download_session.rs @@ -410,7 +410,7 @@ mod tests { .start_clean(Some("test".into()), Some(data.len() as u64), Sha256Policy::Compute) .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _metrics) = cleaner.finish().await.unwrap(); upload_session.finalize().await.unwrap(); xfi } diff --git a/xet_data/src/processing/file_upload_session.rs b/xet_data/src/processing/file_upload_session.rs index 177f068fa..7605ba942 100644 --- a/xet_data/src/processing/file_upload_session.rs +++ b/xet_data/src/processing/file_upload_session.rs @@ -172,7 +172,7 @@ impl FileUploadSession { } // Finish and return the result. - let (xfi, _chunk_hashes, metrics) = cleaner.finish().await?; + let (xfi, metrics) = cleaner.finish().await?; // Record dedup information. let span = Span::current(); @@ -275,7 +275,7 @@ impl FileUploadSession { let handle = runtime.spawn(async move { let _permit = semaphore.acquire().await?; cleaner.add_data(&bytes).await?; - let (file_info, _chunks, metrics) = cleaner.finish().await?; + let (file_info, metrics) = cleaner.finish().await?; Ok((file_info, metrics)) }); @@ -298,7 +298,7 @@ impl FileUploadSession { } cleaner.add_data(&buffer[..n]).await?; } - let (file_info, _chunks, metrics) = cleaner.finish().await?; + let (file_info, metrics) = cleaner.finish().await?; Ok((file_info, metrics)) } @@ -661,7 +661,7 @@ mod tests { // Read blocks from the source file and hand them to the cleaning handle cleaner.add_data(&read_data[..]).await.unwrap(); - let (xet_file_info, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); + let (xet_file_info, _metrics) = cleaner.finish().await.unwrap(); upload_session.finalize().await.unwrap(); pf_out diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 95c673f29..bbd564b90 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -235,7 +235,7 @@ pub async fn upload_ranges( stream_cas_range(&ctx, &cas_client, original_hash, cursor, w_end, &mut cleaner).await?; } - let (info, chunks, _metrics) = cleaner.finish().await?; + let (info, chunks, _metrics) = cleaner.finish_with_chunks().await?; uploaded.push(UploadedWindow { start: w_start, end: w_end, @@ -423,7 +423,7 @@ async fn upload_fresh_file( remaining -= to_read; } } - let (info, _chunks, _metrics) = cleaner.finish().await?; + let (info, _metrics) = cleaner.finish().await?; session.finalize().await?; Ok(info) } @@ -583,7 +583,7 @@ mod tests { .start_clean(Some("original".into()), Some(original_data.len() as u64), Sha256Policy::Skip) .unwrap(); cleaner.add_data(&original_data).await.unwrap(); - let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _metrics) = cleaner.finish().await.unwrap(); upload_session.finalize().await.unwrap(); MerkleHash::from_hex(xfi.hash()).unwrap() }; @@ -1246,7 +1246,7 @@ mod tests { .start_clean(Some("test".into()), Some(data.len() as u64), Sha256Policy::Skip) .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _metrics) = cleaner.finish().await.unwrap(); session.finalize().await.unwrap(); MerkleHash::from_hex(xfi.hash()).unwrap() } diff --git a/xet_data/tests/test_full_file_download.rs b/xet_data/tests/test_full_file_download.rs index 1e4457cac..2194322d7 100644 --- a/xet_data/tests/test_full_file_download.rs +++ b/xet_data/tests/test_full_file_download.rs @@ -18,7 +18,7 @@ mod tests { .start_clean(Some(name.into()), Some(data.len() as u64), Sha256Policy::Compute) .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _chunk_hashes, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _metrics) = cleaner.finish().await.unwrap(); xfi } diff --git a/xet_data/tests/test_range_downloads.rs b/xet_data/tests/test_range_downloads.rs index 76dfb3d62..d3d204210 100644 --- a/xet_data/tests/test_range_downloads.rs +++ b/xet_data/tests/test_range_downloads.rs @@ -16,7 +16,7 @@ mod tests { .start_clean(Some(name.into()), Some(data.len() as u64), Sha256Policy::Compute) .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _metrics) = cleaner.finish().await.unwrap(); xfi } diff --git a/xet_data/tests/test_unordered_download.rs b/xet_data/tests/test_unordered_download.rs index e14dbebbd..74bb0ae69 100644 --- a/xet_data/tests/test_unordered_download.rs +++ b/xet_data/tests/test_unordered_download.rs @@ -12,7 +12,7 @@ mod tests { .start_clean(Some(name.into()), Some(data.len() as u64), Sha256Policy::Compute) .unwrap(); cleaner.add_data(data).await.unwrap(); - let (xfi, _chunks, _metrics) = cleaner.finish().await.unwrap(); + let (xfi, _metrics) = cleaner.finish().await.unwrap(); xfi } diff --git a/xet_pkg/src/xet_session/upload_stream_handle.rs b/xet_pkg/src/xet_session/upload_stream_handle.rs index 6c038a644..28e07f444 100644 --- a/xet_pkg/src/xet_session/upload_stream_handle.rs +++ b/xet_pkg/src/xet_session/upload_stream_handle.rs @@ -44,7 +44,7 @@ impl XetStreamUploadInner { drop(guard); match cleaner.finish().await { - Ok((xet_info, _chunks, dedup_metrics)) => Ok(XetFileMetadata { + Ok((xet_info, dedup_metrics)) => Ok(XetFileMetadata { task_id: self.task_id, xet_info, dedup_metrics, From 87b90611ae4d9b1e7b98c520dc6211c2f93db1e9 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 2 May 2026 11:32:49 +0200 Subject: [PATCH 24/38] fix(range_upload): promote response-shape invariants to runtime errors debug_assert_eq! is compiled out in release builds, so a malformed get_file_chunk_hashes response (wrong number of windows or hash_ranges) would silently truncate the merge sequence via .pop()/.zip() and produce an incorrect composed file hash with no error to the caller. Replace the three correctness-critical checks (server window count, server hash_ranges count, segment-aligned windows, plus the file_size sanity check on reconstruction info) with real Err returns so a misbehaving server is detected in release rather than silently corrupting the composed reconstruction. --- xet_data/src/processing/range_upload.rs | 39 ++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index bbd564b90..48899f65d 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -110,7 +110,12 @@ pub async fn upload_ranges( let original_mdb = recon_result .map(|(mdb, _)| mdb) .ok_or_else(|| DataError::ParameterError("file not found".into()))?; - debug_assert_eq!(original_mdb.file_size(), original_size, "reconstruction info disagrees with original_size"); + if original_mdb.file_size() != original_size { + return Err(DataError::ParameterError(format!( + "caller said original_size={original_size} but reconstruction info reports {}", + original_mdb.file_size() + ))); + } // `seg_byte_starts[i]` is the first byte of segment `i`; the trailing entry equals `original_size`. let mut seg_byte_starts: Vec = Vec::with_capacity(original_mdb.segments.len() + 1); @@ -166,8 +171,22 @@ pub async fn upload_ranges( let n_windows = server_query.len(); let response: FileChunkHashesResponse = cas_client.get_file_chunk_hashes(&original_hash, server_query).await?; - debug_assert_eq!(response.windows.len(), n_windows, "server windows must match segment-aligned query"); - debug_assert_eq!(response.hash_ranges.len(), n_windows + 1, "expected N+1 hash ranges for N windows"); + // These invariants are part of the server contract; violating them silently truncates + // the merge sequence (`zip` would drop windows) and produces a wrong file hash, so we + // bail loudly instead of trusting the response. + if response.windows.len() != n_windows { + return Err(DataError::InternalError(format!( + "server returned {} windows, expected {n_windows} (one per dirty range)", + response.windows.len() + ))); + } + if response.hash_ranges.len() != n_windows + 1 { + return Err(DataError::InternalError(format!( + "server returned {} hash_ranges, expected {} (n_windows + 1)", + response.hash_ranges.len(), + n_windows + 1 + ))); + } let ctx = config.ctx.clone(); let session = FileUploadSession::new(config.clone()).await?; @@ -303,10 +322,16 @@ pub async fn upload_ranges( }; for w in &uploaded { while seg_idx < n_segs && seg_byte_starts[seg_idx] < w.start { - debug_assert!( - seg_byte_starts[seg_idx + 1] <= w.start, - "segment straddles window start (not segment-aligned)" - ); + if seg_byte_starts[seg_idx + 1] > w.start { + return Err(DataError::InternalError(format!( + "server returned a window starting at {} that straddles segment {} \ + ({}..{}); composition requires segment-aligned windows", + w.start, + seg_idx, + seg_byte_starts[seg_idx], + seg_byte_starts[seg_idx + 1] + ))); + } emit_seg(seg_idx, &mut all_segments, &mut all_verification); seg_idx += 1; } From a8c8d01d97f686809cf245120a6225e397c57485 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 2 May 2026 16:23:17 +0200 Subject: [PATCH 25/38] fix(range_upload): error if edits not all assigned to a window Every dirty edit must land in exactly one returned window. If the server ever returns narrower dirty_byte_ranges than requested, leftover edits would silently drop and the composed file would be corrupt with no error. Promote that invariant to a runtime error, matching the existing response-shape checks above. --- xet_data/src/processing/range_upload.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 48899f65d..5aa0a71d2 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -263,6 +263,17 @@ pub async fn upload_ranges( }); } + // Every edit must have been assigned to exactly one window. If the server + // returned narrower `dirty_byte_range`s than requested, leftover edits would + // silently drop and produce a corrupt file with no error. + if input_idx != dirty_inputs.len() { + return Err(DataError::InternalError(format!( + "{} dirty edits not assigned to any window (input_idx={input_idx}, total={})", + dirty_inputs.len() - input_idx, + dirty_inputs.len() + ))); + } + session.checkpoint().await?; let mdb_list = session.file_info_list().await?; let mdb_by_hash: HashMap = From 45f5937321b9d00d965164131e4a62b9c8d6e0e5 Mon Sep 17 00:00:00 2001 From: Adrien Date: Wed, 6 May 2026 10:17:14 +0200 Subject: [PATCH 26/38] fix(range_upload): consume gap_verification from FileChunkHashesResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upload_ranges was emitting composed shards without a verification section, which cas-server now rejects with `MDBShard("Shard verification failure, missing verification section")`. Root cause: the May 1 MerkleHashSubtree refactor (a9d9cfe6) dropped client-side computation of FileVerificationEntry for stable original segments, expecting `original_mdb.verification` to be populated — but GET /v1/reconstructions has always returned an empty verification list, so the composed shard always went out without verification. Fix: extend GET /v2/file-chunk-hashes/{file_id} to return one FileVerificationEntry per stable original segment (segments wholly outside the dirty windows), in segment order. range_upload pops these in lockstep with the segment walk to populate the composed shard's verification section. This is the minimum data needed: window segments are re-uploaded fresh so their verification comes from the per-window MDB; only stable segments need the server's help. ~32 bytes per stable segment, much cheaper than the legacy "return the whole chunk hash list" endpoint that was replaced by the multi-range API in 78de6886. Backwards-compat: when the original file has no verification entries (legacy / test files registered before verification was introduced), the server returns an empty `gap_verification` and the composed shard is emitted with `with_verification=false` as before. Pairs with a xetcas-side change to populate `gap_verification` in the response. Sim clients (local_client, memory_client) populate it locally via build_file_chunk_hashes_response so the existing 27 range_upload tests pass unchanged. --- .../src/cas_client/chunk_window_builder.rs | 35 +++++++++++- .../src/cas_client/simulation/local_client.rs | 2 +- .../cas_client/simulation/memory_client.rs | 2 +- xet_client/src/cas_types/mod.rs | 6 ++ xet_data/src/processing/range_upload.rs | 57 ++++++++++++++----- 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/xet_client/src/cas_client/chunk_window_builder.rs b/xet_client/src/cas_client/chunk_window_builder.rs index 6f68cd1e4..5c3655f80 100644 --- a/xet_client/src/cas_client/chunk_window_builder.rs +++ b/xet_client/src/cas_client/chunk_window_builder.rs @@ -3,6 +3,7 @@ //! [`FileChunkHashesResponse`] without routing through HTTP. use xet_core_structures::merklehash::{MerkleHash, MerkleHashSubtree}; +use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; use crate::cas_types::{ChunkWindow, FileChunkHashesResponse, FileRange}; use crate::error::{ClientError, Result}; @@ -130,11 +131,19 @@ impl<'a> ChunkWindowBuilder<'a> { /// assemble the [`FileChunkHashesResponse`]. Simulation clients pre-collect the chunks for /// the file (typically by walking segments + xorb metadata) and call this to answer /// `Client::get_file_chunk_hashes` locally. +/// +/// Also emits `gap_verification`: for each **stable** original segment (one that lies +/// entirely outside the dirty windows), the corresponding `FileVerificationEntry` from +/// `file_info.verification` is copied into `gap_verification` in segment order. The +/// composed shard built by `upload_ranges` uses these to reconstruct its own verification +/// section without needing per-chunk hashes for the stable segments. When `file_info` +/// has no verification entries (legacy / test files), `gap_verification` is empty. pub fn build_file_chunk_hashes_response( - file_size: u64, + file_info: &MDBFileInfo, dirty_ranges: Vec, chunks: impl IntoIterator, ) -> Result { + let file_size = file_info.file_size(); let dirty_ranges: Vec = dirty_ranges .into_iter() .map(|r| FileRange::new(r.start, r.end.min(file_size))) @@ -158,6 +167,29 @@ pub fn build_file_chunk_hashes_response( return Err(ClientError::Other("dirty ranges do not overlap any chunks".into())); } + // Emit one range hash per stable segment (= no overlap with any window). + // Segments and windows are both monotonic, so a two-pointer walk is O(S+W). + let gap_verification = if file_info.verification.len() == file_info.segments.len() { + let mut gv = Vec::new(); + let mut acc = 0u64; + let mut wi = 0usize; + for (idx, seg) in file_info.segments.iter().enumerate() { + let seg_start = acc; + let seg_end = acc + seg.unpacked_segment_bytes as u64; + acc = seg_end; + while wi < windows.len() && windows[wi].end <= seg_start { + wi += 1; + } + let overlaps = wi < windows.len() && windows[wi].start < seg_end; + if !overlaps { + gv.push(crate::cas_types::HexMerkleHash::from(file_info.verification[idx].range_hash)); + } + } + gv + } else { + Vec::new() + }; + Ok(FileChunkHashesResponse { total_chunks, file_size, @@ -168,5 +200,6 @@ pub fn build_file_chunk_hashes_response( }) .collect(), hash_ranges, + gap_verification, }) } diff --git a/xet_client/src/cas_client/simulation/local_client.rs b/xet_client/src/cas_client/simulation/local_client.rs index c3fe4f5d1..cf4dd0725 100644 --- a/xet_client/src/cas_client/simulation/local_client.rs +++ b/xet_client/src/cas_client/simulation/local_client.rs @@ -1717,7 +1717,7 @@ impl Client for LocalClient { ); } - build_file_chunk_hashes_response(file_info.file_size(), dirty_ranges, chunks) + build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks) } } diff --git a/xet_client/src/cas_client/simulation/memory_client.rs b/xet_client/src/cas_client/simulation/memory_client.rs index 83ecc9194..4b1237b07 100644 --- a/xet_client/src/cas_client/simulation/memory_client.rs +++ b/xet_client/src/cas_client/simulation/memory_client.rs @@ -984,7 +984,7 @@ impl Client for MemoryClient { ); } - build_file_chunk_hashes_response(file_info.file_size(), dirty_ranges, chunks) + build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks) } } diff --git a/xet_client/src/cas_types/mod.rs b/xet_client/src/cas_types/mod.rs index ce4837387..446322674 100644 --- a/xet_client/src/cas_types/mod.rs +++ b/xet_client/src/cas_types/mod.rs @@ -341,6 +341,12 @@ pub struct FileChunkHashesResponse { pub file_size: u64, pub windows: Vec, pub hash_ranges: Vec>, + /// One range hash per **stable original segment** (= a segment that lies in a gap + /// between dirty windows or before/after them, in segment order). Wraps each into a + /// `FileVerificationEntry` to populate the composed shard's verification section. + /// Empty when the original file has no verification section (legacy files). + #[serde(default)] + pub gap_verification: Vec, } #[cfg(test)] diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 5aa0a71d2..409567811 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -187,6 +187,7 @@ pub async fn upload_ranges( n_windows + 1 ))); } + let gap_verification = response.gap_verification; let ctx = config.ctx.clone(); let session = FileUploadSession::new(config.clone()).await?; @@ -315,21 +316,34 @@ pub async fn upload_ranges( aggregated_hash.hmac(MerkleHash::default()) }; - // Walk original segments; replace those a window covers with the window's segments. - // Segment-aligned windows guarantee every original segment is wholly inside or outside a - // window. Verification entries are 1:1 with segments when the original file had them on; - // legacy / test files registered without verification yield an empty vec, in which case - // we emit a verification-less composed MDB. - let with_verification = original_mdb.verification.len() == original_mdb.segments.len(); + // The composed shard must carry a verification section if either side has one: the + // server populates `gap_verification` whenever the original file's segments had verif + // entries, and the locally-fetched `original_mdb` (sim path) populates + // `verification` directly. Real prod always takes the first half; sim tests the second. + let original_has_verification = + !gap_verification.is_empty() || original_mdb.verification.len() == original_mdb.segments.len(); let mut all_segments: Vec = Vec::new(); let mut all_verification: Vec = Vec::new(); let mut seg_idx = 0usize; let n_segs = original_mdb.segments.len(); - let emit_seg = |idx: usize, segs: &mut Vec, vers: &mut Vec| { + let mut gap_idx = 0usize; + let push_stable = |idx: usize, + gap_idx: &mut usize, + segs: &mut Vec, + vers: &mut Vec| + -> Result<()> { segs.push(original_mdb.segments[idx].clone()); - if with_verification { - vers.push(original_mdb.verification[idx].clone()); + if original_has_verification { + let entry = gap_verification.get(*gap_idx).ok_or_else(|| { + DataError::InternalError(format!( + "ran out of gap_verification entries at stable segment {idx}; \ + server response is inconsistent with the segment layout" + )) + })?; + vers.push(FileVerificationEntry::new(entry.into())); + *gap_idx += 1; } + Ok(()) }; for w in &uploaded { while seg_idx < n_segs && seg_byte_starts[seg_idx] < w.start { @@ -343,7 +357,7 @@ pub async fn upload_ranges( seg_byte_starts[seg_idx + 1] ))); } - emit_seg(seg_idx, &mut all_segments, &mut all_verification); + push_stable(seg_idx, &mut gap_idx, &mut all_segments, &mut all_verification)?; seg_idx += 1; } let original_window_end = w.end.min(original_size); @@ -355,14 +369,29 @@ pub async fn upload_ranges( .get(&middle_hash) .ok_or_else(|| DataError::InternalError(format!("no MDBFileInfo for window hash {}", middle_hash.hex())))?; all_segments.extend_from_slice(&middle_mdb.segments); - if with_verification { + if original_has_verification { + if middle_mdb.verification.len() != middle_mdb.segments.len() { + return Err(DataError::InternalError(format!( + "window MDB for {} has {} segments but {} verification entries", + middle_hash.hex(), + middle_mdb.segments.len(), + middle_mdb.verification.len() + ))); + } all_verification.extend_from_slice(&middle_mdb.verification); } } while seg_idx < n_segs { - emit_seg(seg_idx, &mut all_segments, &mut all_verification); + push_stable(seg_idx, &mut gap_idx, &mut all_segments, &mut all_verification)?; seg_idx += 1; } + if gap_idx < gap_verification.len() { + return Err(DataError::InternalError(format!( + "server returned {} gap_verification entries but only {} stable segments were emitted", + gap_verification.len(), + gap_idx + ))); + } debug!( "upload_ranges: composed hash={}, {} segments, {} windows", @@ -371,8 +400,10 @@ pub async fn upload_ranges( uploaded.len() ); + debug_assert!(!original_has_verification || all_segments.len() == all_verification.len()); + let composed_mdb = MDBFileInfo { - metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), with_verification, false), + metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), original_has_verification, false), segments: all_segments, verification: all_verification, // SHA-256 is intentionally omitted: the file content changed, and recomputing it From adb25b1428566bf00aa29a3584e42e09e9c409b5 Mon Sep 17 00:00:00 2001 From: Adrien Date: Wed, 6 May 2026 18:17:31 +0200 Subject: [PATCH 27/38] fix(range_upload): always emit verification section in composed shard The CAS server rejects any shard without a verification section. When the entire file falls within dirty windows (no stable segments), gap_verification is empty and original_mdb.verification is stripped, causing original_has_verification to be false and the composed shard to omit verification. Since the cleaner always produces verification entries for its segments, unconditionally set has_verification=true. --- xet_data/src/processing/range_upload.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 409567811..36ec331a0 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -316,12 +316,10 @@ pub async fn upload_ranges( aggregated_hash.hmac(MerkleHash::default()) }; - // The composed shard must carry a verification section if either side has one: the - // server populates `gap_verification` whenever the original file's segments had verif - // entries, and the locally-fetched `original_mdb` (sim path) populates - // `verification` directly. Real prod always takes the first half; sim tests the second. - let original_has_verification = - !gap_verification.is_empty() || original_mdb.verification.len() == original_mdb.segments.len(); + // The composed shard MUST carry a verification section: the CAS server rejects any + // shard without one. The cleaner always produces verification entries for window + // segments, and the server provides `gap_verification` for stable segments. + let original_has_verification = true; let mut all_segments: Vec = Vec::new(); let mut all_verification: Vec = Vec::new(); let mut seg_idx = 0usize; From 46e1be3ff0f4a91836505d1a7a26aed6c25d04b1 Mon Sep 17 00:00:00 2001 From: Adrien Date: Wed, 6 May 2026 18:48:22 +0200 Subject: [PATCH 28/38] fix(range_upload): address PR review comments - Use checked arithmetic in compute_total_size to prevent overflow - Add clarifying comments on hash_ranges double-Option intent - Add comment noting mdb_by_hash dedup-by-content is intentional --- xet_data/src/processing/range_upload.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 36ec331a0..1ae65df25 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -93,7 +93,7 @@ pub async fn upload_ranges( mut dirty_inputs: Vec, ) -> Result { validate_dirty_ranges(&dirty_inputs, original_size)?; - let total_size = compute_total_size(original_size, &dirty_inputs); + let total_size = compute_total_size(original_size, &dirty_inputs)?; if dirty_inputs.is_empty() { debug_assert_eq!(total_size, original_size); @@ -277,12 +277,14 @@ pub async fn upload_ranges( session.checkpoint().await?; let mdb_list = session.file_info_list().await?; + // Hash collision between two windows implies identical content, so dedup is correct. let mdb_by_hash: HashMap = mdb_list.into_iter().map(|m| (m.metadata.file_hash, m)).collect(); // Merge sequence: [gap0, w0, gap1, w1, ..., gapN]. Empty gaps (`None`) are skipped. let mut hash_ranges = response.hash_ranges; let trailing_gap = hash_ranges.pop().flatten(); + // Leading gap is empty (None) => first window starts at byte 0. let first_window_at_start = hash_ranges.first().is_some_and(Option::is_none); let last_window_at_end = trailing_gap.is_none(); let last_idx = uploaded.len() - 1; @@ -459,11 +461,18 @@ fn validate_dirty_ranges(dirty_inputs: &[DirtyInput], original_size: u64) -> Res } /// Compute the resulting file size: `original_size` minus bytes removed plus bytes added. -fn compute_total_size(original_size: u64, dirty_inputs: &[DirtyInput]) -> u64 { +fn compute_total_size(original_size: u64, dirty_inputs: &[DirtyInput]) -> Result { let (removed, added) = dirty_inputs .iter() .fold((0u64, 0u64), |(r, a), d| (r + (d.original_range.end - d.original_range.start), a + d.new_length)); - original_size + added - removed + original_size + .checked_add(added) + .and_then(|s| s.checked_sub(removed)) + .ok_or_else(|| { + DataError::ParameterError(format!( + "total size overflows: original_size={original_size}, added={added}, removed={removed}" + )) + }) } /// Upload a brand-new file from `dirty_inputs` (no original to compose against). From c41c195cc8a28017ef0993c6571ad7d904050daa Mon Sep 17 00:00:00 2001 From: Adrien Date: Wed, 6 May 2026 19:44:08 +0200 Subject: [PATCH 29/38] fix(range_upload): avoid orphan window MDB entries in session shard Add finish_with_chunks_detached() to SingleFileCleaner that uploads xorb data but returns the MDBFileInfo directly instead of registering it in the session shard. upload_ranges now uses the MDBFileInfo from each window directly for composition, so only the final composed file gets registered. This eliminates N unreferenced per-window entries that previously polluted the shard. --- xet_data/src/processing/file_cleaner.rs | 60 ++++++++++------ .../src/processing/file_upload_session.rs | 70 ++++++++++++++----- xet_data/src/processing/range_upload.rs | 30 +++----- 3 files changed, 97 insertions(+), 63 deletions(-) diff --git a/xet_data/src/processing/file_cleaner.rs b/xet_data/src/processing/file_cleaner.rs index d3c4c60b7..df6923f35 100644 --- a/xet_data/src/processing/file_cleaner.rs +++ b/xet_data/src/processing/file_cleaner.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use tracing::{Instrument, debug_span, info, instrument}; use xet_core_structures::merklehash::ChunkHashList; use xet_core_structures::metadata_shard::Sha256; -use xet_core_structures::metadata_shard::file_structs::FileMetadataExt; +use xet_core_structures::metadata_shard::file_structs::{FileMetadataExt, MDBFileInfo}; use xet_runtime::core::XetContext; use super::XetFileInfo; @@ -212,23 +212,38 @@ impl SingleFileCleaner { /// `MerkleHashSubtree` nodes for newly-uploaded windows; regular uploads should call /// [`finish`] instead. #[instrument(skip_all, name = "FileCleaner::finish_with_chunks", fields(file_name=self.file_name.as_ref().map(|s|s.to_string())))] - pub async fn finish_with_chunks(mut self) -> Result<(XetFileInfo, ChunkHashList, DeduplicationMetrics)> { - // Chunk the rest of the data. - if let Some(chunk) = self.chunker.finish() { + pub async fn finish_with_chunks(self) -> Result<(XetFileInfo, ChunkHashList, DeduplicationMetrics)> { + let (file_info, chunk_hashes, _, deduplication_metrics) = Self::finish_inner(self, true).await?; + Ok((file_info, chunk_hashes, deduplication_metrics)) + } + + /// Like `finish_with_chunks`, but does NOT register the file's MDBFileInfo in the + /// session shard. Returns the MDBFileInfo directly so the caller can compose it into a + /// larger file without creating orphan shard entries. + pub async fn finish_with_chunks_detached( + self, + ) -> Result<(XetFileInfo, ChunkHashList, MDBFileInfo, DeduplicationMetrics)> { + Self::finish_inner(self, false).await + } + + async fn finish_inner( + mut this: Self, + register: bool, + ) -> Result<(XetFileInfo, ChunkHashList, MDBFileInfo, DeduplicationMetrics)> { + if let Some(chunk) = this.chunker.finish() { let data = Arc::new([chunk]); - self.deduper_process_chunks(data).await?; + this.deduper_process_chunks(data).await?; } - // Resolve the SHA-256: computed, provided, or skipped. - let sha256 = if let Some(generator) = self.sha_generator.take() { + let sha256 = if let Some(generator) = this.sha_generator.take() { Some(generator.finalize().await?) } else { - self.provided_sha256 + this.provided_sha256 }; let metadata_ext = sha256.map(FileMetadataExt::new); let (file_hash, chunk_hashes, remaining_file_data, deduplication_metrics) = - self.dedup_manager_fut.await?.finalize(metadata_ext); + this.dedup_manager_fut.await?.finalize(metadata_ext); let file_info = XetFileInfo { hash: file_hash.hex(), @@ -236,34 +251,33 @@ impl SingleFileCleaner { sha256: sha256.map(|s| s.hex()), }; - // Let's check some things that should be invariants #[cfg(debug_assertions)] { - // There should be exactly one file referenced in the remaining file data. debug_assert_eq!(remaining_file_data.pending_file_info.len(), 1); - - // The size should be total bytes debug_assert_eq!(remaining_file_data.pending_file_info[0].0.file_size(), deduplication_metrics.total_bytes) } - // Now, return all this information to the - self.session - .register_single_file_clean_completion(remaining_file_data, &deduplication_metrics) - .await?; - - // NB: xorb upload is happening in the background, this number is optimistic since it does - // not count transfer time of the uploaded xorbs, which is why `end_processing_ts` + let mdb_file_info = if register { + this.session + .register_single_file_clean_completion(remaining_file_data, &deduplication_metrics) + .await?; + MDBFileInfo::default() + } else { + this.session + .register_single_file_clean_completion_detached(remaining_file_data, &deduplication_metrics) + .await? + }; info!( target: "client_telemetry", action = "clean", - file_name = self.file_name.unwrap_or_default().to_string(), + file_name = this.file_name.as_deref().unwrap_or_default().to_string(), file_size_count = deduplication_metrics.total_bytes, new_bytes_count = deduplication_metrics.new_bytes, - start_ts = self.start_time.to_rfc3339(), + start_ts = this.start_time.to_rfc3339(), end_processing_ts = Utc::now().to_rfc3339(), ); - Ok((file_info, chunk_hashes, deduplication_metrics)) + Ok((file_info, chunk_hashes, mdb_file_info, deduplication_metrics)) } } diff --git a/xet_data/src/processing/file_upload_session.rs b/xet_data/src/processing/file_upload_session.rs index 7605ba942..081f8f809 100644 --- a/xet_data/src/processing/file_upload_session.rs +++ b/xet_data/src/processing/file_upload_session.rs @@ -469,36 +469,74 @@ impl FileUploadSession { Ok(()) } + /// Like `register_single_file_clean_completion`, but does NOT register the MDBFileInfo + /// in the session shard. Returns the finalized MDBFileInfo instead. + /// Used by composition flows where only the final composed file should appear in the shard. + pub(crate) async fn register_single_file_clean_completion_detached( + self: &Arc, + file_data: DataAggregator, + dedup_metrics: &DeduplicationMetrics, + ) -> Result { + // Always cut a dedicated xorb for detached files. This avoids mixing with other + // files in current_session_data whose MDBFileInfo must still be registered. + let file_infos = self.process_aggregated_data_as_xorb_detached(file_data).await?; + + self.deduplication_metrics.lock().await.merge_in(dedup_metrics); + + debug_assert_eq!(file_infos.len(), 1); + file_infos + .into_iter() + .next() + .ok_or_else(|| DataError::InternalError("detached completion produced no file info".into())) + } + /// Process the aggregated data, uploading the data as a xorb and registering the files async fn process_aggregated_data_as_xorb(self: &Arc, data_agg: DataAggregator) -> Result<()> { + self.process_aggregated_data_as_xorb_impl(data_agg, true).await.map(|_| ()) + } + + /// Upload the xorb data but do NOT register file reconstruction info in the shard. + /// Returns the finalized MDBFileInfo for each file in the aggregator. + async fn process_aggregated_data_as_xorb_detached( + self: &Arc, + data_agg: DataAggregator, + ) -> Result> { + self.process_aggregated_data_as_xorb_impl(data_agg, false).await + } + + async fn process_aggregated_data_as_xorb_impl( + self: &Arc, + data_agg: DataAggregator, + register_files: bool, + ) -> Result> { let (xorb, new_files) = data_agg.finalize(); let xorb_hash = xorb.hash(); debug_assert_le!(xorb.num_bytes(), *MAX_XORB_BYTES); debug_assert_le!(xorb.data.len(), *MAX_XORB_CHUNKS); - // Now, we need to scan all the file dependencies for dependencies on this xorb, as - // these would not have been registered yet as we just got the xorb hash. let mut new_dependencies = Vec::with_capacity(new_files.len()); + let mut file_infos = Vec::with_capacity(new_files.len()); - { - for (file_id, fi, bytes_in_xorb) in new_files { - new_dependencies.push(FileXorbDependency { - file_id, - xorb_hash, - n_bytes: bytes_in_xorb, - is_external: false, - }); - - // Record the reconstruction. + for (file_id, fi, bytes_in_xorb) in new_files { + new_dependencies.push(FileXorbDependency { + file_id, + xorb_hash, + n_bytes: bytes_in_xorb, + is_external: false, + }); + + if register_files { self.shard_interface.add_file_reconstruction_info(fi).await?; + } else { + file_infos.push(fi); } } // Register the xorb and start the upload process. self.register_new_xorb(xorb, &new_dependencies).await?; - Ok(()) + Ok(file_infos) } /// Register a xorb dependencies that is given as part of the dedup process. @@ -579,12 +617,6 @@ impl FileUploadSession { self.shard_interface.add_file_reconstruction_info(file_info).await } - /// Returns a list of all file reconstruction infos currently registered in this session. - /// Call after all cleaners have finished and after `checkpoint()` to ensure data is flushed. - pub(crate) async fn file_info_list(self: &Arc) -> Result> { - self.shard_interface.session_file_info_list().await - } - fn check_not_finalized(&self) -> Result<()> { if self.finalized.load(Ordering::Acquire) { return Err(DataError::InvalidOperation("FileUploadSession already finalized".to_string())); diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 1ae65df25..26bd1a0f8 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::ops::Range; use std::pin::Pin; use std::sync::Arc; @@ -46,8 +45,8 @@ const STREAM_BLOCK_SIZE: usize = 4 * 1024 * 1024; // 4 MB struct UploadedWindow { start: u64, end: u64, - info: XetFileInfo, chunks: ChunkHashList, + mdb: MDBFileInfo, } /// Upload an edited version of an existing file, reusing the unchanged regions from the @@ -255,12 +254,12 @@ pub async fn upload_ranges( stream_cas_range(&ctx, &cas_client, original_hash, cursor, w_end, &mut cleaner).await?; } - let (info, chunks, _metrics) = cleaner.finish_with_chunks().await?; + let (_info, chunks, mdb, _metrics) = cleaner.finish_with_chunks_detached().await?; uploaded.push(UploadedWindow { start: w_start, end: w_end, - info, chunks, + mdb, }); } @@ -275,12 +274,6 @@ pub async fn upload_ranges( ))); } - session.checkpoint().await?; - let mdb_list = session.file_info_list().await?; - // Hash collision between two windows implies identical content, so dedup is correct. - let mdb_by_hash: HashMap = - mdb_list.into_iter().map(|m| (m.metadata.file_hash, m)).collect(); - // Merge sequence: [gap0, w0, gap1, w1, ..., gapN]. Empty gaps (`None`) are skipped. let mut hash_ranges = response.hash_ranges; let trailing_gap = hash_ranges.pop().flatten(); @@ -364,21 +357,16 @@ pub async fn upload_ranges( while seg_idx < n_segs && seg_byte_starts[seg_idx] < original_window_end { seg_idx += 1; } - let middle_hash = MerkleHash::from_hex(w.info.hash())?; - let middle_mdb = mdb_by_hash - .get(&middle_hash) - .ok_or_else(|| DataError::InternalError(format!("no MDBFileInfo for window hash {}", middle_hash.hex())))?; - all_segments.extend_from_slice(&middle_mdb.segments); + all_segments.extend_from_slice(&w.mdb.segments); if original_has_verification { - if middle_mdb.verification.len() != middle_mdb.segments.len() { + if w.mdb.verification.len() != w.mdb.segments.len() { return Err(DataError::InternalError(format!( - "window MDB for {} has {} segments but {} verification entries", - middle_hash.hex(), - middle_mdb.segments.len(), - middle_mdb.verification.len() + "window MDB has {} segments but {} verification entries", + w.mdb.segments.len(), + w.mdb.verification.len() ))); } - all_verification.extend_from_slice(&middle_mdb.verification); + all_verification.extend_from_slice(&w.mdb.verification); } } while seg_idx < n_segs { From c180ca660820a7997e178d1420998c5a484c0091 Mon Sep 17 00:00:00 2001 From: Adrien Date: Wed, 6 May 2026 20:15:32 +0200 Subject: [PATCH 30/38] refactor(cas_types): remove unnecessary serde(default) on gap_verification The server always returns this field; no backward compatibility needed. --- xet_client/src/cas_types/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/xet_client/src/cas_types/mod.rs b/xet_client/src/cas_types/mod.rs index 446322674..fd788cebc 100644 --- a/xet_client/src/cas_types/mod.rs +++ b/xet_client/src/cas_types/mod.rs @@ -344,8 +344,6 @@ pub struct FileChunkHashesResponse { /// One range hash per **stable original segment** (= a segment that lies in a gap /// between dirty windows or before/after them, in segment order). Wraps each into a /// `FileVerificationEntry` to populate the composed shard's verification section. - /// Empty when the original file has no verification section (legacy files). - #[serde(default)] pub gap_verification: Vec, } From 678609ac6bdeb7ec9a9c3f287157a5615cc70529 Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 7 May 2026 08:54:19 +0200 Subject: [PATCH 31/38] refactor(range_upload): extract compose_mdb, remove dead conditional, improve error msg - Extract MDB assembly (segment splicing + verification) into compose_mdb() - Remove hardcoded original_has_verification=true and its dead branches - Include file hash in "file not found" error for easier debugging --- xet_data/src/processing/range_upload.rs | 136 +++++++++++++----------- 1 file changed, 71 insertions(+), 65 deletions(-) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 26bd1a0f8..514352f40 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use tokio::io::{AsyncRead, AsyncReadExt}; use tracing::{debug, info}; use xet_client::cas_client::Client; -use xet_client::cas_types::{FileChunkHashesResponse, FileRange}; +use xet_client::cas_types::{FileChunkHashesResponse, FileRange, HexMerkleHash}; use xet_core_structures::merklehash::{ChunkHashList, MerkleHash, MerkleHashSubtree}; use xet_core_structures::metadata_shard::file_structs::{ FileDataSequenceEntry, FileDataSequenceHeader, FileVerificationEntry, MDBFileInfo, @@ -108,7 +108,7 @@ pub async fn upload_ranges( let recon_result = cas_client.get_file_reconstruction_info(&original_hash).await?; let original_mdb = recon_result .map(|(mdb, _)| mdb) - .ok_or_else(|| DataError::ParameterError("file not found".into()))?; + .ok_or_else(|| DataError::ParameterError(format!("file {} not found in CAS", original_hash.hex())))?; if original_mdb.file_size() != original_size { return Err(DataError::ParameterError(format!( "caller said original_size={original_size} but reconstruction info reports {}", @@ -311,34 +311,50 @@ pub async fn upload_ranges( aggregated_hash.hmac(MerkleHash::default()) }; - // The composed shard MUST carry a verification section: the CAS server rejects any - // shard without one. The cleaner always produces verification entries for window - // segments, and the server provides `gap_verification` for stable segments. - let original_has_verification = true; + let composed_mdb = + compose_mdb(&original_mdb, &seg_byte_starts, &uploaded, gap_verification, combined_hash, original_size)?; + + debug!( + "upload_ranges: composed hash={}, {} segments, {} windows", + combined_hash.hex(), + composed_mdb.segments.len(), + uploaded.len() + ); + + session.register_composed_file(composed_mdb).await?; + session.finalize().await?; + + let total_dirty: u64 = dirty_inputs.iter().map(|d| d.new_length).sum(); + info!( + "upload_ranges: hash={} size={} (original={}, {} windows, {} dirty bytes)", + combined_hash.hex(), + total_size, + original_size, + uploaded.len(), + total_dirty + ); + + Ok(XetFileInfo::new(combined_hash.hex(), total_size)) +} + +/// Assemble the composed MDB by splicing uploaded window segments into the original +/// file's segment list, pulling verification entries from the server (for stable gaps) +/// and from the cleaner (for re-uploaded windows). +fn compose_mdb( + original_mdb: &MDBFileInfo, + seg_byte_starts: &[u64], + uploaded: &[UploadedWindow], + gap_verification: Vec, + combined_hash: MerkleHash, + original_size: u64, +) -> Result { let mut all_segments: Vec = Vec::new(); let mut all_verification: Vec = Vec::new(); let mut seg_idx = 0usize; let n_segs = original_mdb.segments.len(); let mut gap_idx = 0usize; - let push_stable = |idx: usize, - gap_idx: &mut usize, - segs: &mut Vec, - vers: &mut Vec| - -> Result<()> { - segs.push(original_mdb.segments[idx].clone()); - if original_has_verification { - let entry = gap_verification.get(*gap_idx).ok_or_else(|| { - DataError::InternalError(format!( - "ran out of gap_verification entries at stable segment {idx}; \ - server response is inconsistent with the segment layout" - )) - })?; - vers.push(FileVerificationEntry::new(entry.into())); - *gap_idx += 1; - } - Ok(()) - }; - for w in &uploaded { + + for w in uploaded { while seg_idx < n_segs && seg_byte_starts[seg_idx] < w.start { if seg_byte_starts[seg_idx + 1] > w.start { return Err(DataError::InternalError(format!( @@ -350,27 +366,41 @@ pub async fn upload_ranges( seg_byte_starts[seg_idx + 1] ))); } - push_stable(seg_idx, &mut gap_idx, &mut all_segments, &mut all_verification)?; + all_segments.push(original_mdb.segments[seg_idx].clone()); + let entry = gap_verification.get(gap_idx).ok_or_else(|| { + DataError::InternalError(format!( + "ran out of gap_verification entries at stable segment {seg_idx}; \ + server response is inconsistent with the segment layout" + )) + })?; + all_verification.push(FileVerificationEntry::new(entry.into())); + gap_idx += 1; seg_idx += 1; } let original_window_end = w.end.min(original_size); while seg_idx < n_segs && seg_byte_starts[seg_idx] < original_window_end { seg_idx += 1; } - all_segments.extend_from_slice(&w.mdb.segments); - if original_has_verification { - if w.mdb.verification.len() != w.mdb.segments.len() { - return Err(DataError::InternalError(format!( - "window MDB has {} segments but {} verification entries", - w.mdb.segments.len(), - w.mdb.verification.len() - ))); - } - all_verification.extend_from_slice(&w.mdb.verification); + if w.mdb.verification.len() != w.mdb.segments.len() { + return Err(DataError::InternalError(format!( + "window MDB has {} segments but {} verification entries", + w.mdb.segments.len(), + w.mdb.verification.len() + ))); } + all_segments.extend_from_slice(&w.mdb.segments); + all_verification.extend_from_slice(&w.mdb.verification); } while seg_idx < n_segs { - push_stable(seg_idx, &mut gap_idx, &mut all_segments, &mut all_verification)?; + all_segments.push(original_mdb.segments[seg_idx].clone()); + let entry = gap_verification.get(gap_idx).ok_or_else(|| { + DataError::InternalError(format!( + "ran out of gap_verification entries at stable segment {seg_idx}; \ + server response is inconsistent with the segment layout" + )) + })?; + all_verification.push(FileVerificationEntry::new(entry.into())); + gap_idx += 1; seg_idx += 1; } if gap_idx < gap_verification.len() { @@ -381,38 +411,14 @@ pub async fn upload_ranges( ))); } - debug!( - "upload_ranges: composed hash={}, {} segments, {} windows", - combined_hash.hex(), - all_segments.len(), - uploaded.len() - ); - - debug_assert!(!original_has_verification || all_segments.len() == all_verification.len()); + debug_assert_eq!(all_segments.len(), all_verification.len()); - let composed_mdb = MDBFileInfo { - metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), original_has_verification, false), + Ok(MDBFileInfo { + metadata: FileDataSequenceHeader::new(combined_hash, all_segments.len(), true, false), segments: all_segments, verification: all_verification, - // SHA-256 is intentionally omitted: the file content changed, and recomputing it - // would require reading the full file. metadata_ext: None, - }; - - session.register_composed_file(composed_mdb).await?; - session.finalize().await?; - - let total_dirty: u64 = dirty_inputs.iter().map(|d| d.new_length).sum(); - info!( - "upload_ranges: hash={} size={} (original={}, {} windows, {} dirty bytes)", - combined_hash.hex(), - total_size, - original_size, - uploaded.len(), - total_dirty - ); - - Ok(XetFileInfo::new(combined_hash.hex(), total_size)) + }) } /// Validate the caller-provided dirty ranges. From 16022bdb0845f53b0564b8a70aaabd931cd402c4 Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 7 May 2026 10:34:51 +0200 Subject: [PATCH 32/38] fix(ci): pin WASM nightly to 2026-05-05 to fix wasm-bindgen __heap_base error nightly-2026-05-06 broke __heap_base export needed by wasm-bindgen for threading support. Pin to the last known-good nightly and remove +nightly from build_wasm.sh so the CI-controlled toolchain is used. --- .github/actions/build-wasm/action.yml | 2 +- wasm/hf_xet_wasm/build_wasm.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-wasm/action.yml b/.github/actions/build-wasm/action.yml index 51b69f48e..a2fd915a2 100644 --- a/.github/actions/build-wasm/action.yml +++ b/.github/actions/build-wasm/action.yml @@ -10,7 +10,7 @@ runs: - name: Install Rust nightly uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: - toolchain: nightly + toolchain: nightly-2026-05-05 targets: wasm32-unknown-unknown components: rust-src - uses: ./.github/actions/cache-rust-build diff --git a/wasm/hf_xet_wasm/build_wasm.sh b/wasm/hf_xet_wasm/build_wasm.sh index 8da9b02a9..4806e8399 100755 --- a/wasm/hf_xet_wasm/build_wasm.sh +++ b/wasm/hf_xet_wasm/build_wasm.sh @@ -34,7 +34,7 @@ TARGET_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals \ -C link-arg=--export=__tls_base \ --cfg getrandom_backend=\"wasm_js\"" \ CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="$TARGET_RUSTFLAGS" \ -cargo +nightly build \ +cargo build \ --example simple \ --target wasm32-unknown-unknown \ --release \ From e3cf4de60a6ced0f425e5c46b307d176e9fb1f29 Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 7 May 2026 10:42:19 +0200 Subject: [PATCH 33/38] Revert "fix(ci): pin WASM nightly to 2026-05-05 to fix wasm-bindgen __heap_base error" This reverts commit 16022bdb0845f53b0564b8a70aaabd931cd402c4. --- .github/actions/build-wasm/action.yml | 2 +- wasm/hf_xet_wasm/build_wasm.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-wasm/action.yml b/.github/actions/build-wasm/action.yml index a2fd915a2..51b69f48e 100644 --- a/.github/actions/build-wasm/action.yml +++ b/.github/actions/build-wasm/action.yml @@ -10,7 +10,7 @@ runs: - name: Install Rust nightly uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: - toolchain: nightly-2026-05-05 + toolchain: nightly targets: wasm32-unknown-unknown components: rust-src - uses: ./.github/actions/cache-rust-build diff --git a/wasm/hf_xet_wasm/build_wasm.sh b/wasm/hf_xet_wasm/build_wasm.sh index 4806e8399..8da9b02a9 100755 --- a/wasm/hf_xet_wasm/build_wasm.sh +++ b/wasm/hf_xet_wasm/build_wasm.sh @@ -34,7 +34,7 @@ TARGET_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals \ -C link-arg=--export=__tls_base \ --cfg getrandom_backend=\"wasm_js\"" \ CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="$TARGET_RUSTFLAGS" \ -cargo build \ +cargo +nightly build \ --example simple \ --target wasm32-unknown-unknown \ --release \ From 954ba6f494d9ed25c0e24ed43f14fedad54747f3 Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 18 May 2026 20:38:55 +0200 Subject: [PATCH 34/38] refactor(range_upload): address review nits from #717 - chunk_window_builder: tighten gap_verification mismatch branch into a loud ClientError instead of silently emitting empty, and document the contract (empty or 1:1 with segments). - range_upload: replace w.end.min(original_size) dead defense with a debug_assert, since the server clamps dirty_byte_range.end to file_size. - range_upload: rewrite is_some_and(Option::is_none) as matches!(_, Some(None)) for readability. - cas_types: capitalize X-Range-Dirty header constant to match the local X-Foo-Bar convention (wire is case-insensitive). --- .../src/cas_client/chunk_window_builder.rs | 16 ++++++++++++++-- xet_client/src/cas_types/mod.rs | 2 +- xet_data/src/processing/range_upload.rs | 8 +++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/xet_client/src/cas_client/chunk_window_builder.rs b/xet_client/src/cas_client/chunk_window_builder.rs index 5c3655f80..a43b88da5 100644 --- a/xet_client/src/cas_client/chunk_window_builder.rs +++ b/xet_client/src/cas_client/chunk_window_builder.rs @@ -169,7 +169,14 @@ pub fn build_file_chunk_hashes_response( // Emit one range hash per stable segment (= no overlap with any window). // Segments and windows are both monotonic, so a two-pointer walk is O(S+W). - let gap_verification = if file_info.verification.len() == file_info.segments.len() { + // + // Contract: `verification` is either empty (legacy / test files without verification + // entries) or 1:1 with `segments`. A partially-populated mismatch is a real bug we + // want loud here, rather than as a confusing "ran out of gap_verification entries" + // error later in `compose_mdb`. + let gap_verification = if file_info.verification.is_empty() { + Vec::new() + } else if file_info.verification.len() == file_info.segments.len() { let mut gv = Vec::new(); let mut acc = 0u64; let mut wi = 0usize; @@ -187,7 +194,12 @@ pub fn build_file_chunk_hashes_response( } gv } else { - Vec::new() + return Err(ClientError::Other(format!( + "file_info has {} verification entries but {} segments; \ + expected either zero or a 1:1 mapping", + file_info.verification.len(), + file_info.segments.len() + ))); }; Ok(FileChunkHashesResponse { diff --git a/xet_client/src/cas_types/mod.rs b/xet_client/src/cas_types/mod.rs index fd788cebc..fdbcfa983 100644 --- a/xet_client/src/cas_types/mod.rs +++ b/xet_client/src/cas_types/mod.rs @@ -316,7 +316,7 @@ pub struct QueryChunkResponse { /// Distinct from the standard `Range` header (which scopes the response body): this header tags /// regions that the client intends to re-chunk, and the response covers the whole file (windows + /// gap subtrees). Value uses the same `bytes=A-B,C-D` syntax as `Range`. -pub const X_RANGE_DIRTY_HEADER: &str = "x-range-dirty"; +pub const X_RANGE_DIRTY_HEADER: &str = "X-Range-Dirty"; /// One chunk-aligned dirty window of a file, returned by `GET /v2/file-chunk-hashes/{file_id}`. /// diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 514352f40..5e7533850 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -278,7 +278,7 @@ pub async fn upload_ranges( let mut hash_ranges = response.hash_ranges; let trailing_gap = hash_ranges.pop().flatten(); // Leading gap is empty (None) => first window starts at byte 0. - let first_window_at_start = hash_ranges.first().is_some_and(Option::is_none); + let first_window_at_start = matches!(hash_ranges.first(), Some(None)); let last_window_at_end = trailing_gap.is_none(); let last_idx = uploaded.len() - 1; @@ -377,8 +377,10 @@ fn compose_mdb( gap_idx += 1; seg_idx += 1; } - let original_window_end = w.end.min(original_size); - while seg_idx < n_segs && seg_byte_starts[seg_idx] < original_window_end { + // Server guarantees window ends are clamped to file_size (see xetcas + // `core::get_file_chunk_hashes`), so this invariant should always hold. + debug_assert!(w.end <= original_size, "window end {} exceeds original_size {}", w.end, original_size); + while seg_idx < n_segs && seg_byte_starts[seg_idx] < w.end { seg_idx += 1; } if w.mdb.verification.len() != w.mdb.segments.len() { From 2ebcfcc1cdc1374e29b27b817e622171f403e2e6 Mon Sep 17 00:00:00 2001 From: Adrien Date: Mon, 18 May 2026 20:45:22 +0200 Subject: [PATCH 35/38] docs(range_upload): link boundary-case comment to exercising tests Reference test_resize_insert_at_segment_boundary and test_mid_edit_plus_append in the comment block explaining the pure-insert-at-w_end edge case, so a future reader can find the coverage immediately. --- xet_data/src/processing/range_upload.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/xet_data/src/processing/range_upload.rs b/xet_data/src/processing/range_upload.rs index 5e7533850..9da0746fc 100644 --- a/xet_data/src/processing/range_upload.rs +++ b/xet_data/src/processing/range_upload.rs @@ -201,6 +201,7 @@ pub async fn upload_ranges( // Find the slice of edits that land in this window. A pure insert at exactly // `w_end` belongs here only when `w_end == original_size` (no later window can // take it); anywhere else it belongs to the next window starting at that byte. + // Exercised by `test_resize_insert_at_segment_boundary` and `test_mid_edit_plus_append`. let edits_end = dirty_inputs[input_idx..] .iter() .take_while(|d| { From 624ba7c4a490c9277e850f4c1dbdcde0485a8aa1 Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 21 May 2026 02:00:20 +0200 Subject: [PATCH 36/38] refactor(file_cleaner): use mut self instead of this in finish_inner --- xet_data/src/processing/file_cleaner.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/xet_data/src/processing/file_cleaner.rs b/xet_data/src/processing/file_cleaner.rs index df6923f35..d292ea3e9 100644 --- a/xet_data/src/processing/file_cleaner.rs +++ b/xet_data/src/processing/file_cleaner.rs @@ -227,23 +227,23 @@ impl SingleFileCleaner { } async fn finish_inner( - mut this: Self, + mut self, register: bool, ) -> Result<(XetFileInfo, ChunkHashList, MDBFileInfo, DeduplicationMetrics)> { - if let Some(chunk) = this.chunker.finish() { + if let Some(chunk) = self.chunker.finish() { let data = Arc::new([chunk]); - this.deduper_process_chunks(data).await?; + self.deduper_process_chunks(data).await?; } - let sha256 = if let Some(generator) = this.sha_generator.take() { + let sha256 = if let Some(generator) = self.sha_generator.take() { Some(generator.finalize().await?) } else { - this.provided_sha256 + self.provided_sha256 }; let metadata_ext = sha256.map(FileMetadataExt::new); let (file_hash, chunk_hashes, remaining_file_data, deduplication_metrics) = - this.dedup_manager_fut.await?.finalize(metadata_ext); + self.dedup_manager_fut.await?.finalize(metadata_ext); let file_info = XetFileInfo { hash: file_hash.hex(), @@ -258,12 +258,12 @@ impl SingleFileCleaner { } let mdb_file_info = if register { - this.session + self.session .register_single_file_clean_completion(remaining_file_data, &deduplication_metrics) .await?; MDBFileInfo::default() } else { - this.session + self.session .register_single_file_clean_completion_detached(remaining_file_data, &deduplication_metrics) .await? }; @@ -271,10 +271,10 @@ impl SingleFileCleaner { info!( target: "client_telemetry", action = "clean", - file_name = this.file_name.as_deref().unwrap_or_default().to_string(), + file_name = self.file_name.as_deref().unwrap_or_default().to_string(), file_size_count = deduplication_metrics.total_bytes, new_bytes_count = deduplication_metrics.new_bytes, - start_ts = this.start_time.to_rfc3339(), + start_ts = self.start_time.to_rfc3339(), end_processing_ts = Utc::now().to_rfc3339(), ); From 3fd592ebffdc6252680592c6295397322a3dfc2b Mon Sep 17 00:00:00 2001 From: Adrien Date: Thu, 21 May 2026 02:01:58 +0200 Subject: [PATCH 37/38] refactor(file_upload_session): drop unnecessary destructure+rewrap of finish() result --- xet_data/src/processing/file_upload_session.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/xet_data/src/processing/file_upload_session.rs b/xet_data/src/processing/file_upload_session.rs index 081f8f809..b80f93f10 100644 --- a/xet_data/src/processing/file_upload_session.rs +++ b/xet_data/src/processing/file_upload_session.rs @@ -275,8 +275,7 @@ impl FileUploadSession { let handle = runtime.spawn(async move { let _permit = semaphore.acquire().await?; cleaner.add_data(&bytes).await?; - let (file_info, metrics) = cleaner.finish().await?; - Ok((file_info, metrics)) + cleaner.finish().await }); Ok((id, handle)) @@ -298,8 +297,7 @@ impl FileUploadSession { } cleaner.add_data(&buffer[..n]).await?; } - let (file_info, metrics) = cleaner.finish().await?; - Ok((file_info, metrics)) + cleaner.finish().await } /// Registers a new xorb for upload, returning true if the xorb was added to the upload queue and false From 54e049857c2e0e2619ae5ba9c063ea13dfd2a166 Mon Sep 17 00:00:00 2001 From: Hoyt Koepke Date: Thu, 21 May 2026 11:11:24 -0700 Subject: [PATCH 38/38] Added stress testing; use of next_stable_chunk_boundary logic (#845) This PR adds additional stress testing to https://github.com/huggingface/xet-core/pull/717, causing the simulation server logic to properly use next_stable_chunk_boundary logic for the simulation. As a result, multiple requested ranges for editing could be merged into a single range on the server end, which required updating some checks on the client side. Additional stress tests were added under the simulation feature flag, and smoke tests added as well for the cargo smoke-test feature. --- > [!NOTE] > **Medium Risk** > Moderate risk: changes core dedup/chunk-window construction and relaxes client/server window shape assumptions, which can affect correctness of range uploads and hashing. Also bumps low-level deps (`ctor`, `openssl`) and adds a Node napi smoke-test example, increasing build surface area. > > **Overview** > Adds a new public helper `next_stable_chunk_boundary` (canonical in `xet_core_structures`, re-exported from `xet_data`) and updates server-side `build_file_chunk_hashes_response` to **extend dirty ranges to the next stable chunk boundary and coalesce overlaps** before computing windows. > > Updates `upload_ranges` to accept that the server may merge windows (validating only `windows` non-empty and `hash_ranges.len() == windows.len() + 1`) and adds targeted regression/stress tests plus a new `xet_data` test suite validating stable-boundary behavior under random prefix mutations. > > Separately: improves retry logging by marking `query_dedup` 404s as *expected cache misses*, increases client read timeout to 300s, bumps `ctor` to v1 and updates `openssl` crates, tightens a few minor iterations/formatting, and adds an `examples/xet_pkg_napi` Node addon smoke-test project (excluded from the workspace). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 49427f0776188ddd021103672400f21b5cb81cc0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Signed-off-by: dependabot[bot] Signed-off-by: Arpit Jain Co-authored-by: tison Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Di Xiao Co-authored-by: Arpit Jain <3242828+arpitjain099@users.noreply.github.com> Co-authored-by: Assaf Vayner Co-authored-by: Rajat Arya Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/pre-release-testing.yml | 3 + Cargo.lock | 50 +- Cargo.toml | 10 +- ...pdate_260424_next_stable_chunk_boundary.md | 18 + examples/xet_pkg_napi/.gitignore | 5 + examples/xet_pkg_napi/Cargo.lock | 3448 +++++++++++++++++ examples/xet_pkg_napi/Cargo.toml | 38 + examples/xet_pkg_napi/README.md | 103 + examples/xet_pkg_napi/build.rs | 3 + examples/xet_pkg_napi/package-lock.json | 35 + examples/xet_pkg_napi/package.json | 22 + examples/xet_pkg_napi/smoke.mjs | 112 + examples/xet_pkg_napi/src/lib.rs | 106 + hf_xet/Cargo.lock | 50 +- simulation/chunk_cache_bench/Cargo.lock | 50 +- wasm/hf_xet_thin_wasm/Cargo.lock | 41 +- wasm/hf_xet_wasm/Cargo.lock | 41 +- .../src/cas_client/chunk_window_builder.rs | 180 +- xet_client/src/cas_client/remote_client.rs | 3 +- xet_client/src/cas_client/retry_wrapper.rs | 45 + .../src/metadata_shard/shard_file_manager.rs | 7 +- .../src/metadata_shard/shard_in_memory.rs | 4 +- .../src/metadata_shard/streaming_shard.rs | 64 +- .../collect_compression_stats.rs | 4 +- .../src/xorb_object/constants.rs | 47 + .../src/xorb_object/xorb_object_format.rs | 6 +- xet_data/src/deduplication/chunking.rs | 17 +- xet_data/src/deduplication/mod.rs | 2 +- .../src/deduplication/parallel chunking.lyx | 55 +- .../src/deduplication/parallel chunking.pdf | Bin 164254 -> 165636 bytes xet_data/src/processing/range_upload.rs | 321 +- .../test_stable_chunk_boundary_detection.rs | 312 ++ .../src/xet_session/file_download_group.rs | 2 +- xet_runtime/src/config/groups/client.rs | 4 +- .../src/file_utils/safe_file_creator.rs | 4 +- xet_runtime/src/utils/configuration_utils.rs | 4 +- 36 files changed, 5025 insertions(+), 191 deletions(-) create mode 100644 api_changes/update_260424_next_stable_chunk_boundary.md create mode 100644 examples/xet_pkg_napi/.gitignore create mode 100644 examples/xet_pkg_napi/Cargo.lock create mode 100644 examples/xet_pkg_napi/Cargo.toml create mode 100644 examples/xet_pkg_napi/README.md create mode 100644 examples/xet_pkg_napi/build.rs create mode 100644 examples/xet_pkg_napi/package-lock.json create mode 100644 examples/xet_pkg_napi/package.json create mode 100644 examples/xet_pkg_napi/smoke.mjs create mode 100644 examples/xet_pkg_napi/src/lib.rs create mode 100644 xet_data/tests/test_stable_chunk_boundary_detection.rs diff --git a/.github/workflows/pre-release-testing.yml b/.github/workflows/pre-release-testing.yml index f9e72ea69..3817ac150 100644 --- a/.github/workflows/pre-release-testing.yml +++ b/.github/workflows/pre-release-testing.yml @@ -10,6 +10,9 @@ on: tag: description: "Tag to test (e.g., v1.0.3-rc2)" required: true + +permissions: {} + jobs: trigger_rc_testing: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 8eb12411d..61cb6b9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,20 +1129,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "f7335955a5f85f95f3188623240e081e7b2059a8ad1bae68944b7cfdd718fb10" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctr" version = "0.9.2" @@ -1284,21 +1278,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -2732,6 +2711,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-section" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2c24837c4fd5ab6a31d64133eae954f5199247523cf29586117e85245c0dd3" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3068,15 +3059,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.0", "cfg-if 1.0.4", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3115,9 +3105,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 2501c1bf3..6c1cf0c22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,13 @@ members = [ "git_xet", "simulation", ] -exclude = ["simulation/chunk_cache_bench", "hf_xet", "wasm/hf_xet_wasm", "wasm/hf_xet_thin_wasm"] +exclude = [ + "simulation/chunk_cache_bench", + "hf_xet", + "wasm/hf_xet_wasm", + "wasm/hf_xet_thin_wasm", + "examples/xet_pkg_napi", +] [workspace.package] version = "1.5.2" @@ -52,7 +58,7 @@ console-subscriber = "0.5" countio = { version = "0.3", features = ["futures"] } crc32fast = "1.5" csv = "1" -ctor = "0.6" +ctor = "1" dirs = "6.0" futures = "0.3" humantime = "2.1" diff --git a/api_changes/update_260424_next_stable_chunk_boundary.md b/api_changes/update_260424_next_stable_chunk_boundary.md new file mode 100644 index 000000000..696f67ce2 --- /dev/null +++ b/api_changes/update_260424_next_stable_chunk_boundary.md @@ -0,0 +1,18 @@ +This update adds a new public deduplication helper for computing restart-safe chunk boundaries from existing chunk boundary metadata. + +What changed +- Added `next_stable_chunk_boundary(starting_position, chunk_boundaries) -> Option`. +- Canonical implementation lives in `xet_core_structures::xorb_object::constants` (alongside the chunk-size constants it uses). +- Re-exported from `xet_data::deduplication` for convenience. +- The function scans forward from `starting_position` and returns the next chunk boundary that satisfies the stable-boundary condition: + - two consecutive chunk sizes in `[2 * min_chunk, max_chunk - min_chunk)`, + - where `min_chunk` and `max_chunk` are derived from chunking constants. + +Why this matters +- Callers that already have chunk-boundary metadata can locate a stable resume boundary without re-reading file bytes. +- This enables deterministic alignment behavior for resumed/partial workflows that need chunk boundaries robust to prefix changes. +- The server-side `build_file_chunk_hashes_response` now extends dirty ranges to stable chunk boundaries before building windows, so that the client's rechunking around each dirty window is guaranteed to converge to the same chunk boundaries as the original file. + +Usage notes +- `chunk_boundaries` should be monotonically increasing chunk-end offsets produced by the same chunking configuration. +- `starting_position` may be any byte offset (not necessarily a chunk boundary) and is used as the reference offset from which to search for the next stable chunk boundary. diff --git a/examples/xet_pkg_napi/.gitignore b/examples/xet_pkg_napi/.gitignore new file mode 100644 index 000000000..6dd946c17 --- /dev/null +++ b/examples/xet_pkg_napi/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.node +index.js +index.d.ts +downloads/ diff --git a/examples/xet_pkg_napi/Cargo.lock b/examples/xet_pkg_napi/Cargo.lock new file mode 100644 index 000000000..e1c6d30f9 --- /dev/null +++ b/examples/xet_pkg_napi/Cargo.lock @@ -0,0 +1,3448 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if 1.0.4", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "countio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" +dependencies = [ + "futures-io", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if 1.0.4", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ctor" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378f0974ae2468eaf63aa036dbe9c926b0dc7ea64c156f2ea618bc2f75b934f0" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gearhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" +dependencies = [ + "cfg-if 0.1.10", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hf-xet" +version = "1.5.2" +dependencies = [ + "async-trait", + "bytes", + "http", + "more-asserts", + "serde", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if 1.0.4", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if 1.0.4", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "konst_proc_macros", + "typewit", +] + +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if 1.0.4", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "link-section" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8600ca3dbe044f07955b443ff606c50f45295b863289bbe7d0844d50cf11e4" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4_flex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor 0.2.9", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if 1.0.4", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "thiserror", + "tower-service", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if 1.0.4", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe-transmute" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest", + "sha2-asm", +] + +[[package]] +name = "sha2-asm" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" +dependencies = [ + "cc", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if 1.0.4", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-retry" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40f644c762e9d396831ae2f8935c954b0d758c4532e924bead0f666d0c1c8640" +dependencies = [ + "pin-project-lite", + "rand 0.10.1", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if 1.0.4", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xet-client" +version = "1.5.2" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bytes", + "clap", + "crc32fast", + "futures", + "http", + "hyper", + "lazy_static", + "more-asserts", + "rand 0.10.1", + "redb", + "reqwest", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror", + "tokio", + "tokio-retry", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-core-structures" +version = "1.5.2" +dependencies = [ + "async-trait", + "base64", + "blake3", + "bytemuck", + "bytes", + "clap", + "countio", + "csv", + "futures", + "futures-util", + "getrandom 0.4.2", + "heapify", + "itertools", + "lazy_static", + "lz4_flex", + "more-asserts", + "rand 0.10.1", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] + +[[package]] +name = "xet-data" +version = "1.5.2" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "clap", + "gearhash", + "http", + "itertools", + "lazy_static", + "more-asserts", + "rand 0.10.1", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", + "walkdir", + "xet-client", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-runtime" +version = "1.5.2" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor 1.0.5", + "dirs", + "futures", + "git-version", + "humantime", + "konst", + "lazy_static", + "libc", + "more-asserts", + "oneshot", + "pin-project", + "rand 0.10.1", + "reqwest", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "whoami", + "winapi", +] + +[[package]] +name = "xet_pkg_napi" +version = "0.0.1" +dependencies = [ + "hf-xet", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/xet_pkg_napi/Cargo.toml b/examples/xet_pkg_napi/Cargo.toml new file mode 100644 index 000000000..bfbd31fa6 --- /dev/null +++ b/examples/xet_pkg_napi/Cargo.toml @@ -0,0 +1,38 @@ +# Standalone — not part of the xet-core workspace. The workspace excludes this +# directory, but napi-rs's build flow can resolve cargo against the *original* +# (non-worktree) repo path during development, which would otherwise drag this +# crate back into the workspace it's deliberately outside of. +[workspace] + +[package] +name = "xet_pkg_napi" +version = "0.0.1" +edition = "2024" +license = "Apache-2.0" +description = "Smoke-test napi binding for hf-xet (xet_pkg). Verifies that the Rust client builds and links inside a Node.js native addon." +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# hf-xet is published as `hf-xet` but the lib name is `xet`. Pull it via path. +hf-xet = { path = "../../xet_pkg" } + +# napi-rs 2.x — the standard for Rust → Node.js native addons. + +napi = { version = "2", default-features = false, features = ["napi8"] } +napi-derive = "2" + +[build-dependencies] +napi-build = "2" + +# cargo-machete can't match `hf-xet` (package name) to `xet::` (lib name in source). +[package.metadata.cargo-machete] +ignored = ["hf-xet"] + +[profile.release] +lto = true +opt-level = 3 +debug = 1 +strip = "symbols" diff --git a/examples/xet_pkg_napi/README.md b/examples/xet_pkg_napi/README.md new file mode 100644 index 000000000..269a66a48 --- /dev/null +++ b/examples/xet_pkg_napi/README.md @@ -0,0 +1,103 @@ +# xet_pkg_napi — napi smoke test for hf-xet + +A minimal [napi-rs](https://napi.rs) native addon that links against the +`xet_pkg` (`hf-xet`) crate. Verifies that `hf-xet` compiles, links, starts up, +and can actually pull a file from CAS — all from inside a Node.js native +module. + +## What it exports + +The Rust crate at `src/lib.rs` exposes three functions to Node: + +- `initLogging(version: string)` — installs `xet`'s tracing subscriber. +- `smokeTest(): string` — builds a `XetSession` synchronously and constructs + upload-commit + file-download-group builders. No I/O. +- `downloadFile(opts): { destPath, bytesDownloaded }` — actually downloads a + Xet-stored file from the HuggingFace Hub. **Synchronous**: blocks the libuv + main thread until the download finishes, so the JS event loop is paused for + the duration. Acceptable for a smoke test; a real binding should wrap this + in `napi::Task` / `tokio::task::spawn_blocking`. + +This crate is **excluded from the workspace** (see the root `Cargo.toml`) +and carries its own `[workspace]` table because it has its own +`crate-type = ["cdylib"]` and ships under the `napi-rs/cli` build flow rather +than `cargo build`. + +## Build & run + +Requires Node ≥ 18, a Rust toolchain, and outbound network access to +`huggingface.co` and `cas-bridge.xethub.hf.co`. + +```sh +cd examples/xet_pkg_napi +npm install +npm run build:debug # or `npm run build` for release +npm run smoke +``` + +`napi build` writes two artifacts next to `package.json`: + +- `xet-pkg-napi.-.node` — the compiled cdylib +- `index.js` / `index.d.ts` — a CJS shim that picks the right `.node` for the + current platform + +`smoke.mjs`: + +1. Issues a `HEAD` against the HF Hub `resolve` URL with a non-default + User-Agent (Cloudfront strips `X-Xet-Hash` on cache hits served to + default UAs). +2. Reads `X-Xet-Hash`, `X-Linked-Size`, and `X-Linked-Etag` from the response. +3. Calls `downloadFile()` with the parsed metadata. +4. Verifies the on-disk size matches `X-Linked-Size`. + +### Configuration + +All env vars are optional. Defaults target a tiny (~540 KB) public Xet file +so the smoke test runs quickly without an HF token. + +| Var | Default | +| -------------- | -------------------------------------------------- | +| `HF_ENDPOINT` | `https://huggingface.co` | +| `HF_REPO_TYPE` | `model` (`model` \| `dataset` \| `space`) | +| `HF_REPO` | `hf-internal-testing/tiny-random-bert` | +| `HF_BRANCH` | `main` | +| `HF_FILENAME` | `pytorch_model.bin` | +| `HF_TOKEN` | _unset_ (required for private repos) | +| `HF_DEST_DIR` | `./downloads` | + +## Expected output + +``` +loaded addon, exports: [ 'initLogging', 'smokeTest', 'downloadFile' ] + +Fetching xet metadata for model:hf-internal-testing/tiny-random-bert/pytorch_model.bin@main + https://huggingface.co/hf-internal-testing/tiny-random-bert/resolve/main/pytorch_model.bin + xet-hash: 75402e74462600f62ca4a08b91c9218f36075860d5f6d7eb07f4c29ed7fa4ad6 + size: 540,217 bytes + sha256: 9922e8996d0c7e24c7f4e7a5d9c5b7303549f4ee94de0f1138b103014b51be13 +smokeTest: xet session built; runtime initialized + +Downloading -> downloads/pytorch_model.bin + +Result: + bytes downloaded: 540,217 + on-disk size: 540,217 + elapsed: 1.23s + +OK — file downloaded and size matches. +``` + +## Notes / caveats + +- **Synchronous download.** A real binding should expose this as + `#[napi]` async fn or wrap in `napi::Task` so the JS event loop isn't blocked + while xet pulls bytes from CAS. +- **No double runtime.** `xet-runtime` owns its own tokio runtime; it doesn't + piggyback on libuv. The blocking calls used here use `block_on` against + xet's runtime, so napi's main thread is the only thread that gets parked. +- **Metadata source.** The xet hash + file size come from the HF Hub's + `X-Xet-Hash` / `X-Linked-Size` headers. A non-default `User-Agent` is + required because Cloudfront caches strip those headers on cache hits served + to default UAs. +- **napi feature level.** Built against `napi8`. Bumping to `napi9`+ would + unlock newer N-API surfaces if needed. diff --git a/examples/xet_pkg_napi/build.rs b/examples/xet_pkg_napi/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/examples/xet_pkg_napi/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/examples/xet_pkg_napi/package-lock.json b/examples/xet_pkg_napi/package-lock.json new file mode 100644 index 000000000..285a1b27d --- /dev/null +++ b/examples/xet_pkg_napi/package-lock.json @@ -0,0 +1,35 @@ +{ + "name": "xet-pkg-napi", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xet-pkg-napi", + "version": "0.0.1", + "devDependencies": { + "@napi-rs/cli": "^2.18.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + } + } +} diff --git a/examples/xet_pkg_napi/package.json b/examples/xet_pkg_napi/package.json new file mode 100644 index 000000000..586ce146a --- /dev/null +++ b/examples/xet_pkg_napi/package.json @@ -0,0 +1,22 @@ +{ + "name": "xet-pkg-napi", + "version": "0.0.1", + "description": "napi-rs smoke test for hf-xet", + "private": true, + "main": "index.js", + "type": "commonjs", + "napi": { + "name": "xet-pkg-napi" + }, + "scripts": { + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "smoke": "node smoke.mjs" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/xet_pkg_napi/smoke.mjs b/examples/xet_pkg_napi/smoke.mjs new file mode 100644 index 000000000..d94bf85d9 --- /dev/null +++ b/examples/xet_pkg_napi/smoke.mjs @@ -0,0 +1,112 @@ +// Smoke driver: load the napi addon, fetch a public Xet file's metadata from +// the HuggingFace Hub, then download the file via the binding. +// +// Run after `npm run build` (or `npm run build:debug`): +// +// node smoke.mjs +// +// Optional env vars (defaults pick a tiny ~540KB public Xet file): +// HF_ENDPOINT default: https://huggingface.co +// HF_REPO_TYPE default: model (model | dataset | space) +// HF_REPO default: hf-internal-testing/tiny-random-bert +// HF_BRANCH default: main +// HF_FILENAME default: pytorch_model.bin +// HF_TOKEN optional; required for private repos +// HF_DEST_DIR default: ./downloads + +import { createRequire } from "node:module"; +import { mkdirSync, statSync, rmSync } from "node:fs"; +import { join, basename } from "node:path"; + +const require = createRequire(import.meta.url); +const addon = require("./index.js"); + +console.log("loaded addon, exports:", Object.keys(addon)); + +const endpoint = process.env.HF_ENDPOINT ?? "https://huggingface.co"; +const repoType = process.env.HF_REPO_TYPE ?? "model"; +const repoId = process.env.HF_REPO ?? "hf-internal-testing/tiny-random-bert"; +const branch = process.env.HF_BRANCH ?? "main"; +const filename = process.env.HF_FILENAME ?? "pytorch_model.bin"; +const token = process.env.HF_TOKEN ?? null; +const destDir = process.env.HF_DEST_DIR ?? "./downloads"; + +const repoPathSegment = repoType === "model" ? "" : `${repoType}s/`; +const apiTypeSegment = `${repoType}s`; + +const resolveUrl = + `${endpoint}/${repoPathSegment}${repoId}/resolve/${branch}/${filename}`; +const tokenRefreshUrl = + `${endpoint}/api/${apiTypeSegment}/${repoId}/xet-read-token/${branch}`; + +console.log(`\nFetching xet metadata for ${repoType}:${repoId}/${filename}@${branch}`); +console.log(` ${resolveUrl}`); + +// HEAD against /resolve/ — Cloudfront strips X-Xet-Hash on cache hits served +// to default UAs, so spoof a hf-xet-style User-Agent + cache-bust the URL. +const headResp = await fetch(`${resolveUrl}?_=${Date.now()}`, { + method: "HEAD", + redirect: "manual", + headers: { + "User-Agent": "xet-pkg-napi-smoke/0.1", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, +}); + +if (headResp.status !== 302 && headResp.status !== 200) { + throw new Error( + `HEAD ${resolveUrl} returned ${headResp.status} ${headResp.statusText}` + + (token ? "" : " — set HF_TOKEN if the repo is private"), + ); +} + +const xetHash = headResp.headers.get("x-xet-hash"); +const linkedSize = headResp.headers.get("x-linked-size"); +const linkedEtag = headResp.headers.get("x-linked-etag"); + +if (!xetHash || !linkedSize) { + throw new Error( + "Hub did not return X-Xet-Hash / X-Linked-Size headers — is this a Xet-stored file?", + ); +} + +const sha256 = linkedEtag ? linkedEtag.replace(/"/g, "") : null; +const fileSize = Number(linkedSize); +console.log(` xet-hash: ${xetHash}`); +console.log(` size: ${fileSize.toLocaleString()} bytes`); +console.log(` sha256: ${sha256 ?? "(unknown)"}`); + +mkdirSync(destDir, { recursive: true }); +const destPath = join(destDir, basename(filename)); +rmSync(destPath, { force: true }); + +addon.initLogging("xet_pkg_napi/0.0.1 smoke"); + +const smokeResult = addon.smokeTest(); +console.log(`smokeTest: ${smokeResult}`); + +console.log(`\nDownloading -> ${destPath}`); +const t0 = Date.now(); +const result = addon.downloadFile({ + tokenRefreshUrl, + ...(token ? { authToken: token } : {}), + xetHash, + fileSize, + ...(sha256 ? { sha256 } : {}), + destPath, +}); +const elapsedSec = (Date.now() - t0) / 1000; + +const stat = statSync(destPath); +console.log(`\nResult:`); +console.log(` bytes downloaded: ${result.bytesDownloaded.toLocaleString()}`); +console.log(` on-disk size: ${stat.size.toLocaleString()}`); +console.log(` elapsed: ${elapsedSec.toFixed(2)}s`); + +if (stat.size !== fileSize) { + throw new Error( + `size mismatch: expected ${fileSize}, got ${stat.size} on disk`, + ); +} + +console.log("\nOK — file downloaded and size matches."); diff --git a/examples/xet_pkg_napi/src/lib.rs b/examples/xet_pkg_napi/src/lib.rs new file mode 100644 index 000000000..6fd008dcf --- /dev/null +++ b/examples/xet_pkg_napi/src/lib.rs @@ -0,0 +1,106 @@ +//! napi smoke-test binding for `hf-xet` (the `xet` crate at `xet_pkg/`). +//! +//! Exposes: +//! - `initLogging(version)` — install xet's tracing subscriber +//! - `smokeTest()` — build a `XetSession` and runtime helpers without doing any I/O +//! - `downloadFile(opts)` — actually download a Xet-stored file from the HuggingFace Hub to a local path +//! +//! `downloadFile` is intentionally synchronous: it blocks the caller until the +//! download completes, internally using `xet`'s `*_blocking` APIs which run on +//! xet-runtime's own tokio runtime. Calling it from JS will block the libuv +//! main thread for the duration — fine for a smoke test, but a real binding +//! should wrap this in `napi::Task` / `tokio::task::spawn_blocking` so the JS +//! event loop stays responsive. + +use napi::{Error as NapiError, Status}; +use napi_derive::napi; +use xet::xet_session::{HeaderMap, HeaderValue, XetFileInfo, XetSessionBuilder, header}; + +fn to_napi_err(e: E) -> NapiError { + NapiError::new(Status::GenericFailure, e.to_string()) +} + +#[napi(js_name = "initLogging")] +pub fn init_logging(version: String) { + xet::init_logging(version); +} + +#[napi(js_name = "smokeTest")] +pub fn smoke_test() -> Result { + let session = XetSessionBuilder::new().build().map_err(to_napi_err)?; + let _upload = session.new_upload_commit().map_err(to_napi_err)?; + let _download = session.new_file_download_group().map_err(to_napi_err)?; + Ok("xet session built; runtime initialized".to_string()) +} + +/// Options for [`downloadFile`]. +/// +/// `xetHash` and `fileSize` come from the HuggingFace Hub's +/// `X-Xet-Hash` and `X-Linked-Size` response headers (issue a HEAD against +/// the `/{repo}/resolve/{ref}/{filename}` URL with a `User-Agent` to see them +/// — Cloudfront strips them on cache hits without a UA hint). +#[napi(object, js_name = "DownloadFileOptions")] +pub struct DownloadFileOptions { + /// The HuggingFace Hub's xet-read-token endpoint, e.g. + /// `https://huggingface.co/api/models/{repo}/xet-read-token/{ref}`. + pub token_refresh_url: String, + /// Optional bearer token for the refresh endpoint. Required for private + /// repos; for public repos this can be `null`. + pub auth_token: Option, + /// The xet content hash (hex string) of the file to download. + pub xet_hash: String, + /// The file's size in bytes. JS `number` is precise up to 2^53; HF files + /// are well under that. + pub file_size: i64, + /// Optional SHA-256 (hex) used by xet to verify the download. + pub sha256: Option, + /// Local filesystem destination for the downloaded file. + pub dest_path: String, +} + +/// Result of a successful [`downloadFile`] call. +#[napi(object, js_name = "DownloadFileResult")] +pub struct DownloadFileResult { + pub dest_path: String, + pub bytes_downloaded: i64, +} + +#[napi(js_name = "downloadFile")] +pub fn download_file(opts: DownloadFileOptions) -> Result { + let mut headers = HeaderMap::new(); + if let Some(token) = opts.auth_token.as_deref() { + let value = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|e| NapiError::new(Status::InvalidArg, format!("invalid auth token: {e}")))?; + headers.insert(header::AUTHORIZATION, value); + } + + let file_size: u64 = opts + .file_size + .try_into() + .map_err(|_| NapiError::new(Status::InvalidArg, "fileSize must be non-negative"))?; + let file_info = match opts.sha256 { + Some(sha) => XetFileInfo::new_with_sha256(opts.xet_hash, file_size, sha), + None => XetFileInfo::new(opts.xet_hash, file_size), + }; + + let session = XetSessionBuilder::new().build().map_err(to_napi_err)?; + let group = session + .new_file_download_group() + .map_err(to_napi_err)? + .with_token_refresh_url(opts.token_refresh_url, headers) + .build_blocking() + .map_err(to_napi_err)?; + + let dest_path = std::path::PathBuf::from(&opts.dest_path); + group + .download_file_to_path_blocking(file_info, dest_path.clone()) + .map_err(to_napi_err)?; + let report = group.finish_blocking().map_err(to_napi_err)?; + + let bytes_downloaded: i64 = report.progress.total_bytes_completed.try_into().unwrap_or(i64::MAX); + + Ok(DownloadFileResult { + dest_path: opts.dest_path, + bytes_downloaded, + }) +} diff --git a/hf_xet/Cargo.lock b/hf_xet/Cargo.lock index 706c3b794..1b32fe3c6 100644 --- a/hf_xet/Cargo.lock +++ b/hf_xet/Cargo.lock @@ -633,20 +633,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "5c24d2b2b7c12a2fffb7c5c8fd0dcda7ca14b4600fa2d3701b6079aefb6fa180" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "debugid" version = "0.8.0" @@ -707,21 +701,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1617,6 +1596,18 @@ dependencies = [ "libc", ] +[[package]] +name = "link-section" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4641b91711debb59c61b07eb5e30521ed6d9e2bdd9fd04f934e7da3a5bc386d4" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1875,15 +1866,14 @@ checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.0", "cfg-if 1.0.4", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1916,9 +1906,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", diff --git a/simulation/chunk_cache_bench/Cargo.lock b/simulation/chunk_cache_bench/Cargo.lock index 912362504..60c14127d 100644 --- a/simulation/chunk_cache_bench/Cargo.lock +++ b/simulation/chunk_cache_bench/Cargo.lock @@ -791,20 +791,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "5c24d2b2b7c12a2fffb7c5c8fd0dcda7ca14b4600fa2d3701b6079aefb6fa180" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "daemonize" version = "0.5.0" @@ -919,21 +913,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1866,12 +1845,24 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "link-section" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4641b91711debb59c61b07eb5e30521ed6d9e2bdd9fd04f934e7da3a5bc386d4" + [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2207,15 +2198,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2245,9 +2235,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", diff --git a/wasm/hf_xet_thin_wasm/Cargo.lock b/wasm/hf_xet_thin_wasm/Cargo.lock index fef71fd2c..087ca4132 100644 --- a/wasm/hf_xet_thin_wasm/Cargo.lock +++ b/wasm/hf_xet_thin_wasm/Cargo.lock @@ -471,20 +471,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "5c24d2b2b7c12a2fffb7c5c8fd0dcda7ca14b4600fa2d3701b6079aefb6fa180" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "deranged" version = "0.5.8" @@ -536,21 +530,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1217,6 +1196,18 @@ dependencies = [ "libc", ] +[[package]] +name = "link-section" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4641b91711debb59c61b07eb5e30521ed6d9e2bdd9fd04f934e7da3a5bc386d4" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.12.1" diff --git a/wasm/hf_xet_wasm/Cargo.lock b/wasm/hf_xet_wasm/Cargo.lock index 83fc63fd3..3e0cef086 100644 --- a/wasm/hf_xet_wasm/Cargo.lock +++ b/wasm/hf_xet_wasm/Cargo.lock @@ -492,20 +492,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "5c24d2b2b7c12a2fffb7c5c8fd0dcda7ca14b4600fa2d3701b6079aefb6fa180" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "deranged" version = "0.5.8" @@ -557,21 +551,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1305,6 +1284,18 @@ dependencies = [ "libc", ] +[[package]] +name = "link-section" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4641b91711debb59c61b07eb5e30521ed6d9e2bdd9fd04f934e7da3a5bc386d4" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.12.1" diff --git a/xet_client/src/cas_client/chunk_window_builder.rs b/xet_client/src/cas_client/chunk_window_builder.rs index a43b88da5..85d165e19 100644 --- a/xet_client/src/cas_client/chunk_window_builder.rs +++ b/xet_client/src/cas_client/chunk_window_builder.rs @@ -4,6 +4,7 @@ use xet_core_structures::merklehash::{MerkleHash, MerkleHashSubtree}; use xet_core_structures::metadata_shard::file_structs::MDBFileInfo; +use xet_core_structures::xorb_object::constants::next_stable_chunk_boundary; use crate::cas_types::{ChunkWindow, FileChunkHashesResponse, FileRange}; use crate::error::{ClientError, Result}; @@ -20,6 +21,44 @@ pub struct ChunkWindowBuilder<'a> { hash_ranges: Vec>, } +fn next_stable_end_for_range(range_end: u64, file_size: u64, chunk_boundaries: &[usize]) -> u64 { + if range_end >= file_size { + return file_size; + } + let Ok(starting_position) = usize::try_from(range_end) else { + return file_size; + }; + next_stable_chunk_boundary(starting_position, chunk_boundaries) + .and_then(|boundary| u64::try_from(boundary).ok()) + .map(|boundary| boundary.min(file_size)) + .unwrap_or(file_size) +} + +fn extend_dirty_ranges_to_stable_windows( + mut dirty_ranges: Vec, + file_size: u64, + chunk_boundaries: &[usize], +) -> Vec { + for range in &mut dirty_ranges { + if range.end < file_size { + range.end = next_stable_end_for_range(range.end, file_size, chunk_boundaries); + } + } + + dirty_ranges.sort_by_key(|range| range.start); + let mut coalesced: Vec = Vec::with_capacity(dirty_ranges.len()); + for range in dirty_ranges { + if let Some(last) = coalesced.last_mut() + && range.start <= last.end + { + last.end = last.end.max(range.end); + continue; + } + coalesced.push(range); + } + coalesced +} + impl<'a> ChunkWindowBuilder<'a> { pub fn new(dirty_ranges: &'a [FileRange]) -> Self { debug_assert!( @@ -153,13 +192,33 @@ pub fn build_file_chunk_hashes_response( return Err(ClientError::Other("no valid dirty ranges".into())); } + let chunks: Vec<(MerkleHash, u64)> = chunks.into_iter().collect(); + let mut chunk_boundaries: Vec = Vec::with_capacity(chunks.len()); + let mut stable_ranges_supported = true; + { + let mut boundary_cursor: u64 = 0; + for (_, size) in &chunks { + boundary_cursor += *size; + let Ok(boundary) = usize::try_from(boundary_cursor) else { + stable_ranges_supported = false; + break; + }; + chunk_boundaries.push(boundary); + } + } + + let dirty_ranges = if stable_ranges_supported { + extend_dirty_ranges_to_stable_windows(dirty_ranges, file_size, &chunk_boundaries) + } else { + dirty_ranges + }; + + let total_chunks = chunks.len() as u64; let mut builder = ChunkWindowBuilder::new(&dirty_ranges); let mut cumulative_bytes: u64 = 0; - let mut total_chunks: u64 = 0; for (hash, size) in chunks { cumulative_bytes += size; builder.process_chunk(hash, size, cumulative_bytes); - total_chunks += 1; } let (windows, hash_ranges) = builder.finish(); @@ -215,3 +274,120 @@ pub fn build_file_chunk_hashes_response( gap_verification, }) } + +#[cfg(test)] +mod tests { + use xet_core_structures::merklehash::MerkleHash; + use xet_core_structures::metadata_shard::file_structs::{FileDataSequenceEntry, MDBFileInfo}; + use xet_core_structures::xorb_object::constants::{ + MAXIMUM_CHUNK_MULTIPLIER, MINIMUM_CHUNK_DIVISOR, TARGET_CHUNK_SIZE, + }; + + use super::*; + + fn stable_chunk_size() -> u64 { + let minimum_chunk = *TARGET_CHUNK_SIZE / *MINIMUM_CHUNK_DIVISOR; + let maximum_chunk = *TARGET_CHUNK_SIZE * *MAXIMUM_CHUNK_MULTIPLIER; + let size = 2 * minimum_chunk; + assert!(size < maximum_chunk - minimum_chunk); + size as u64 + } + + fn build_test_file_info_and_chunks(n_chunks: usize, chunk_size: u64) -> (MDBFileInfo, Vec<(MerkleHash, u64)>) { + let chunks: Vec<(MerkleHash, u64)> = (0..n_chunks) + .map(|i| (MerkleHash::random_from_seed(i as u64 + 1), chunk_size)) + .collect(); + let file_size = chunk_size * n_chunks as u64; + let file_info = MDBFileInfo { + segments: vec![FileDataSequenceEntry { + xorb_hash: MerkleHash::random_from_seed(999), + xorb_flags: 0, + unpacked_segment_bytes: file_size as u32, + chunk_index_start: 0, + chunk_index_end: n_chunks as u32, + }], + ..Default::default() + }; + (file_info, chunks) + } + + #[test] + fn test_server_extends_dirty_window_to_next_stable_boundary() { + let chunk_size = stable_chunk_size(); + let (file_info, chunks) = build_test_file_info_and_chunks(6, chunk_size); + let dirty_ranges = vec![FileRange::new(1, chunk_size + 1)]; + + let response = build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks).unwrap(); + + assert_eq!(response.windows.len(), 1); + assert_eq!(response.windows[0].dirty_byte_range, [0, 4 * chunk_size]); + } + + #[test] + fn test_server_coalesces_ranges_after_stable_extension() { + let chunk_size = stable_chunk_size(); + let (file_info, chunks) = build_test_file_info_and_chunks(8, chunk_size); + let dirty_ranges = vec![ + FileRange::new(1, chunk_size + 1), + FileRange::new(3 * chunk_size + 7, 3 * chunk_size + 42), + ]; + + let response = build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks).unwrap(); + + assert_eq!(response.windows.len(), 1); + assert_eq!(response.windows[0].dirty_byte_range, [0, 6 * chunk_size]); + } + + #[test] + fn test_server_no_extension_when_range_already_at_file_end() { + let chunk_size = stable_chunk_size(); + let (file_info, chunks) = build_test_file_info_and_chunks(6, chunk_size); + let file_size = chunk_size * 6; + let dirty_ranges = vec![FileRange::new(4 * chunk_size, file_size)]; + + let response = build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks).unwrap(); + + assert_eq!(response.windows.len(), 1); + assert_eq!(response.windows[0].dirty_byte_range, [4 * chunk_size, file_size]); + } + + #[test] + fn test_server_separate_ranges_stay_separate_when_far_apart() { + let chunk_size = stable_chunk_size(); + let n_chunks = 20; + let (file_info, chunks) = build_test_file_info_and_chunks(n_chunks, chunk_size); + let dirty_ranges = vec![ + FileRange::new(0, chunk_size), + FileRange::new(14 * chunk_size, 15 * chunk_size), + ]; + + let response = build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks).unwrap(); + + assert!( + response.windows.len() >= 2, + "far-apart ranges should remain separate, got {} window(s)", + response.windows.len() + ); + assert_eq!(response.hash_ranges.len(), response.windows.len() + 1); + } + + #[test] + fn test_server_extension_falls_through_to_file_end_when_no_stable_boundary() { + let minimum_chunk = *TARGET_CHUNK_SIZE / *MINIMUM_CHUNK_DIVISOR; + let maximum_chunk = *TARGET_CHUNK_SIZE * *MAXIMUM_CHUNK_MULTIPLIER; + let forced_size = maximum_chunk as u64; + let (file_info, chunks) = build_test_file_info_and_chunks(4, forced_size); + let file_size = forced_size * 4; + let dirty_ranges = vec![FileRange::new(0, forced_size)]; + + let response = build_file_chunk_hashes_response(&file_info, dirty_ranges, chunks).unwrap(); + + assert_eq!(response.windows.len(), 1); + let w = &response.windows[0]; + assert_eq!( + w.dirty_byte_range[1], file_size, + "when no stable boundary exists, window should extend to file end; \ + minimum_chunk={minimum_chunk}, maximum_chunk={maximum_chunk}" + ); + } +} diff --git a/xet_client/src/cas_client/remote_client.rs b/xet_client/src/cas_client/remote_client.rs index c24b27a92..3a8bc6177 100644 --- a/xet_client/src/cas_client/remote_client.rs +++ b/xet_client/src/cas_client/remote_client.rs @@ -156,6 +156,7 @@ impl RemoteClient { let result = RetryWrapper::new(self.ctx.clone(), api_tag) .with_429_no_retry() + .with_expected_404() .log_errors_as_info() .run(move || client.get(url.clone()).with_extension(Api(api_tag)).send()) .await; @@ -583,7 +584,7 @@ impl Client for RemoteClient { // Use the no-read-timeout client for shard uploads. reqwest's per-request timeout() // does NOT override the client-level read_timeout(), so we use a separate client // with no read_timeout. Server-side shard processing scales linearly with file entry - // count and can exceed the global read_timeout (120s) for large shards. + // count and can exceed the global read_timeout (300s) for large shards. #[cfg(not(target_family = "wasm"))] let client = self.shard_upload_http_client.clone(); diff --git a/xet_client/src/cas_client/retry_wrapper.rs b/xet_client/src/cas_client/retry_wrapper.rs index 9ce983084..ad43b26ff 100644 --- a/xet_client/src/cas_client/retry_wrapper.rs +++ b/xet_client/src/cas_client/retry_wrapper.rs @@ -31,6 +31,7 @@ pub struct RetryWrapper { no_retry_on_429: bool, retry_on_403: bool, expected_416: bool, + expected_404: bool, log_errors_as_info: bool, api_tag: &'static str, connection_permit: Option>, @@ -46,6 +47,7 @@ impl RetryWrapper { no_retry_on_429: false, retry_on_403: false, expected_416: false, + expected_404: false, log_errors_as_info: false, api_tag, connection_permit: None, @@ -77,6 +79,15 @@ impl RetryWrapper { self } + /// Mark 404 responses as expected (e.g. `query_dedup` cache miss). When set, + /// a 404 is still returned as a fatal (non-retried) error to the caller — which is + /// usually handled by the caller converting it to `Ok(None)` — but it is logged as + /// a cache miss instead of the misleading `"Fatal Error"`. + pub fn with_expected_404(mut self) -> Self { + self.expected_404 = true; + self + } + pub fn log_errors_as_info(mut self) -> Self { self.log_errors_as_info = true; self @@ -166,6 +177,9 @@ impl RetryWrapper { } else if e.status() == Some(StatusCode::RANGE_NOT_SATISFIABLE) && self.expected_416 { let cas_err = process_error("Reached end of reconstruction 416 (Range Not Satisfiable)", e, true); Err(RetryableReqwestError::FatalError(cas_err)) + } else if e.status() == Some(StatusCode::NOT_FOUND) && self.expected_404 { + let cas_err = process_error("Not Found (cache miss)", e, true); + Err(RetryableReqwestError::FatalError(cas_err)) } else { let cas_err = process_error("Fatal Error", e, false); Err(RetryableReqwestError::FatalError(cas_err)) @@ -857,6 +871,37 @@ mod tests { check_json_unexpected_eof_retry(&server).await; } + #[tokio::test] + async fn test_404_expected_is_fatal_and_not_retried() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/not_found")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let client = make_client(); + let counter = Arc::new(AtomicU32::new(0)); + let counter_ = counter.clone(); + + let result = connection_wrapper("test_404_expected_is_fatal_and_not_retried") + .with_max_attempts(3) + .with_expected_404() + .run(move || { + let url = format!("{}/not_found", server.uri()); + counter_.fetch_add(1, Ordering::Relaxed); + client.clone().get(&url).send() + }) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.status(), Some(StatusCode::NOT_FOUND)); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn test_403_no_retry_by_default() { let server = MockServer::start().await; diff --git a/xet_core_structures/src/metadata_shard/shard_file_manager.rs b/xet_core_structures/src/metadata_shard/shard_file_manager.rs index e77d05dd4..3a181fca1 100644 --- a/xet_core_structures/src/metadata_shard/shard_file_manager.rs +++ b/xet_core_structures/src/metadata_shard/shard_file_manager.rs @@ -256,12 +256,7 @@ impl ShardFileManager { s.verify_shard_integrity_debug_only(); // Make sure the shard is in the shard directory - debug_assert!( - s.path.starts_with(&self.shard_directory), - "{:?} not in {:?}", - &s.path, - &self.shard_directory - ); + debug_assert!(s.path.starts_with(&self.shard_directory), "{:?} not in {:?}", s.path, self.shard_directory); if self .shard_bookkeeper diff --git a/xet_core_structures/src/metadata_shard/shard_in_memory.rs b/xet_core_structures/src/metadata_shard/shard_in_memory.rs index acff71938..1ce9deffa 100644 --- a/xet_core_structures/src/metadata_shard/shard_in_memory.rs +++ b/xet_core_structures/src/metadata_shard/shard_in_memory.rs @@ -90,14 +90,14 @@ impl MDBInMemoryShard { pub fn recalculate_shard_size(&mut self) { // Calculate the size let mut num_bytes = 0u64; - for (_, xorb_block_contents) in self.xorb_content.iter() { + for xorb_block_contents in self.xorb_content.values() { num_bytes += xorb_block_contents.num_bytes(); // The xorb lookup table num_bytes += (size_of::() + size_of::()) as u64; } - for (_, file_info) in self.file_content.iter() { + for file_info in self.file_content.values() { num_bytes += file_info.num_bytes(); num_bytes += (size_of::() + size_of::()) as u64; } diff --git a/xet_core_structures/src/metadata_shard/streaming_shard.rs b/xet_core_structures/src/metadata_shard/streaming_shard.rs index dc9b37410..e531c5b13 100644 --- a/xet_core_structures/src/metadata_shard/streaming_shard.rs +++ b/xet_core_structures/src/metadata_shard/streaming_shard.rs @@ -185,9 +185,10 @@ impl MDBMinimalShard { let _ = MDBShardFileHeader::deserialize(reader)?; let mut file_info_views = Vec::::new(); + let mut seen_file_hashes = HashSet::new(); process_shard_file_info_section(reader, |fiv: MDBFileInfoView| { // register the offset here to the file entries - if include_files { + if include_files && seen_file_hashes.insert(fiv.file_hash()) { file_info_views.push(fiv); } Ok(()) @@ -232,11 +233,14 @@ impl MDBMinimalShard { let _ = MDBShardFileHeader::deserialize(&mut Cursor::new(&buf))?; let mut file_info_views = Vec::::new(); + let mut seen_file_hashes = HashSet::new(); process_shard_file_info_section_async(reader, |fiv: MDBFileInfoView| { // register the offset here to the file entries if include_files { file_callback(&fiv)?; - file_info_views.push(fiv); + if seen_file_hashes.insert(fiv.file_hash()) { + file_info_views.push(fiv); + } } Ok(()) }) @@ -507,17 +511,28 @@ mod tests { use rand::rngs::SmallRng; use rand::{RngExt, SeedableRng}; - use super::super::MDBShardInfo; - use super::super::file_structs::MDBFileInfo; + use super::super::file_structs::{FileDataSequenceHeader, MDBFileInfo}; use super::super::shard_file::test_routines::{ - convert_to_file, gen_random_shard, gen_random_shard_with_xorb_references, + convert_to_file, gen_random_file_info, gen_random_shard, gen_random_shard_with_xorb_references, }; use super::super::shard_in_memory::MDBInMemoryShard; - use super::super::xorb_structs::MDBXorbInfo; + use super::super::xorb_structs::{MDBXorbInfo, XorbChunkSequenceHeader}; + use super::super::{MDBShardFileHeader, MDBShardInfo}; use super::MDBMinimalShard; use crate::error::Result; use crate::merklehash::MerkleHash; + fn file_info_stream(file_infos: &[MDBFileInfo]) -> Vec { + let mut buffer = Vec::new(); + MDBShardFileHeader::default().serialize(&mut buffer).unwrap(); + for file_info in file_infos { + file_info.serialize(&mut buffer).unwrap(); + } + FileDataSequenceHeader::bookend().serialize(&mut buffer).unwrap(); + XorbChunkSequenceHeader::bookend().serialize(&mut buffer).unwrap(); + buffer + } + fn verify_serialization(min_shard: &MDBMinimalShard, mem_shard: &MDBInMemoryShard) -> Result<()> { for verification in [true, false] { // compute size, with verification if possible only @@ -665,6 +680,43 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_minimal_shard_deduplicates_file_infos_first_wins() { + let mut rng = rand::rngs::StdRng::seed_from_u64(7); + let first = gen_random_file_info(&mut rng, &2, false, false); + let mut duplicate = gen_random_file_info(&mut rng, &3, true, true); + duplicate.metadata.file_hash = first.metadata.file_hash; + + let buffer = file_info_stream(&[first.clone(), duplicate.clone()]); + + let min_shard = MDBMinimalShard::from_reader(&mut Cursor::new(&buffer), true, true).unwrap(); + assert_eq!(min_shard.num_files(), 1); + assert_eq!(MDBFileInfo::from(min_shard.file(0).unwrap()), first); + + let mut callback_file_infos = Vec::new(); + let min_shard_async = MDBMinimalShard::from_reader_async_with_custom_callbacks( + &mut &buffer[..], + true, + true, + |f| { + callback_file_infos.push(MDBFileInfo::from(f)); + Ok(()) + }, + |_| Ok(()), + ) + .await + .unwrap(); + + assert_eq!(min_shard, min_shard_async); + assert_eq!(callback_file_infos, vec![first.clone(), duplicate]); + + let mut reserialized = Vec::new(); + min_shard.serialize(&mut reserialized, false).unwrap(); + let shard_info = MDBShardInfo::load_from_reader(&mut Cursor::new(&reserialized)).unwrap(); + let file_infos = shard_info.read_all_file_info_sections(&mut Cursor::new(&reserialized)).unwrap(); + assert_eq!(file_infos, vec![first]); + } + #[tokio::test] async fn test_shards() -> Result<()> { let shard = gen_random_shard(0, &[], &[0], false, false)?; diff --git a/xet_core_structures/src/xorb_object/byte_grouping/compression_stats/collect_compression_stats.rs b/xet_core_structures/src/xorb_object/byte_grouping/compression_stats/collect_compression_stats.rs index 5dbc81e26..07099fa4c 100644 --- a/xet_core_structures/src/xorb_object/byte_grouping/compression_stats/collect_compression_stats.rs +++ b/xet_core_structures/src/xorb_object/byte_grouping/compression_stats/collect_compression_stats.rs @@ -187,8 +187,8 @@ async fn main() -> Result<(), Box> { let writer = Arc::new(Mutex::new(wtr)); - eprintln!("Output File: {}", &args.output); - eprintln!("Input Files: {:?}", &args.files); + eprintln!("Output File: {}", args.output); + eprintln!("Input Files: {:?}", args.files); let max_limiter = Arc::new(Semaphore::new(32 * 1024)); diff --git a/xet_core_structures/src/xorb_object/constants.rs b/xet_core_structures/src/xorb_object/constants.rs index b322326e8..2105f781e 100644 --- a/xet_core_structures/src/xorb_object/constants.rs +++ b/xet_core_structures/src/xorb_object/constants.rs @@ -27,3 +27,50 @@ lazy_static::lazy_static! { /// The maximum chunk size, calculated from the configurable constants above pub static ref MAX_CHUNK_SIZE: usize = (*TARGET_CHUNK_SIZE) * (*MAXIMUM_CHUNK_MULTIPLIER); } + +/// Given a list of chunk boundaries in a file and an arbitrary reference position, +/// returns the next stable chunk boundary at or after that position. +/// +/// `starting_position` may be any byte offset in the file; it does not need to +/// be an existing chunk boundary. The search starts at the first chunk boundary +/// `>= starting_position`. +/// +/// A stable chunk boundary is defined such that any possible changes in the data +/// before `starting_position` would produce the same chunk boundaries at the +/// stable boundary and later. The fixed data between `starting_position` and +/// the returned stable boundary is always sufficient to restore the chunker to +/// its original chunk boundaries. +/// +/// The stability condition requires two consecutive chunks after `starting_position`, +/// both with sizes in `[2 * min_chunk, max_chunk - min_chunk)`. The boundary +/// at the end of the second such chunk is the stable chunk boundary. +/// +/// The lower bound is `2 * min_chunk` rather than `min_chunk` (as used in +/// `find_partitions` in the chunking module) because this function operates on +/// existing chunk boundaries without data access, and cannot verify the absence +/// of hidden hash triggers in the `[c_k, c_k + min_chunk)` skip zone. A +/// shadow-zone trigger can advance a modified chunker by up to `min_chunk`, so +/// the next chunk must be at least `2 * min_chunk` to remain reachable. +/// +/// See `parallel chunking.lyx` for the full proof and `find_stable_start` in +/// `merkle_hash_subtree.rs` for the analogous construction in merkle hashing. +pub fn next_stable_chunk_boundary(starting_position: usize, chunk_boundaries: &[usize]) -> Option { + let minimum_chunk = *TARGET_CHUNK_SIZE / *MINIMUM_CHUNK_DIVISOR; + let maximum_chunk = *TARGET_CHUNK_SIZE * *MAXIMUM_CHUNK_MULTIPLIER; + + let start_idx = chunk_boundaries.partition_point(|&x| x < starting_position); + + for i in start_idx..chunk_boundaries.len().saturating_sub(2) { + let size_a = chunk_boundaries[i + 1] - chunk_boundaries[i]; + let size_b = chunk_boundaries[i + 2] - chunk_boundaries[i + 1]; + + if size_a >= 2 * minimum_chunk + && size_a < maximum_chunk - minimum_chunk + && size_b >= 2 * minimum_chunk + && size_b < maximum_chunk - minimum_chunk + { + return Some(chunk_boundaries[i + 2]); + } + } + None +} diff --git a/xet_core_structures/src/xorb_object/xorb_object_format.rs b/xet_core_structures/src/xorb_object/xorb_object_format.rs index 6bd6b6454..db22b2a43 100644 --- a/xet_core_structures/src/xorb_object/xorb_object_format.rs +++ b/xet_core_structures/src/xorb_object/xorb_object_format.rs @@ -18,10 +18,10 @@ use crate::metadata_shard::chunk_verification::range_hash_from_chunks; use crate::serialization_utils::*; pub type XorbObjectIdent = [u8; 7]; -pub(crate) const XORB_OBJECT_FORMAT_IDENT: XorbObjectIdent = [b'X', b'E', b'T', b'B', b'L', b'O', b'B']; +pub(crate) const XORB_OBJECT_FORMAT_IDENT: XorbObjectIdent = *b"XETBLOB"; pub(crate) const XORB_OBJECT_FORMAT_VERSION_V0: u8 = 0; -pub(crate) const XORB_OBJECT_FORMAT_IDENT_HASHES: XorbObjectIdent = [b'X', b'B', b'L', b'B', b'H', b'S', b'H']; -pub(crate) const XORB_OBJECT_FORMAT_IDENT_BOUNDARIES: XorbObjectIdent = [b'X', b'B', b'L', b'B', b'B', b'N', b'D']; +pub(crate) const XORB_OBJECT_FORMAT_IDENT_HASHES: XorbObjectIdent = *b"XBLBHSH"; +pub(crate) const XORB_OBJECT_FORMAT_IDENT_BOUNDARIES: XorbObjectIdent = *b"XBLBBND"; pub(crate) const XORB_OBJECT_FORMAT_VERSION: u8 = 1; pub(crate) const XORB_OBJECT_FORMAT_HASHES_VERSION: u8 = 0; diff --git a/xet_data/src/deduplication/chunking.rs b/xet_data/src/deduplication/chunking.rs index 399f1dafb..97f70026c 100644 --- a/xet_data/src/deduplication/chunking.rs +++ b/xet_data/src/deduplication/chunking.rs @@ -282,9 +282,15 @@ impl Chunker { /// partition_scan_bytes is the number of bytes to scan at each /// proposed partition boundary in search of a valid chunk. /// -/// Due to a known issue in how we do chunking, note that these -/// partitions are not 100% guaranteed to align. See the -/// parallel_chunking.pdf for details. +/// Partition alignment is guaranteed by the hash warmup fix: the +/// chunker feeds `min_chunk - 64 - 1` bytes before scanning for +/// boundaries, ensuring the gear hash window is fully warmed (purely +/// data-dependent) at all accepted trigger positions. This function +/// additionally verifies the absence of hidden triggers by re-chunking +/// with `min_chunk = 0`. See `parallel chunking.lyx` for the proof. +/// +/// For finding stable chunk boundaries from existing chunk boundaries (without +/// data access), see [`next_stable_chunk_boundary`]. pub fn find_partitions( reader: &mut R, file_size: usize, @@ -353,6 +359,11 @@ pub fn find_partitions( Ok(partitions) } +// Re-exported from xet_core_structures where the canonical implementation lives, +// so that downstream users of xet_data::deduplication::next_stable_chunk_boundary +// continue to work without a source change. +pub use xet_core_structures::xorb_object::constants::next_stable_chunk_boundary; + #[cfg(test)] mod tests { use std::collections::HashSet; diff --git a/xet_data/src/deduplication/mod.rs b/xet_data/src/deduplication/mod.rs index b7e9ebfef..f2614984b 100644 --- a/xet_data/src/deduplication/mod.rs +++ b/xet_data/src/deduplication/mod.rs @@ -5,7 +5,7 @@ mod defrag_prevention; mod file_deduplication; mod interface; -pub use chunking::{Chunker, find_partitions}; +pub use chunking::{Chunker, find_partitions, next_stable_chunk_boundary}; pub use data_aggregator::DataAggregator; pub use dedup_metrics::DeduplicationMetrics; pub use file_deduplication::FileDeduper; diff --git a/xet_data/src/deduplication/parallel chunking.lyx b/xet_data/src/deduplication/parallel chunking.lyx index 881cbafeb..d95427e03 100644 --- a/xet_data/src/deduplication/parallel chunking.lyx +++ b/xet_data/src/deduplication/parallel chunking.lyx @@ -827,9 +827,58 @@ Now, with this implementation, can we still perform parallel chunking? \end_layout \begin_layout Standard -I do not believe so. - Due to the hash disagreement, under adverserial settings it is possible - to construct two chunk sequences which will *never* align. + +\series bold +Update: +\series default + The implementation has been fixed. + Instead of skipping +\begin_inset Formula $m$ +\end_inset + + bytes (setting HashStreamStart = +\begin_inset Formula $i+m$ +\end_inset + +), the chunker now starts feeding the hash at +\begin_inset Formula $m-k-1$ +\end_inset + + bytes from the chunk start. + This ensures the hash window has been fed at least +\begin_inset Formula $k+1$ +\end_inset + + bytes by position +\begin_inset Formula $m$ +\end_inset + +, so the hash output at every accepted trigger position ( +\begin_inset Formula $\ge m$ +\end_inset + + from chunk start) depends only on the last +\begin_inset Formula $k$ +\end_inset + + bytes of data, independent of the chunk starting point. + Therefore +\begin_inset Formula $H(a_{1},b)=H(a_{2},b)$ +\end_inset + + for all +\begin_inset Formula $b$ +\end_inset + + where +\begin_inset Formula $b-a_{1}\ge m$ +\end_inset + + and +\begin_inset Formula $b-a_{2}\ge m$ +\end_inset + +, and the parallel chunking proof in Section 2 holds. \end_layout \end_body diff --git a/xet_data/src/deduplication/parallel chunking.pdf b/xet_data/src/deduplication/parallel chunking.pdf index 607ec3b75c5e3c9f8ce9b7627014968553875504..b6efd9ee2d6dd01e81cbc0c37b93532a8e2f69d6 100644 GIT binary patch delta 67414 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

9m`!&{$sc!>KG(pYww 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 U^a}0M()*@c;k- delta 65981 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

D-5?A;3U`ck=z)AChdFv{yMiYuwL21|G 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

wQu>aDJ1GQA0rc+Z01o+q)(HGXB~*n- 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 xwNCs83rQbaSMMQ{smoke{ = Vec::with_capacity(n_windows); + let mut uploaded: Vec = Vec::with_capacity(response.windows.len()); let mut buf = vec![0u8; STREAM_BLOCK_SIZE]; for window in response.windows.iter() { @@ -283,7 +278,7 @@ pub async fn upload_ranges( let last_window_at_end = trailing_gap.is_none(); let last_idx = uploaded.len() - 1; - let mut merge_seq: Vec = Vec::with_capacity(2 * n_windows + 1); + let mut merge_seq: Vec = Vec::with_capacity(2 * uploaded.len() + 1); for (i, (w, gap)) in uploaded.iter().zip(hash_ranges).enumerate() { if let Some(g) = gap { merge_seq.push(g); @@ -533,6 +528,7 @@ fn snap_to_segment_end(seg_byte_starts: &[u64], byte: u64) -> u64 { #[cfg(test)] mod tests { use std::io::Cursor; + use std::ops::Range; use std::path::Path; use std::sync::Arc; @@ -1311,6 +1307,135 @@ mod tests { .collect() } + #[derive(Clone, Debug)] + struct DeterministicRng { + state: u64, + } + + impl DeterministicRng { + fn new(seed: u64) -> Self { + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1); + self.state + } + + fn gen_range(&mut self, start: usize, end: usize) -> usize { + if end <= start { + return start; + } + start + (self.next_u64() as usize % (end - start)) + } + + fn gen_bytes(&mut self, len: usize) -> Vec { + (0..len).map(|_| (self.next_u64() >> 56) as u8).collect() + } + } + + #[derive(Clone, Debug)] + struct PlannedEdit { + original_range: Range, + replacement: Vec, + } + + fn build_random_non_overlapping_edits( + rng: &mut DeterministicRng, + original_len: usize, + max_edits: usize, + ) -> Vec { + if original_len == 0 { + let replacement_len = 1 + rng.gen_range(0, 8 * 1024); + return vec![PlannedEdit { + original_range: 0..0, + replacement: rng.gen_bytes(replacement_len), + }]; + } + + let target_edits = 1 + rng.gen_range(0, max_edits.max(1)); + let mut edits: Vec = Vec::with_capacity(target_edits); + let mut cursor = 0usize; + + while edits.len() < target_edits && cursor <= original_len { + let remaining = original_len - cursor; + let max_gap = remaining.min(64 * 1024); + let start = cursor + rng.gen_range(0, max_gap + 1); + + let (end, replacement_len) = if start == original_len { + (start, 1 + rng.gen_range(0, 32 * 1024)) + } else { + let op = rng.gen_range(0, 5); + let max_span = (original_len - start).clamp(1, 64 * 1024); + let span = 1 + rng.gen_range(0, max_span); + let end = start + span; + match op { + 0 => (start, 1 + rng.gen_range(0, 32 * 1024)), + 1 => (end, span), + 2 => (end, span + 1 + rng.gen_range(0, 16 * 1024)), + 3 => (end, rng.gen_range(0, span + 1)), + _ => (end, 0), + } + }; + + edits.push(PlannedEdit { + original_range: start..end, + replacement: rng.gen_bytes(replacement_len), + }); + cursor = if end > start { end } else { start.saturating_add(1) }; + } + + if edits.is_empty() { + let replacement_len = 1 + rng.gen_range(0, 32 * 1024); + edits.push(PlannedEdit { + original_range: original_len..original_len, + replacement: rng.gen_bytes(replacement_len), + }); + } + + for w in edits.windows(2) { + assert!(w[0].original_range.end <= w[1].original_range.start); + } + + edits + } + + fn apply_planned_edits(original: &[u8], edits: &[PlannedEdit]) -> Vec { + let removed: usize = edits.iter().map(|e| e.original_range.end - e.original_range.start).sum(); + let added: usize = edits.iter().map(|e| e.replacement.len()).sum(); + let mut out: Vec = Vec::with_capacity(original.len() + added.saturating_sub(removed)); + let mut cursor = 0usize; + + for edit in edits { + assert!(edit.original_range.start >= cursor); + out.extend_from_slice(&original[cursor..edit.original_range.start]); + out.extend_from_slice(&edit.replacement); + cursor = edit.original_range.end; + } + + out.extend_from_slice(&original[cursor..]); + out + } + + fn edits_to_dirty_inputs(edits: &[PlannedEdit]) -> Vec { + edits + .iter() + .map(|e| DirtyInput { + original_range: e.original_range.start as u64..e.original_range.end as u64, + new_length: e.replacement.len() as u64, + reader: Box::pin(Cursor::new(e.replacement.clone())), + }) + .collect() + } + + fn summarize_edits(edits: &[PlannedEdit]) -> String { + edits + .iter() + .map(|e| format!("[{}..{}, new_len={}]", e.original_range.start, e.original_range.end, e.replacement.len())) + .collect::>() + .join(", ") + } + async fn upload_file(config: &Arc, data: &[u8]) -> MerkleHash { let session = FileUploadSession::new(config.clone()).await.unwrap(); let (_id, mut cleaner) = session @@ -1807,4 +1932,174 @@ mod tests { ) .await; } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[ignore = "stress test"] + async fn test_stress_random_resize_sequences() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + for seed in 0..6u64 { + let mut rng = DeterministicRng::new(0x9E37_79B9_7F4A_7C15 ^ seed.wrapping_mul(0xD1B5_4A32_D192_ED03)); + let mut expected = random_data(10_000 + seed, 1_048_576 + (seed as usize * 91_117 % 262_144)); + let mut original_hash = upload_file(&config, &expected).await; + let mut original_size = expected.len() as u64; + + for round in 0..25usize { + let edits = build_random_non_overlapping_edits(&mut rng, expected.len(), 8); + let expected_next = apply_planned_edits(&expected, &edits); + let inputs = edits_to_dirty_inputs(&edits); + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs) + .await + .unwrap(); + let result_hash = MerkleHash::from_hex(result.hash()).unwrap(); + + assert_eq!( + result.file_size(), + Some(expected_next.len() as u64), + "seed={seed}, round={round}: size mismatch" + ); + let clean_hash = upload_file(&config, &expected_next).await; + assert_eq!(result.hash(), clean_hash.hex(), "seed={seed}, round={round}: hash mismatch"); + + let downloaded = download_file(&config, result_hash, expected_next.len() as u64).await; + assert_eq!(downloaded, expected_next, "seed={seed}, round={round}: content mismatch"); + + expected = expected_next; + original_hash = result_hash; + original_size = expected.len() as u64; + } + } + } + + #[cfg(not(feature = "smoke-test"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_regression_hash_matches_clean_upload_seed1_round17() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let seed = 1u64; + let mut rng = DeterministicRng::new(0x9E37_79B9_7F4A_7C15 ^ seed.wrapping_mul(0xD1B5_4A32_D192_ED03)); + let mut expected = random_data(10_000 + seed, 1_048_576 + (seed as usize * 91_117 % 262_144)); + let mut original_hash = upload_file(&config, &expected).await; + let mut original_size = expected.len() as u64; + + for round in 0..=17usize { + let edits = build_random_non_overlapping_edits(&mut rng, expected.len(), 8); + let edits_summary = summarize_edits(&edits); + let expected_next = apply_planned_edits(&expected, &edits); + let inputs = edits_to_dirty_inputs(&edits); + let result = upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs) + .await + .unwrap(); + let result_hash = MerkleHash::from_hex(result.hash()).unwrap(); + + let clean_hash = upload_file(&config, &expected_next).await; + assert_eq!( + result.hash(), + clean_hash.hex(), + "seed={seed}, round={round}: hash mismatch; original_size={original_size}, expected_size={}, edits={edits_summary}", + expected_next.len() + ); + + let downloaded = download_file(&config, result_hash, expected_next.len() as u64).await; + assert_eq!(downloaded, expected_next, "seed={seed}, round={round}: content mismatch"); + + expected = expected_next; + original_hash = result_hash; + original_size = expected.len() as u64; + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[ignore = "stress test"] + async fn test_stress_many_sparse_windows_single_call() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let original = random_data(13_337, 16 * 1024 * 1024); + let mut rng = DeterministicRng::new(0xA5A5_5A5A_0123_4567); + let mut edits: Vec = Vec::new(); + let stride = original.len() / 200; + let mut cursor = stride / 2; + + while edits.len() < 128 && cursor < original.len() { + let start = cursor; + let max_span = (original.len() - start).clamp(1, 1536); + let span = 128 + rng.gen_range(0, max_span); + let end = (start + span).min(original.len()); + let replacement_len = match rng.gen_range(0, 4) { + 0 => end - start, + 1 => (end - start) + 64 + rng.gen_range(0, 512), + 2 => rng.gen_range(0, end - start + 1), + _ => 0, + }; + edits.push(PlannedEdit { + original_range: start..end, + replacement: rng.gen_bytes(replacement_len), + }); + cursor = cursor.saturating_add(stride.max(1)); + } + + let expected = apply_planned_edits(&original, &edits); + let inputs = edits_to_dirty_inputs(&edits); + assert_edits(&config, &cas_client, &original, inputs, &expected).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + #[ignore = "stress test"] + async fn test_stress_parallel_random_resize_sequences() { + let server = LocalTestServerBuilder::new().start().await; + let base_dir = TempDir::new().unwrap(); + let config = test_config(server.http_endpoint(), base_dir.path()); + let cas_client: Arc = Arc::new(server); + + let mut handles = Vec::new(); + for worker in 0..8u64 { + let config = config.clone(); + let cas_client = cas_client.clone(); + handles.push(tokio::spawn(async move { + let mut rng = DeterministicRng::new(0xC0FF_EE00_1234_5678 ^ worker.wrapping_mul(0x94D0_49BB_1331_11EB)); + let mut expected = random_data(20_000 + worker, 786_432 + worker as usize * 17_321); + let mut original_hash = upload_file(&config, &expected).await; + let mut original_size = expected.len() as u64; + + for round in 0..18usize { + let edits = build_random_non_overlapping_edits(&mut rng, expected.len(), 6); + let expected_next = apply_planned_edits(&expected, &edits); + let inputs = edits_to_dirty_inputs(&edits); + let result = + upload_ranges(config.clone(), cas_client.clone(), original_hash, original_size, inputs) + .await + .unwrap(); + let result_hash = MerkleHash::from_hex(result.hash()).unwrap(); + + assert_eq!( + result.file_size(), + Some(expected_next.len() as u64), + "worker={worker}, round={round}: size mismatch" + ); + let clean_hash = upload_file(&config, &expected_next).await; + assert_eq!(result.hash(), clean_hash.hex(), "worker={worker}, round={round}: hash mismatch"); + + let downloaded = download_file(&config, result_hash, expected_next.len() as u64).await; + assert_eq!(downloaded, expected_next, "worker={worker}, round={round}: content mismatch"); + + expected = expected_next; + original_hash = result_hash; + original_size = expected.len() as u64; + } + })); + } + + for handle in handles { + handle.await.unwrap(); + } + } } diff --git a/xet_data/tests/test_stable_chunk_boundary_detection.rs b/xet_data/tests/test_stable_chunk_boundary_detection.rs new file mode 100644 index 000000000..8198f3aa7 --- /dev/null +++ b/xet_data/tests/test_stable_chunk_boundary_detection.rs @@ -0,0 +1,312 @@ +use std::collections::HashSet; + +use rand::rngs::StdRng; +use rand::{RngExt, SeedableRng}; +use xet_data::deduplication::constants::TARGET_CHUNK_SIZE; +use xet_data::deduplication::{Chunk, Chunker, next_stable_chunk_boundary}; +use xet_runtime::test_set_constants; + +test_set_constants! { + TARGET_CHUNK_SIZE = 1024; +} + +fn make_random_data(seed: u64, len: usize) -> Vec { + let mut rng = StdRng::seed_from_u64(seed); + let mut data = vec![0u8; len]; + rng.fill(&mut data[..]); + data +} + +fn chunk_data(data: &[u8]) -> Vec { + let mut chunker = Chunker::default(); + chunker.next_block(data, true) +} + +fn get_chunk_boundaries(chunks: &[Chunk]) -> Vec { + chunks + .iter() + .scan(0usize, |pos, c| { + *pos += c.data.len(); + Some(*pos) + }) + .collect() +} + +fn verify_alignment( + original_boundaries: &[usize], + new_boundaries: &[usize], + stable: usize, + file_size: usize, + starting_position: usize, + mutation_seed: u64, +) { + let orig_set: HashSet = original_boundaries.iter().copied().collect(); + let new_set: HashSet = new_boundaries.iter().copied().collect(); + + for &oc in original_boundaries { + if oc >= stable && oc < file_size { + assert!( + new_set.contains(&oc), + "Original chunk boundary {oc} (>= stable {stable}) missing from new chunk boundaries. \ + starting_position={starting_position}, mutation_seed={mutation_seed}" + ); + } + } + + for &nc in new_boundaries { + if nc >= stable && nc < file_size { + assert!( + orig_set.contains(&nc), + "New chunk boundary {nc} (>= stable {stable}) not in original chunk boundaries. \ + starting_position={starting_position}, mutation_seed={mutation_seed}" + ); + } + } +} + +/// For a given data buffer, exercise `next_stable_chunk_boundary` at random +/// starting positions across the full data range with random mutations. +fn stress_test_stable_chunk_boundaries(data: &[u8], seed: u64, num_positions: usize, num_mutations: u64) { + let file_size = data.len(); + let chunks = chunk_data(data); + let chunk_boundaries = get_chunk_boundaries(&chunks); + + assert!( + chunk_boundaries.len() > 10, + "Need enough chunks for meaningful testing, got {}", + chunk_boundaries.len() + ); + + let mut rng = StdRng::seed_from_u64(seed); + let mut tested_stable = 0u64; + + for trial in 0..num_positions { + let starting_position = rng.random_range(1..file_size); + + let stable = match next_stable_chunk_boundary(starting_position, &chunk_boundaries) { + Some(s) => s, + None => continue, + }; + + assert!( + chunk_boundaries.contains(&stable), + "Stable chunk boundary {stable} is not a member of original chunk boundaries" + ); + assert!( + stable >= starting_position, + "Stable chunk boundary {stable} must be at or after starting_position {starting_position}" + ); + + tested_stable += 1; + + for mutation_seed in 0..num_mutations { + let combined_seed = (trial as u64) * 10000 + mutation_seed + 1; + let mut modified = data.to_vec(); + let mut mrng = StdRng::seed_from_u64(combined_seed); + mrng.fill(&mut modified[..starting_position]); + + let new_chunks = chunk_data(&modified); + let new_boundaries = get_chunk_boundaries(&new_chunks); + + verify_alignment(&chunk_boundaries, &new_boundaries, stable, file_size, starting_position, combined_seed); + } + } + + let min_expected = (num_positions / 4).max(1); + assert!( + tested_stable >= min_expected as u64, + "Too few starting positions had stable chunk boundaries: {tested_stable} (expected >= {min_expected})" + ); +} + +#[test] +fn test_stable_chunk_boundary_edge_cases() { + let data = make_random_data(42, 50_000); + let chunks = chunk_data(&data); + let chunk_boundaries = get_chunk_boundaries(&chunks); + + // starting_position at 0: should still return a valid point + let stable_0 = next_stable_chunk_boundary(0, &chunk_boundaries); + if let Some(s) = stable_0 { + assert!(chunk_boundaries.contains(&s)); + } + + // starting_position past all chunk boundaries: should return None + let past_end = *chunk_boundaries.last().unwrap() + 1; + assert!(next_stable_chunk_boundary(past_end, &chunk_boundaries).is_none()); + + // starting_position near end with too few remaining points + if chunk_boundaries.len() >= 2 { + let near_end = chunk_boundaries[chunk_boundaries.len() - 2]; + assert!(next_stable_chunk_boundary(near_end + 1, &chunk_boundaries).is_none()); + } + + // Degenerate inputs + assert!(next_stable_chunk_boundary(0, &[]).is_none()); + assert!(next_stable_chunk_boundary(0, &[100]).is_none()); + assert!(next_stable_chunk_boundary(0, &[100, 200]).is_none()); +} + +#[test] +fn test_stable_chunk_boundary_with_constant_data() { + // Constant data produces max-chunk-sized chunks (forced boundaries). + // With target=1024: max_chunk=2048, min_chunk=128, so max-min=1920. + // Forced boundaries at size 2048 fail the upper bound check of < 1920. + let data = vec![0u8; 50_000]; + let chunks = chunk_data(&data); + let chunk_boundaries = get_chunk_boundaries(&chunks); + + let stable = next_stable_chunk_boundary(0, &chunk_boundaries); + assert!(stable.is_none(), "Constant data should have no stable chunk boundary (all forced cuts)"); +} + +#[test] +fn test_stable_chunk_boundary_smoke_stress() { + let data = make_random_data(42, 50_000); + stress_test_stable_chunk_boundaries(&data, 42, 5, 5); +} + +#[test] +fn test_stable_chunk_boundary_smoke_varied_seeds() { + for seed in [1, 7, 255] { + let data = make_random_data(seed, 50_000); + stress_test_stable_chunk_boundaries(&data, seed + 77, 5, 5); + } +} + +#[cfg(not(feature = "smoke-test"))] +#[test] +fn test_stable_chunk_boundary_stress() { + let data = make_random_data(42, 256_000); + stress_test_stable_chunk_boundaries(&data, 42, 100, 20); +} + +#[cfg(not(feature = "smoke-test"))] +#[test] +fn test_stable_chunk_boundary_stress_varied_seeds() { + for seed in [1, 7, 13, 100, 255, 1024, 42424, 999999] { + let data = make_random_data(seed, 100_000); + stress_test_stable_chunk_boundaries(&data, seed + 77, 50, 20); + } +} + +#[cfg(not(feature = "smoke-test"))] +#[test] +fn test_stable_chunk_boundary_mutation_types() { + let data = make_random_data(42, 100_000); + let chunks = chunk_data(&data); + let chunk_boundaries = get_chunk_boundaries(&chunks); + let file_size = data.len(); + + let mid_idx = chunk_boundaries.len() / 2; + let starting_position = chunk_boundaries[mid_idx]; + + let stable = match next_stable_chunk_boundary(starting_position, &chunk_boundaries) { + Some(s) => s, + None => return, + }; + + // Zero-fill + { + let mut modified = data.to_vec(); + modified[..starting_position].fill(0); + let new_boundaries = get_chunk_boundaries(&chunk_data(&modified)); + verify_alignment(&chunk_boundaries, &new_boundaries, stable, file_size, starting_position, 0); + } + + // 0xFF-fill + { + let mut modified = data.to_vec(); + modified[..starting_position].fill(0xFF); + let new_boundaries = get_chunk_boundaries(&chunk_data(&modified)); + verify_alignment(&chunk_boundaries, &new_boundaries, stable, file_size, starting_position, 1); + } + + // Reverse the prefix + { + let mut modified = data.to_vec(); + modified[..starting_position].reverse(); + let new_boundaries = get_chunk_boundaries(&chunk_data(&modified)); + verify_alignment(&chunk_boundaries, &new_boundaries, stable, file_size, starting_position, 2); + } + + // XOR with a pattern + { + let mut modified = data.to_vec(); + for (i, byte) in modified[..starting_position].iter_mut().enumerate() { + *byte ^= (i & 0xFF) as u8; + } + let new_boundaries = get_chunk_boundaries(&chunk_data(&modified)); + verify_alignment(&chunk_boundaries, &new_boundaries, stable, file_size, starting_position, 3); + } + + // Many different random fills + for seed in 0..200 { + let mut modified = data.to_vec(); + let mut rng = StdRng::seed_from_u64(seed + 5000); + rng.fill(&mut modified[..starting_position]); + let new_boundaries = get_chunk_boundaries(&chunk_data(&modified)); + verify_alignment(&chunk_boundaries, &new_boundaries, stable, file_size, starting_position, seed + 5000); + } +} + +#[cfg(not(feature = "smoke-test"))] +#[test] +fn test_stable_chunk_boundary_is_tight() { + // Verify the stable chunk boundary is actually needed: the chunk boundary + // just before stable should NOT always be stable (there should exist some + // mutation that breaks it). + let data = make_random_data(42, 256_000); + let chunks = chunk_data(&data); + let chunk_boundaries = get_chunk_boundaries(&chunks); + + let mut found_non_stable_predecessor = false; + + for &starting_position in chunk_boundaries.iter().take(chunk_boundaries.len() / 2) { + if starting_position == 0 { + continue; + } + + let stable = match next_stable_chunk_boundary(starting_position, &chunk_boundaries) { + Some(s) => s, + None => continue, + }; + + let stable_idx = chunk_boundaries.iter().position(|&x| x == stable).unwrap(); + if stable_idx == 0 { + continue; + } + let predecessor = chunk_boundaries[stable_idx - 1]; + if predecessor <= starting_position { + continue; + } + + let orig_set: HashSet = chunk_boundaries.iter().copied().collect(); + for seed in 0..200 { + let mut modified = data.to_vec(); + let mut rng = StdRng::seed_from_u64(seed + 90000); + rng.fill(&mut modified[..starting_position]); + let new_boundaries = get_chunk_boundaries(&chunk_data(&modified)); + let new_set: HashSet = new_boundaries.iter().copied().collect(); + + if !new_set.contains(&predecessor) + || new_boundaries + .iter() + .any(|&nc| nc >= predecessor && nc < stable && !orig_set.contains(&nc)) + { + found_non_stable_predecessor = true; + break; + } + } + + if found_non_stable_predecessor { + break; + } + } + + assert!( + found_non_stable_predecessor, + "Could not find any case where the predecessor of a stable chunk boundary was actually unstable. \ + This suggests the stability condition may be too conservative." + ); +} diff --git a/xet_pkg/src/xet_session/file_download_group.rs b/xet_pkg/src/xet_session/file_download_group.rs index d78310197..c8f70d4d7 100644 --- a/xet_pkg/src/xet_session/file_download_group.rs +++ b/xet_pkg/src/xet_session/file_download_group.rs @@ -178,7 +178,7 @@ impl XetFileDownloadGroup { pub fn abort(&self) -> Result<(), XetError> { info!(group_id = %self.id(), "Download group abort"); self.task_runtime.cancel_subtree()?; - for (_tracking_id, handle) in self.inner.active_tasks.read()?.iter() { + for handle in self.inner.active_tasks.read()?.values() { handle.cancel(); } Ok(()) diff --git a/xet_runtime/src/config/groups/client.rs b/xet_runtime/src/config/groups/client.rs index 4ded6fe96..f42b8ae4c 100644 --- a/xet_runtime/src/config/groups/client.rs +++ b/xet_runtime/src/config/groups/client.rs @@ -54,10 +54,10 @@ crate::config_group!({ /// transfers to complete. If no data is received for this duration, the connection /// is considered stalled and will timeout. /// - /// The default value is 120 seconds. + /// The default value is 300 seconds. /// /// Use the environment variable `HF_XET_CLIENT_READ_TIMEOUT` to set this value. - ref read_timeout: Duration = Duration::from_secs(120); + ref read_timeout: Duration = Duration::from_secs(300); /// Send a report of a successful partial upload every 512kb. /// diff --git a/xet_runtime/src/file_utils/safe_file_creator.rs b/xet_runtime/src/file_utils/safe_file_creator.rs index 85cb81d90..3bb7b15c7 100644 --- a/xet_runtime/src/file_utils/safe_file_creator.rs +++ b/xet_runtime/src/file_utils/safe_file_creator.rs @@ -138,7 +138,7 @@ impl SafeFileCreator { Some(wr) => Ok(wr), None => Err(io::Error::new( io::ErrorKind::BrokenPipe, - format!("Writing to {:?} already completed.", &self.dest_path), + format!("Writing to {:?} already completed.", self.dest_path), )), } } @@ -163,7 +163,7 @@ impl Seek for SafeFileCreator { impl Drop for SafeFileCreator { fn drop(&mut self) { if let Err(e) = self.close() { - eprintln!("Error: Failed to close writer for {:?}: {}", &self.dest_path, e); + eprintln!("Error: Failed to close writer for {:?}: {}", self.dest_path, e); } } } diff --git a/xet_runtime/src/utils/configuration_utils.rs b/xet_runtime/src/utils/configuration_utils.rs index 123dfbf99..b0f860bbb 100644 --- a/xet_runtime/src/utils/configuration_utils.rs +++ b/xet_runtime/src/utils/configuration_utils.rs @@ -229,7 +229,7 @@ macro_rules! test_set_constants { )+) => { use $crate::configuration_utils::ctor_reexport as ctor; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn set_constants_on_load() { $( let val = $val; @@ -300,7 +300,7 @@ macro_rules! test_set_config { )+) => { use $crate::configuration_utils::ctor_reexport as config_ctor; - #[config_ctor::ctor] + #[config_ctor::ctor(unsafe)] fn set_config_on_load() { $( let group_name_upper = stringify!($group_name).to_uppercase();