Skip to content

Commit 97062e6

Browse files
committed
Tests: SMB concurrent 100-way copy cross-contamination guard
- New `smb_integration_copy_100_unique_files_no_cross_contamination` in `smb.rs` — 100 SMB source files with unique `blake3(b"cmdr-fix8-" || i).repeat(320)` content (10 KB each), copied concurrently via `copy_volumes_with_progress` to a local `TempDir`, then blake3-verified per index. - Catches buffer reuse across tasks, wrong-buffer-to-wrong-path routing, races in Fix 2's split session (`Arc<Mutex<Option<SmbClient>>>` + `Arc<RwLock<Option<Arc<Tree>>>>`), and cross-MessageId wire demux mistakes on cloned `Connection`s — none of which identical-content tests can see. - Drives the same code path production uses (`copy_volumes_with_progress`, called by `copy_between_volumes`), so `FuturesUnordered` + Fix 2 split session + Fix 3 compound fast-path + Fix 4 pipelined scan all execute together. - Per-index assertions with actionable mismatch output: path, expected/actual hashes, bytes-at-first-diff. No aggregate-only checks that would hide a destination-swap. - Exposed `copy_volumes_with_progress` and `CollectorEventSink` as `pub(crate)` under `#[cfg(test)]` so cross-module integration tests can drive the real pipeline without a Tauri runtime. - Wall-clock: ~0.4 s per run on Docker SMB guest container.
1 parent f948731 commit 97062e6

3 files changed

Lines changed: 213 additions & 1 deletion

File tree

