Skip to main content

s4_codec/
multipart.rs

1//! Multipart upload で使う on-the-wire フレーム形式。
2//!
3//! ## 課題
4//!
5//! AWS S3 multipart upload は各 part を独立にアップロードし、CompleteMultipartUpload
6//! で順番に concat した bytes が最終 object になる。S4 が per-part で圧縮すると、
7//! 最終 object は **N 個の圧縮済 chunk の concat**。GET 時に「どこからどこまでが
8//! 1 chunk か」を知るためのメタが必要だが、object metadata には全 chunk の境界を
9//! 入れる容量がない (S3 metadata 上限 2 KB、1000 parts × 8 byte = 8 KB で溢れる)。
10//!
11//! ## 解決策: in-band frame header
12//!
13//! 各 part bytes の先頭に固定 24 byte のフレームヘッダを置き、続く `compressed_size`
14//! バイトが圧縮済 payload。GET は object 全体を読み込み、先頭から frame を順に
15//! parse し各 chunk を解凍 → 連結する。
16//!
17//! ```text
18//! ┌──────────────────────────── 24 bytes ────────────────────────────┐
19//! │ magic    │ orig_size │ compressed_size │ crc32c │   ── then payload ──
20//! │ "S4F1"   │  u64 LE   │     u64 LE      │ u32 LE │ [compressed_size bytes]
21//! └──────────┴───────────┴─────────────────┴────────┘
22//! ```
23//!
24//! - codec は object metadata の `s4-codec` で **全 part 共通** (CreateMultipartUpload
25//!   で固定)。Phase 2 で per-frame codec 化を検討可。
26//! - object metadata に `s4-multipart=true` を立てておき、GET 側はそれを見て frame
27//!   parse を有効化する。
28//!
29//! ## 制限事項 (Phase 1)
30//!
31//! - **Range GET 非対応**: chunk 境界と byte offset の対応を計算しないので、
32//!   client が Range を指定しても無視 (もしくは下流の Range を尊重して invalid
33//!   解凍になる) — 実装上は Range を S4 で reject する方が安全。Phase 2 で対応。
34//! - **per-part 別 codec 非対応**: 上記 frame format に codec ID を入れるか、
35//!   object metadata を per-part に拡張するかの判断は Phase 2 で。
36
37use bytes::{Buf, BufMut, Bytes, BytesMut};
38use thiserror::Error;
39
40use crate::CodecKind;
41
42/// Frame magic = ASCII "S4F2" (S4 Frame, version 2)。
43/// v1 (S4F1) との違い: 4 byte の codec_id field を header に追加し、per-frame
44/// codec dispatch を可能にした。`s4-codec` v0.0.x は v1 を読まない (released 前
45/// なので backward compat 不要)。
46pub const FRAME_MAGIC: &[u8; 4] = b"S4F2";
47/// Padding frame magic = ASCII "S4P1" (S4 Padding, version 1)。
48///
49/// AWS S3 は multipart の non-final part に min 5 MB 制約を課すが、S4 が圧縮すると
50/// part が 5 MB を下回ることが多発する (圧縮率 10-100x で 5 MB が 50 KB-500 KB)。
51/// その場合 `write_padded_frame` が compressed payload の後ろに `[S4P1][len:u64]
52/// [len bytes of zeros]` を書き込んで全体を S3 の最小サイズまで膨らませる。
53/// `FrameIter` は padding を skip するので decode 側は意識不要。
54pub const PADDING_MAGIC: &[u8; 4] = b"S4P1";
55/// 4 (magic) + 4 (codec_id) + 8 (orig_size) + 8 (compressed_size) + 4 (crc32c) = 28
56pub const FRAME_HEADER_BYTES: usize = 4 + 4 + 8 + 8 + 4;
57pub const PADDING_HEADER_BYTES: usize = 4 + 8; // = 12
58
59/// AWS S3 の non-final multipart part の最小サイズ (5 MiB)。
60pub const S3_MULTIPART_MIN_PART_BYTES: usize = 5 * 1024 * 1024;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct FrameHeader {
64    pub codec: CodecKind,
65    pub original_size: u64,
66    pub compressed_size: u64,
67    pub crc32c: u32,
68}
69
70#[derive(Debug, Error)]
71pub enum FrameError {
72    #[error("frame too short: need at least {FRAME_HEADER_BYTES} bytes, have {0}")]
73    TooShort(usize),
74    #[error("bad frame magic: expected {expected:?}, got {got:?}")]
75    BadMagic { expected: [u8; 4], got: [u8; 4] },
76    #[error("frame compressed_size {compressed_size} exceeds remaining buffer {remaining}")]
77    PayloadTruncated {
78        compressed_size: u64,
79        remaining: usize,
80    },
81    #[error("unknown codec id {0} in frame header (decoder out of date?)")]
82    UnknownCodec(u32),
83    /// v0.8.15 H-b: a frame's declared `compressed_size` (or padding
84    /// frame length) exceeds the target architecture's
85    /// `usize::MAX`. On 64-bit hosts this is unreachable; on the
86    /// 32-bit `wasm32-unknown-unknown` target the `as usize` cast
87    /// used to truncate, letting a forged 4 GiB+ frame parse as a
88    /// 64-byte payload (silent data loss in the browser decoder).
89    /// `try_from`-based validation forces the typed error instead.
90    #[error("frame payload size {0} exceeds usize on this target")]
91    PayloadTooLarge(u64),
92}
93
94/// 1 フレーム分を直列化: header + payload を `dst` に追記。
95pub fn write_frame(dst: &mut BytesMut, header: FrameHeader, payload: &[u8]) {
96    debug_assert_eq!(payload.len() as u64, header.compressed_size);
97    dst.reserve(FRAME_HEADER_BYTES + payload.len());
98    dst.put_slice(FRAME_MAGIC);
99    dst.put_u32_le(header.codec.id());
100    dst.put_u64_le(header.original_size);
101    dst.put_u64_le(header.compressed_size);
102    dst.put_u32_le(header.crc32c);
103    dst.put_slice(payload);
104}
105
106/// `dst` の現在サイズが `min_total` byte を下回っていれば、padding frame を追記して
107/// `min_total` byte 以上になるよう pad する。
108///
109/// # 厳密な事後条件 (v0.8.15 M-8 で明文化)
110///
111/// 呼び出し後 `dst.len()` は以下を満たす:
112///
113/// 1. `dst.len() >= min_total` (常に)
114/// 2. `dst.len() <= max(min_total, prev_len + PADDING_HEADER_BYTES)` ── 1 frame
115///    追記の上限。`need < PADDING_HEADER_BYTES` のケースでは padding header
116///    自体が `min_total` を超える余地を作るため最大 11 byte の overshoot が
117///    起こり得る。
118/// 3. overshoot は最大 `PADDING_HEADER_BYTES - 1 = 11` byte。これは
119///    multipart unit test `pad_to_minimum_no_excessive_overshoot`
120///    (`< 5 MiB + 64`) で実証済。
121///
122/// padding 自体の中身は zero bytes (compress も decompress も無し)。
123pub fn pad_to_minimum(dst: &mut BytesMut, min_total: usize) {
124    if dst.len() >= min_total {
125        return;
126    }
127    // 残り = min_total - 現在 ですが、padding 自体に PADDING_HEADER_BYTES 必要。
128    let need = min_total - dst.len();
129    let payload_len = need.saturating_sub(PADDING_HEADER_BYTES);
130    // v0.8.15 M-8: `payload_len = 0` のケースでも PADDING_MAGIC + u64 length は必ず
131    // 12 byte 書く必要があるが、`reserve(0)` 呼び出しは無駄でしかない (下の
132    // `put_slice` / `put_u64_le` が必要分を確保する) ので、reserve は payload があると
133    // きだけ行う。
134    if payload_len > 0 {
135        dst.reserve(PADDING_HEADER_BYTES + payload_len);
136    }
137    dst.put_slice(PADDING_MAGIC);
138    dst.put_u64_le(payload_len as u64);
139    // zero-fill。`put_bytes` で 1 回 syscall。
140    dst.put_bytes(0, payload_len);
141}
142
143/// `input` の先頭から 1 フレーム読み出し、`(header, payload, remainder)` を返す。
144pub fn read_frame(mut input: Bytes) -> Result<(FrameHeader, Bytes, Bytes), FrameError> {
145    if input.len() < FRAME_HEADER_BYTES {
146        return Err(FrameError::TooShort(input.len()));
147    }
148    let mut magic = [0u8; 4];
149    magic.copy_from_slice(&input[..4]);
150    if &magic != FRAME_MAGIC {
151        return Err(FrameError::BadMagic {
152            expected: *FRAME_MAGIC,
153            got: magic,
154        });
155    }
156    input.advance(4);
157    let codec_id = input.get_u32_le();
158    let codec = CodecKind::from_id(codec_id).ok_or(FrameError::UnknownCodec(codec_id))?;
159    let original_size = input.get_u64_le();
160    let compressed_size = input.get_u64_le();
161    let crc32c = input.get_u32_le();
162    // v0.8.15 H-b: `compressed_size as usize` used to silently
163    // truncate on 32-bit targets (`s4-codec-wasm`), letting a 4 GiB+
164    // forged frame decode as a 64-byte payload. `try_from` forces
165    // the typed error so the WASM client surfaces the bad frame
166    // instead of misreading silently.
167    let compressed_size_usize = usize::try_from(compressed_size)
168        .map_err(|_| FrameError::PayloadTooLarge(compressed_size))?;
169    if compressed_size_usize > input.len() {
170        return Err(FrameError::PayloadTruncated {
171            compressed_size,
172            remaining: input.len(),
173        });
174    }
175    let payload = input.split_to(compressed_size_usize);
176    Ok((
177        FrameHeader {
178            codec,
179            original_size,
180            compressed_size,
181            crc32c,
182        },
183        payload,
184        input,
185    ))
186}
187
188/// `input` 全体を frame の sequence として parse、各 frame を yield する iterator。
189///
190/// `S4P1` (padding) を見つけたら header の length 分だけ skip して次に進む
191/// (= caller には見せない)。
192///
193/// **エラー時の振る舞い**: parse 失敗を 1 度返したら、それ以降 next() は `None`
194/// を返す (= iterator 終了)。これにより corrupt 入力に対する **無限ループ防止**
195/// (proptest fuzz で発覚)。
196pub struct FrameIter {
197    rest: Bytes,
198    fused: bool,
199}
200
201impl FrameIter {
202    pub fn new(input: Bytes) -> Self {
203        Self {
204            rest: input,
205            fused: false,
206        }
207    }
208}
209
210impl Iterator for FrameIter {
211    type Item = Result<(FrameHeader, Bytes), FrameError>;
212    fn next(&mut self) -> Option<Self::Item> {
213        if self.fused {
214            return None;
215        }
216        loop {
217            if self.rest.is_empty() {
218                return None;
219            }
220            if self.rest.len() < 4 {
221                self.fused = true;
222                return Some(Err(FrameError::TooShort(self.rest.len())));
223            }
224            let mut magic = [0u8; 4];
225            magic.copy_from_slice(&self.rest[..4]);
226            if &magic == PADDING_MAGIC {
227                // skip padding frame: 4 magic + 8 len + len bytes
228                if self.rest.len() < PADDING_HEADER_BYTES {
229                    self.fused = true;
230                    return Some(Err(FrameError::TooShort(self.rest.len())));
231                }
232                self.rest.advance(4);
233                let pad_len = self.rest.get_u64_le();
234                // v0.8.15 H-b: same `as usize` truncation hazard as
235                // `read_frame` above. On 32-bit WASM a 4 GiB+
236                // `pad_len` would skip 0 bytes silently.
237                let pad_len_usize = match usize::try_from(pad_len) {
238                    Ok(n) => n,
239                    Err(_) => {
240                        self.fused = true;
241                        return Some(Err(FrameError::PayloadTooLarge(pad_len)));
242                    }
243                };
244                if pad_len_usize > self.rest.len() {
245                    self.fused = true;
246                    return Some(Err(FrameError::PayloadTruncated {
247                        compressed_size: pad_len,
248                        remaining: self.rest.len(),
249                    }));
250                }
251                self.rest.advance(pad_len_usize);
252                continue;
253            }
254            // それ以外は data frame として parse
255            return match read_frame(std::mem::take(&mut self.rest)) {
256                Ok((hdr, payload, remainder)) => {
257                    self.rest = remainder;
258                    Some(Ok((hdr, payload)))
259                }
260                Err(e) => {
261                    self.fused = true;
262                    Some(Err(e))
263                }
264            };
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn frame_roundtrip_single() {
275        let payload = Bytes::from_static(b"hello frame payload");
276        let header = FrameHeader {
277            codec: CodecKind::CpuZstd,
278            original_size: 999,
279            compressed_size: payload.len() as u64,
280            crc32c: 0xdead_beef,
281        };
282        let mut buf = BytesMut::new();
283        write_frame(&mut buf, header, &payload);
284        assert_eq!(buf.len(), FRAME_HEADER_BYTES + payload.len());
285        let bytes = buf.freeze();
286        let (got_header, got_payload, rest) = read_frame(bytes).unwrap();
287        assert_eq!(got_header, header);
288        assert_eq!(got_payload, payload);
289        assert!(rest.is_empty());
290    }
291
292    #[test]
293    fn frame_iter_walks_all_frames_with_mixed_codecs() {
294        // 異なる codec で 5 frame を交互に書く → reader が per-frame codec を返すこと
295        let codecs = [
296            CodecKind::Passthrough,
297            CodecKind::CpuZstd,
298            CodecKind::NvcompZstd,
299            CodecKind::NvcompBitcomp,
300            CodecKind::DietGpuAns,
301        ];
302        let mut buf = BytesMut::new();
303        for (i, codec) in codecs.iter().enumerate() {
304            let payload = vec![i as u8; (i + 1) * 4];
305            let h = FrameHeader {
306                codec: *codec,
307                original_size: 100 + i as u64,
308                compressed_size: payload.len() as u64,
309                crc32c: i as u32,
310            };
311            write_frame(&mut buf, h, &payload);
312        }
313        let total = FrameIter::new(buf.freeze())
314            .collect::<Result<Vec<_>, _>>()
315            .unwrap();
316        assert_eq!(total.len(), 5);
317        for (i, (h, payload)) in total.iter().enumerate() {
318            assert_eq!(h.codec, codecs[i], "codec must be preserved per frame");
319            assert_eq!(h.original_size, 100 + i as u64);
320            assert_eq!(h.crc32c, i as u32);
321            assert_eq!(payload.len(), (i + 1) * 4);
322        }
323    }
324
325    #[test]
326    fn frame_bad_magic_rejected() {
327        let mut buf = BytesMut::with_capacity(FRAME_HEADER_BYTES);
328        buf.put_slice(b"BAD!");
329        buf.put_u32_le(0); // codec_id
330        buf.put_u64_le(0);
331        buf.put_u64_le(0);
332        buf.put_u32_le(0);
333        let err = read_frame(buf.freeze()).unwrap_err();
334        assert!(matches!(err, FrameError::BadMagic { .. }));
335    }
336
337    #[test]
338    fn frame_truncated_rejected() {
339        // header says 100 bytes payload, but we provide 0
340        let mut buf = BytesMut::with_capacity(FRAME_HEADER_BYTES);
341        buf.put_slice(FRAME_MAGIC);
342        buf.put_u32_le(CodecKind::CpuZstd.id());
343        buf.put_u64_le(100);
344        buf.put_u64_le(100);
345        buf.put_u32_le(0);
346        let err = read_frame(buf.freeze()).unwrap_err();
347        assert!(matches!(err, FrameError::PayloadTruncated { .. }));
348    }
349
350    #[test]
351    fn frame_unknown_codec_rejected() {
352        let mut buf = BytesMut::with_capacity(FRAME_HEADER_BYTES);
353        buf.put_slice(FRAME_MAGIC);
354        buf.put_u32_le(99); // unknown codec id
355        buf.put_u64_le(0);
356        buf.put_u64_le(0);
357        buf.put_u32_le(0);
358        let err = read_frame(buf.freeze()).unwrap_err();
359        assert!(matches!(err, FrameError::UnknownCodec(99)));
360    }
361
362    #[test]
363    fn frame_too_short_for_header_rejected() {
364        let buf = Bytes::from_static(b"shortdata");
365        let err = read_frame(buf).unwrap_err();
366        assert!(matches!(err, FrameError::TooShort(_)));
367    }
368
369    #[test]
370    fn padding_skipped_by_iter() {
371        let mut buf = BytesMut::new();
372        // frame 1: small data
373        let p1 = Bytes::from_static(b"first frame");
374        write_frame(
375            &mut buf,
376            FrameHeader {
377                codec: CodecKind::CpuZstd,
378                original_size: 11,
379                compressed_size: p1.len() as u64,
380                crc32c: 0,
381            },
382            &p1,
383        );
384        // pad to 1024 bytes (well above min)
385        pad_to_minimum(&mut buf, 1024);
386        assert!(buf.len() >= 1024);
387        // frame 2: another small data
388        let p2 = Bytes::from_static(b"second frame");
389        write_frame(
390            &mut buf,
391            FrameHeader {
392                codec: CodecKind::CpuZstd,
393                original_size: 12,
394                compressed_size: p2.len() as u64,
395                crc32c: 0,
396            },
397            &p2,
398        );
399
400        let frames: Vec<_> = FrameIter::new(buf.freeze())
401            .collect::<Result<_, _>>()
402            .unwrap();
403        assert_eq!(
404            frames.len(),
405            2,
406            "padding must be skipped, only data yielded"
407        );
408        assert_eq!(frames[0].1, p1);
409        assert_eq!(frames[1].1, p2);
410    }
411
412    #[test]
413    fn pad_to_minimum_is_noop_when_already_above() {
414        let mut buf = BytesMut::new();
415        buf.extend_from_slice(&[0u8; 1024]);
416        pad_to_minimum(&mut buf, 100);
417        assert_eq!(buf.len(), 1024);
418    }
419
420    #[test]
421    fn pad_to_minimum_grows_to_target() {
422        let mut buf = BytesMut::new();
423        write_frame(
424            &mut buf,
425            FrameHeader {
426                codec: CodecKind::Passthrough,
427                original_size: 0,
428                compressed_size: 0,
429                crc32c: 0,
430            },
431            &[],
432        );
433        let before = buf.len();
434        pad_to_minimum(&mut buf, 5_000_000);
435        assert!(buf.len() >= 5_000_000);
436        assert!(buf.len() < 5_000_000 + 64, "no excessive overshoot");
437        assert!(buf.len() > before);
438    }
439}