| 664 |
664 |
|
|
| 665 |
665 |
|
std::fs::remove_file(&file).ok();
|
| 666 |
666 |
|
}
|
|
667 |
+ |
|
|
668 |
+ |
// ── Part-boundary arithmetic ──
|
|
669 |
+ |
//
|
|
670 |
+ |
// The part plan is arithmetic on the ciphertext length, and the cases that break
|
|
671 |
+ |
// it are the exact multiples and their two neighbours: a ciphertext of exactly N
|
|
672 |
+ |
// parts, one byte short of N parts, and one byte over. Every fixture above sits
|
|
673 |
+ |
// mid-part, so none of them separates `div_ceil` from a truncating divide, nor a
|
|
674 |
+ |
// final part sized `cipher_len - part_size * (n - 1)` from one sized `part_size`.
|
|
675 |
+ |
|
|
676 |
+ |
/// A plaintext length whose v3 ciphertext is exactly `n * part_size`, plus that
|
|
677 |
+ |
/// part size. `approx` grows by at most `n - 1` bytes to reach divisibility,
|
|
678 |
+ |
/// which cannot change the chunk count for a length that is not itself on a
|
|
679 |
+ |
/// chunk boundary.
|
|
680 |
+ |
fn exact_part_multiple(n: usize, approx: usize) -> (usize, usize) {
|
|
681 |
+ |
let cipher = synckit_client::crypto::blob_encrypted_len(approx);
|
|
682 |
+ |
let plaintext_len = approx + (n - cipher % n) % n;
|
|
683 |
+ |
let cipher = synckit_client::crypto::blob_encrypted_len(plaintext_len);
|
|
684 |
+ |
assert_eq!(cipher % n, 0, "the fixture must land on a part boundary");
|
|
685 |
+ |
(plaintext_len, cipher / n)
|
|
686 |
+ |
}
|
|
687 |
+ |
|
|
688 |
+ |
/// Upload a blob whose ciphertext is `n * part_size + delta` bytes and check the
|
|
689 |
+ |
/// whole plan: how many parts were requested, how long each PUT was, and that
|
|
690 |
+ |
/// the parts concatenate back into a blob that opens.
|
|
691 |
+ |
async fn boundary_upload(n: usize, approx: usize, delta: isize) {
|
|
692 |
+ |
let (exact_len, part_size) = exact_part_multiple(n, approx);
|
|
693 |
+ |
let plaintext_len = exact_len.checked_add_signed(delta).unwrap();
|
|
694 |
+ |
|
|
695 |
+ |
let plaintext: Vec<u8> = (0..plaintext_len).map(|i| i as u8).collect();
|
|
696 |
+ |
let hash = hex::encode(sha2::Sha256::digest(&plaintext));
|
|
697 |
+ |
let file = temp_blob("boundary.bin", &plaintext);
|
|
698 |
+ |
let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext_len);
|
|
699 |
+ |
|
|
700 |
+ |
// The three cases differ in exactly the way the arithmetic has to notice:
|
|
701 |
+ |
// a byte over spills into an extra part carrying a single byte.
|
|
702 |
+ |
let expected_parts = if delta > 0 { n + 1 } else { n };
|
|
703 |
+ |
assert_eq!(
|
|
704 |
+ |
cipher_len.div_ceil(part_size),
|
|
705 |
+ |
expected_parts,
|
|
706 |
+ |
"fixture geometry: n={n} delta={delta}"
|
|
707 |
+ |
);
|
|
708 |
+ |
|
|
709 |
+ |
let kit = MockKit::start().await;
|
|
710 |
+ |
let (client, key) = kit.keyed();
|
|
711 |
+ |
let planned = mount_session(&kit, cipher_len, part_size).await;
|
|
712 |
+ |
assert_eq!(planned as usize, expected_parts);
|
|
713 |
+ |
|
|
714 |
+ |
client.blob_upload_streaming(&hash, &file).await.unwrap();
|
|
715 |
+ |
|
|
716 |
+ |
let puts = kit.requests_to(PART_PUT_PATH).await;
|
|
717 |
+ |
assert_eq!(
|
|
718 |
+ |
puts.len(),
|
|
719 |
+ |
expected_parts,
|
|
720 |
+ |
"one PUT per planned part: n={n} delta={delta}"
|
|
721 |
+ |
);
|
|
722 |
+ |
for (i, put) in puts.iter().enumerate() {
|
|
723 |
+ |
let expected = if i + 1 == expected_parts {
|
|
724 |
+ |
cipher_len - part_size * (expected_parts - 1)
|
|
725 |
+ |
} else {
|
|
726 |
+ |
part_size
|
|
727 |
+ |
};
|
|
728 |
+ |
assert_eq!(
|
|
729 |
+ |
put.body.len(),
|
|
730 |
+ |
expected,
|
|
731 |
+ |
"n={n} delta={delta} part {} length",
|
|
732 |
+ |
i + 1
|
|
733 |
+ |
);
|
|
734 |
+ |
}
|
|
735 |
+ |
|
|
736 |
+ |
let assembled: Vec<u8> = puts.iter().flat_map(|r| r.body.clone()).collect();
|
|
737 |
+ |
assert_eq!(assembled.len(), cipher_len, "n={n} delta={delta}");
|
|
738 |
+ |
assert_eq!(
|
|
739 |
+ |
synckit_client::crypto::decrypt_blob_chunked(&assembled, &key, &hash).unwrap(),
|
|
740 |
+ |
plaintext,
|
|
741 |
+ |
"n={n} delta={delta}: the parts must reassemble into the original"
|
|
742 |
+ |
);
|
|
743 |
+ |
|
|
744 |
+ |
std::fs::remove_file(&file).ok();
|
|
745 |
+ |
}
|
|
746 |
+ |
|
|
747 |
+ |
#[tokio::test]
|
|
748 |
+ |
async fn two_part_boundaries_are_planned_and_sent_exactly() {
|
|
749 |
+ |
// One sealed chunk cut into two parts.
|
|
750 |
+ |
for delta in [-1, 0, 1] {
|
|
751 |
+ |
boundary_upload(2, 600_000, delta).await;
|
|
752 |
+ |
}
|
|
753 |
+ |
}
|
|
754 |
+ |
|
|
755 |
+ |
#[tokio::test]
|
|
756 |
+ |
async fn three_part_boundaries_are_planned_and_sent_exactly() {
|
|
757 |
+ |
// Three sealed chunks cut into three parts, so chunk and part boundaries
|
|
758 |
+ |
// are near each other without coinciding.
|
|
759 |
+ |
for delta in [-1, 0, 1] {
|
|
760 |
+ |
boundary_upload(
|
|
761 |
+ |
3,
|
|
762 |
+ |
synckit_client::crypto::BLOB_CHUNK_SIZE * 2 + 500_000,
|
|
763 |
+ |
delta,
|
|
764 |
+ |
)
|
|
765 |
+ |
.await;
|
|
766 |
+ |
}
|
|
767 |
+ |
}
|