apps/desktop/src-tauri/src/file_system/volume/smb.rs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2939,4 +2939,200 @@ mod tests {
29392939

29402940
ensure_clean(&vol, &dir).await;
29412941
}
2942+
2943+
/// Cross-task content integrity: 100 concurrent SMB → local copies, each file
2944+
/// with unique deterministic content. After the batch completes, every
2945+
/// destination's blake3 hash must match the hash of the source it claims to
2946+
/// come from — catches buffer reuse across tasks, wrong-buffer-to-wrong-path
2947+
/// routing, races in the `Arc<Mutex<Option<SmbClient>>>` +
2948+
/// `Arc<RwLock<Option<Arc<Tree>>>>` split-session (Fix 2), and
2949+
/// cross-MessageId wire demux mistakes on cloned `Connection`s.
2950+
///
2951+
/// Identical-content tests can't see any of these — every file would hash
2952+
/// the same, so a "swapped slice mid-file" or "task B's buffer landed under
2953+
/// task A's path" bug would pass trivially. Unique per-file content makes
2954+
/// any cross-contamination flip at least one destination's hash.
2955+
///
2956+
/// Runs the real copy pipeline (`copy_volumes_with_progress` — the same
2957+
/// function `copy_between_volumes` calls) so `FuturesUnordered` + Fix 2's
2958+
/// split session + Fix 3's compound fast-path + Fix 4's pipelined scan all
2959+
/// execute together, the way a user's "copy 100 files" action does.
2960+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2961+
#[ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)"]
2962+
async fn smb_integration_copy_100_unique_files_no_cross_contamination() {
2963+
use crate::file_system::write_operations::{
2964+
CollectorEventSink, VolumeCopyConfig, WriteOperationState, copy_volumes_with_progress,
2965+
};
2966+
use std::sync::atomic::AtomicU8;
2967+
use std::time::{Duration, Instant};
2968+
2969+
// Content scheme: `blake3(b"cmdr-fix8-" || index_le) .as_bytes() repeated 320 times`
2970+
// = 10_240 bytes per file, truly unique per index, every byte position varies
2971+
// between files. Any cross-task slice swap — even a 32-byte block in the
2972+
// middle of one file coming from a neighbor's buffer — flips blake3.
2973+
// 10 KB keeps fixture setup cheap and stays inside the SMB compound
2974+
// fast-path (Fix 3) so we're exercising it, not the streaming fallback.
2975+
fn expected_content(index: usize) -> Vec<u8> {
2976+
let mut seed = Vec::with_capacity(10 + 8);
2977+
seed.extend_from_slice(b"cmdr-fix8-");
2978+
seed.extend_from_slice(&(index as u64).to_le_bytes());
2979+
let block = *blake3::hash(&seed).as_bytes(); // 32 bytes
2980+
let mut out = Vec::with_capacity(32 * 320);
2981+
for _ in 0..320 {
2982+
out.extend_from_slice(&block);
2983+
}
2984+
out
2985+
}
2986+
2987+
const FILE_COUNT: usize = 100;
2988+
2989+
// Hold the concrete `SmbVolume` for `ensure_clean` (which takes
2990+
// `&SmbVolume`) and clone an `Arc<dyn Volume>` view of the same
2991+
// session for the copy pipeline.
2992+
let smb_vol = Arc::new(make_docker_volume().await);
2993+
let src_dir = test_dir_name();
2994+
ensure_clean(&smb_vol, &src_dir).await;
2995+
smb_vol.create_directory(Path::new(&src_dir)).await.unwrap();
2996+
let vol: Arc<dyn Volume> = smb_vol.clone();
2997+
2998+
// Fixture: create 100 files on the SMB source, serially. Parallel
2999+
// `create_file` on a single SMB session wouldn't speed this up —
3000+
// creates are 1 RTT each — and keeping setup simple keeps any bug
3001+
// the test catches unambiguously a read/copy-path bug, not a
3002+
// write-path races-with-itself bug.
3003+
let fixture_start = Instant::now();
3004+
let mut source_paths: Vec<PathBuf> = Vec::with_capacity(FILE_COUNT);
3005+
for i in 0..FILE_COUNT {
3006+
let name = format!("f_{:03}.bin", i);
3007+
let smb_path = format!("{}/{}", src_dir, name);
3008+
vol.create_file(Path::new(&smb_path), &expected_content(i))
3009+
.await
3010+
.unwrap();
3011+
source_paths.push(PathBuf::from(smb_path));
3012+
}
3013+
log::info!(
3014+
"smb_integration_copy_100_unique_files: fixture setup took {:?}",
3015+
fixture_start.elapsed()
3016+
);
3017+
3018+
// Destination: local TempDir wrapped in a LocalPosixVolume. We feed the
3019+
// copy pipeline the same way production does (SMB volume → Local
3020+
// volume → `copy_volumes_with_progress`). `dest_path` is "/" relative to
3021+
// the local volume root — i.e. the TempDir itself.
3022+
let local_dir = tempfile::TempDir::new().expect("create TempDir");
3023+
let dest_vol: Arc<dyn Volume> = Arc::new(crate::file_system::volume::LocalPosixVolume::new(
3024+
"dest",
3025+
local_dir.path().to_path_buf(),
3026+
));
3027+
3028+
let state = Arc::new(WriteOperationState {
3029+
intent: Arc::new(AtomicU8::new(0)),
3030+
progress_interval: Duration::from_millis(200),
3031+
conflict_resolution_tx: std::sync::Mutex::new(None),
3032+
});
3033+
let events = CollectorEventSink::new();
3034+
let config = VolumeCopyConfig::default();
3035+
3036+
let copy_start = Instant::now();
3037+
let result = copy_volumes_with_progress(
3038+
&events,
3039+
"test-op-100-unique",
3040+
&state,
3041+
Arc::clone(&vol),
3042+
&source_paths,
3043+
Arc::clone(&dest_vol),
3044+
Path::new("/"),
3045+
&config,
3046+
)
3047+
.await;
3048+
log::info!(
3049+
"smb_integration_copy_100_unique_files: copy pipeline took {:?}",
3050+
copy_start.elapsed()
3051+
);
3052+
assert!(result.is_ok(), "copy should succeed: {:?}", result);
3053+
3054+
// Count landed files — cheap aggregate sanity check before per-index
3055+
// verification. A cross-contamination bug that swapped two destinations
3056+
// would still show 100 files here, so this is not the real check.
3057+
let entries = std::fs::read_dir(local_dir.path())
3058+
.expect("read dest dir")
3059+
.filter_map(|e| e.ok())
3060+
.count();
3061+
assert_eq!(entries, FILE_COUNT, "expected {} files at destination", FILE_COUNT);
3062+
3063+
// Per-index integrity: for each source index, read its destination file
3064+
// and compare blake3 against the expected hash derived from the same
3065+
// index. Assert each one individually so a swap of two destinations
3066+
// fails loudly with both offending indices, not a vague aggregate.
3067+
let mut mismatches: Vec<String> = Vec::new();
3068+
for i in 0..FILE_COUNT {
3069+
let name = format!("f_{:03}.bin", i);
3070+
let dest_path = local_dir.path().join(&name);
3071+
let actual_bytes = match std::fs::read(&dest_path) {
3072+
Ok(b) => b,
3073+
Err(e) => {
3074+
mismatches.push(format!("{}: couldn't read destination: {}", name, e));
3075+
continue;
3076+
}
3077+
};
3078+
let expected_bytes = expected_content(i);
3079+
let expected_hash = hash_bytes(&expected_bytes);
3080+
let actual_hash = hash_bytes(&actual_bytes);
3081+
if actual_hash != expected_hash {
3082+
// Find the first diff position and a small slice of context —
3083+
// a 10 KB diff dump would drown the terminal on any failure.
3084+
let first_diff = expected_bytes.iter().zip(actual_bytes.iter()).position(|(a, b)| a != b);
3085+
let diff_detail = match first_diff {
3086+
Some(pos) => {
3087+
let end_exp = pos.saturating_add(16).min(expected_bytes.len());
3088+
let end_act = pos.saturating_add(16).min(actual_bytes.len());
3089+
format!(
3090+
"first diff at byte {}: expected {:02x?}, got {:02x?}",
3091+
pos,
3092+
&expected_bytes[pos..end_exp],
3093+
&actual_bytes[pos..end_act]
3094+
)
3095+
}
3096+
None => {
3097+
// Same bytes but different length (hashes differ so
3098+
// there must be a difference somewhere).
3099+
format!(
3100+
"byte-for-byte equal in overlap but lengths differ: expected {}, got {}",
3101+
expected_bytes.len(),
3102+
actual_bytes.len()
3103+
)
3104+
}
3105+
};
3106+
mismatches.push(format!(
3107+
"{}: expected blake3 {} ({} bytes), got blake3 {} ({} bytes); {}",
3108+
name,
3109+
hex_of(&expected_hash),
3110+
expected_bytes.len(),
3111+
hex_of(&actual_hash),
3112+
actual_bytes.len(),
3113+
diff_detail,
3114+
));
3115+
}
3116+
}
3117+
assert!(
3118+
mismatches.is_empty(),
3119+
"{} of {} destinations failed content check:\n - {}",
3120+
mismatches.len(),
3121+
FILE_COUNT,
3122+
mismatches.join("\n - "),
3123+
);
3124+
3125+
// Cleanup the SMB source. The TempDir cleans itself on drop.
3126+
ensure_clean(&smb_vol, &src_dir).await;
3127+
}
3128+
3129+
/// Hex formatter for blake3 hashes in failure messages. Avoids a hex-crate
3130+
/// dep just for test diagnostics.
3131+
fn hex_of(bytes: &[u8; 32]) -> String {
3132+
let mut s = String::with_capacity(64);
3133+
for b in bytes {
3134+
s.push_str(&format!("{:02x}", b));
3135+
}
3136+
s
3137+
}
29423138
}

