Skip to main content

max / synckit

Pin the v3 blob header bound and mid-chunk truncation Feed parse_blob_header bodies one byte short of, exactly, and one byte past BLOB_V3_HEADER_LEN, and hand decrypt_blob_chunked a blob missing the last byte of its final sealed chunk.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 21:58 UTC
Signed with PGP, not checked
Commit: 1e82680f839a5430afbbc728a2ccd761462a6992
Parent: 36e691c
1 file changed, +58 insertions, -0 deletions
@@ -1883,6 +1883,64 @@
1883 1883 assert!(decrypt_blob_chunked(&buf, &key, "hash").is_err());
1884 1884 }
1885 1885
1886 + /// The header-length guard is an exact bound: one byte short of the header
1887 + /// must fail, and exactly the header (or more) must parse.
1888 + #[test]
1889 + fn v3_header_length_bound_is_exact() {
1890 + fn body_of(len: usize) -> Vec<u8> {
1891 + let mut buf = Vec::from(WIRE_V3_TAG_BYTES);
1892 + let mut body = vec![0u8; len];
1893 + if !body.is_empty() {
1894 + body[0] = 3;
1895 + }
1896 + // chunk_size must be nonzero for the header to be accepted.
1897 + if len >= 5 {
1898 + body[1..5].copy_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
1899 + }
1900 + buf.extend_from_slice(&body);
1901 + buf
1902 + }
1903 +
1904 + let short = body_of(BLOB_V3_HEADER_LEN - 1);
1905 + assert!(
1906 + parse_blob_header(&short).is_err(),
1907 + "a body one byte short of the header must be rejected"
1908 + );
1909 +
1910 + let exact = body_of(BLOB_V3_HEADER_LEN);
1911 + let (header, consumed) = parse_blob_header(&exact).expect("exact header parses");
1912 + assert_eq!(consumed, WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN);
1913 + assert_eq!(header.chunk_size, BLOB_CHUNK_SIZE);
1914 + assert_eq!(header.total_len, 0);
1915 +
1916 + let long = body_of(BLOB_V3_HEADER_LEN + 1);
1917 + let (header, consumed) =
1918 + parse_blob_header(&long).expect("header plus a stream byte parses");
1919 + assert_eq!(
1920 + consumed,
1921 + WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN,
1922 + "the trailing byte belongs to the chunk stream, not the header"
1923 + );
1924 + assert_eq!(header.chunk_size, BLOB_CHUNK_SIZE);
1925 + }
1926 +
1927 + /// Losing the last byte of the final sealed chunk is a clean error, not a
1928 + /// slice panic.
1929 + #[test]
1930 + fn v3_blob_truncated_one_byte_into_final_chunk_errs() {
1931 + let key = generate_master_key();
1932 + let plaintext = vec![7u8; 4096];
1933 + let sealed = encrypt_blob_chunked(&plaintext, &key, "hash").unwrap();
1934 + let truncated = &sealed[..sealed.len() - 1];
1935 + assert!(
1936 + matches!(
1937 + decrypt_blob_chunked(truncated, &key, "hash"),
1938 + Err(SyncKitError::Crypto(_))
1939 + ),
1940 + "a one-byte truncation must report a truncated blob"
1941 + );
1942 + }
1943 +
1886 1944 #[test]
1887 1945 fn v2_data_is_tagged_and_roundtrips() {
1888 1946 let key = generate_master_key();