Skip to main content

max / synckit

Pin the multipart part-boundary arithmetic The multipart fixtures all sat mid-part, so nothing distinguished div_ceil from a truncating divide or a final part sized from the remainder. Add a parametrized upload over ciphertexts of exactly N parts, one byte short and one byte over, for N of 2 and 3, asserting the planned part count, every PUT's length and that the parts reassemble into a blob that decrypts. Also cover short(), including the hash whose eighth byte falls inside a multibyte character.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 22:26 UTC
Signed with PGP, not checked
Commit: 682c0d35b97c571abce570014e3f96fe6daa9ff0
Parent: d0b6e54
2 files changed, +114 insertions, -0 deletions
@@ -445,6 +445,19 @@
445 445 .unwrap();
446 446 }
447 447
448 + #[test]
449 + fn short_truncates_a_hash_and_passes_a_shorter_one_through() {
450 + let hash = "0123456789abcdef";
451 + assert_eq!(short(hash), "01234567", "longer than the log prefix");
452 + assert_eq!(short("0123456"), "0123456", "shorter than the log prefix");
453 + assert_eq!(short("01234567"), "01234567", "exactly the log prefix");
454 + assert_eq!(short(""), "");
455 + // Seven ASCII bytes then a two-byte char, so byte 8 is mid-character:
456 + // a naive `&hash[..8]` panics here, and the fallback returns the whole
457 + // string instead.
458 + assert_eq!(short("abcdefg\u{e9}hij"), "abcdefg\u{e9}hij");
459 + }
460 +
448 461 #[tokio::test]
449 462 async fn upload_sends_local_blob_and_dedups() {
450 463 let dir = tempdir();
@@ -664,3 +664,104 @@
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 + }