apps/desktop/src-tauri/src/file_system/write_operations/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ pub(crate) use helpers::{
8080
};
8181
#[cfg(test)]
8282
pub(crate) use state::{CopyTransaction, OperationIntent, WriteOperationState, is_cancelled, load_intent};
83+
// Exposed for cross-module integration tests (for example the SMB
84+
// concurrent-copy cross-contamination test in
85+
// `file_system::volume::smb`) that drive `copy_volumes_with_progress`
86+
// directly against a real SMB backend instead of the full Tauri path.
87+
#[cfg(test)]
88+
#[allow(unused_imports, reason = "Used by SMB integration tests in file_system::volume::smb")]
89+
pub(crate) use types::CollectorEventSink;
90+
#[cfg(test)]
91+
#[allow(unused_imports, reason = "Used by SMB integration tests in file_system::volume::smb")]
92+
pub(crate) use volume_copy::copy_volumes_with_progress;
8393

8494
// Re-export volume copy types and functions
8595
pub use types::{VolumeCopyConfig, VolumeCopyScanResult};

apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,17 @@ pub async fn scan_for_volume_copy(
284284
}
285285

286286
/// Internal function that performs the actual copy with progress reporting.
287+
///
288+
/// Exposed as `pub(crate)` under `cfg(test)` so integration tests in sibling
289+
/// modules (for example the SMB concurrent-copy cross-contamination test in
290+
/// `volume/smb.rs`) can drive the real copy pipeline with a
291+
/// `CollectorEventSink` instead of spinning up a full Tauri app. In
292+
/// production, the only caller is `copy_between_volumes` in this file.
287293
#[allow(
288294
clippy::too_many_arguments,
289295
reason = "Volume copy requires passing multiple context parameters"
290296
)]
291-
async fn copy_volumes_with_progress(
297+
pub(crate) async fn copy_volumes_with_progress(
292298
events: &dyn OperationEventSink,
293299
operation_id: &str,
294300
state: &Arc<WriteOperationState>,

0 commit comments

Comments
 (0)