Skip to main content

s4_codec/
index.rs

1//! Frame index — Range GET の partial fetch を可能にするための sidecar object 形式。
2//!
3//! ## 課題
4//!
5//! S4-multipart object は `[S4F2 frame]([S4P1 padding][S4F2 frame])*` のシーケンス。
6//! Range GET (e.g. `bytes=N-M`) を効率的に処理するには、(a) どの frame が
7//! decompressed offset N..M に対応しているか、(b) その frame は object body の
8//! どこ (compressed_offset) から始まるか、を知る必要がある。
9//!
10//! ## 解決策
11//!
12//! `<key>.s4index` という sidecar object に下記の binary index を書く:
13//!
14//! ```text
15//! ┌──── v1 32 byte header ─┐
16//! │ S4IX magic (4)         │
17//! │ version u32 (4)        │
18//! │ total_frames u64 (8)   │
19//! │ total_original u64 (8) │
20//! │ total_padded u64 (8)   │  ← S3 上の object サイズ (padding 含む)
21//! └────────────────────────┘
22//! 各 frame について 32 byte:
23//!   original_offset  u64 LE
24//!   original_size    u64 LE
25//!   compressed_offset u64 LE  ← S3 object body における frame header の開始位置
26//!   compressed_size  u64 LE   ← header (28 byte) + payload の合計
27//! ```
28//!
29//! 1000 frame で 32 KB、10000 frame で 320 KB。10 万 frame でも 3.2 MB に収まる。
30//!
31//! ## 使い方
32//!
33//! - PUT: 1 frame の単純 index、PUT 完了後に sidecar 書込
34//! - CompleteMultipartUpload: object 全体を一度 fetch + scan して index を構築
35//! - Range GET: sidecar fetch → `lookup_range(start, end)` で frame 範囲 + S3 byte 範囲を取得
36//!   → backend に partial Range GET → frame parse → decompress → slice
37//!
38//! ## v0.8.4 #73 H-2: source object version binding (v2 header)
39//!
40//! v1 では sidecar に source object の identity が無いため、object overwrite 後に
41//! sidecar が stale のままだと Range GET が **間違った frame** を返す危険があった
42//! (古い byte offset で新 object を partial GET する hazard)。攻撃者が backend を
43//! 直接触れる脅威モデルでは、偽 sidecar を仕込めば任意 frame を露呈させ得る。
44//!
45//! 対策として v2 header に `source_etag` と `source_compressed_size` を追加。GET
46//! 側は HEAD で current etag を取って一致確認 → 不一致なら sidecar を信用せず full
47//! GET path に fall back する。
48//!
49//! ```text
50//! ┌──── v2 header (variable) ┐
51//! │ S4IX magic (4)           │
52//! │ version u32 (4) = 2      │
53//! │ total_frames u64 (8)     │
54//! │ total_original u64 (8)   │
55//! │ total_padded u64 (8)     │
56//! │ source_compressed_size u64 (8)  ← v2 で追加
57//! │ etag_len u32 (4)                 ← v2 で追加 (UTF-8 byte length, 0 = absent)
58//! │ etag bytes (etag_len)            ← v2 で追加 (RFC 7232 entity-tag, quotes 含む)
59//! └──────────────────────────┘
60//! ```
61//!
62//! - **back-compat**: v1 sidecar が backend に既存していれば read-only で `decode_index`
63//!   が `source_etag = None`, `source_compressed_size = None` で復元する。GET 側は
64//!   `None` を見たら "legacy sidecar — verify skip, full GET にも fallback できる"
65//!   と扱う (= 既存挙動保持)。
66//! - **新規 PUT**: 常に v2 を書く。`source_etag` は backend response の e_tag、
67//!   `source_compressed_size` は put body 長 (= `total_padded_size`) が原則。
68
69use bytes::{Buf, BufMut, Bytes, BytesMut};
70use thiserror::Error;
71
72pub const INDEX_MAGIC: &[u8; 4] = b"S4IX";
73/// v0.8.4 #73 H-2: bumped 1 → 2. v2 appends `source_compressed_size` (u64) +
74/// `etag_len` (u32) + variable-length `etag` bytes to the fixed header. v1
75/// readers are kept as a back-compat path (see [`decode_index`]).
76pub const INDEX_VERSION: u32 = 2;
77/// Legacy v1 fixed header — kept for tests / back-compat readers.
78pub const INDEX_VERSION_V1: u32 = 1;
79/// v1 fixed header layout (kept for back-compat readers).
80pub const HEADER_FIXED_V1: usize = 4 + 4 + 8 + 8 + 8; // 32
81/// v2 fixed header layout (`HEADER_FIXED_V1` + `source_compressed_size` u64 +
82/// `etag_len` u32). The variable-length `etag` payload follows.
83pub const HEADER_FIXED_V2: usize = HEADER_FIXED_V1 + 8 + 4; // 44
84/// v0.8.16 F-15: kept for back-compat with external consumers that
85/// imported the v0.8.10-era constant. **DEPRECATED** — the value
86/// `40` was a typo (it should have been `44` for the v2 fixed
87/// header). Use [`HEADER_FIXED_V1`] / [`HEADER_FIXED_V2`] directly.
88#[deprecated(
89    since = "0.8.16",
90    note = "INDEX_HEADER_BYTES was an off-by-4 typo; use HEADER_FIXED_V1 or HEADER_FIXED_V2 instead"
91)]
92pub const INDEX_HEADER_BYTES: usize = HEADER_FIXED_V2;
93pub const ENTRY_BYTES: usize = 8 + 8 + 8 + 8;
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct FrameIndexEntry {
97    /// この frame が担当する decompressed byte 範囲の開始 (累計、0-based)
98    pub original_offset: u64,
99    /// 解凍後 byte 数 (frame header の original_size と同じ)
100    pub original_size: u64,
101    /// S3 object body 内での frame 開始位置 (S4F2 magic の先頭 byte)
102    pub compressed_offset: u64,
103    /// frame 全体のバイト数 (28 byte header + payload)
104    pub compressed_size: u64,
105}
106
107impl FrameIndexEntry {
108    /// v0.8.15 H-a: was plain `self.original_offset + self.original_size`,
109    /// which panics in `dev` (workspace `overflow_checks = true`) and
110    /// wraps in release on an attacker-supplied sidecar entry with
111    /// `original_offset = u64::MAX - 10` and `original_size = 100`.
112    /// `decode_index` now also pre-validates each entry below, so this
113    /// `saturating_add` is defence-in-depth — a corrupted in-memory
114    /// `FrameIndexEntry` cannot crash the gateway through `binary_search_by`.
115    pub fn original_end(&self) -> u64 {
116        self.original_offset.saturating_add(self.original_size)
117    }
118    pub fn compressed_end(&self) -> u64 {
119        self.compressed_offset.saturating_add(self.compressed_size)
120    }
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct FrameIndex {
125    /// S3 上の object 全体サイズ (padding frame 含む)
126    pub total_padded_size: u64,
127    pub entries: Vec<FrameIndexEntry>,
128    /// v0.8.4 #73 H-2: backend-reported ETag of the source object the
129    /// sidecar describes. Populated by `s4-server::put_object` from the
130    /// backend's PUT response so the matching GET can `head_object` and
131    /// confirm it's still talking about the same body. `None` for legacy
132    /// (v1) sidecars decoded out of an existing backend, in which case
133    /// the GET path treats the partial-fetch as best-effort and falls
134    /// back to a full read on any inconsistency signal.
135    pub source_etag: Option<String>,
136    /// v0.8.4 #73 H-2: backend object's compressed bytes length the sidecar
137    /// was computed against. Cross-check signal alongside `source_etag` —
138    /// some backends (lifecycle moves, multi-object operations) can change
139    /// the bytes without a fresh ETag, so a size mismatch is independently
140    /// load-bearing. `None` on legacy v1 sidecars.
141    pub source_compressed_size: Option<u64>,
142}
143
144impl FrameIndex {
145    pub fn total_original_size(&self) -> u64 {
146        self.entries.last().map(|e| e.original_end()).unwrap_or(0)
147    }
148
149    /// Range request `[start, end_exclusive)` を解決して必要 frame の (start_idx, end_idx_exclusive)
150    /// と S3 上の partial-fetch byte range `[byte_start, byte_end_exclusive)` を返す。
151    ///
152    /// 1 frame でもオーバーラップしていればその frame の **全 byte** を fetch する
153    /// (= 部分 frame は decompress 単位)。
154    pub fn lookup_range(&self, start: u64, end_exclusive: u64) -> Option<RangePlan> {
155        if self.entries.is_empty() || start >= end_exclusive {
156            return None;
157        }
158        let total = self.total_original_size();
159        if start >= total {
160            return None;
161        }
162        let clamped_end = end_exclusive.min(total);
163
164        // start を含む frame を二分探索 (entries は original_offset 昇順)
165        let first_idx = match self.entries.binary_search_by(|e| {
166            if e.original_end() <= start {
167                std::cmp::Ordering::Less
168            } else if e.original_offset > start {
169                std::cmp::Ordering::Greater
170            } else {
171                std::cmp::Ordering::Equal
172            }
173        }) {
174            Ok(i) => i,
175            Err(_) => return None,
176        };
177        // end を含む frame (end-1 を含むもの)
178        let last_inclusive = clamped_end - 1;
179        let last_idx = match self.entries.binary_search_by(|e| {
180            if e.original_end() <= last_inclusive {
181                std::cmp::Ordering::Less
182            } else if e.original_offset > last_inclusive {
183                std::cmp::Ordering::Greater
184            } else {
185                std::cmp::Ordering::Equal
186            }
187        }) {
188            Ok(i) => i,
189            Err(_) => return None,
190        };
191
192        let byte_start = self.entries[first_idx].compressed_offset;
193        let byte_end_exclusive = self.entries[last_idx].compressed_end();
194        Some(RangePlan {
195            first_frame_idx: first_idx,
196            last_frame_idx_inclusive: last_idx,
197            byte_start,
198            byte_end_exclusive,
199            // slice 開始 / 終了の original 内 offset
200            slice_start_in_combined: start - self.entries[first_idx].original_offset,
201            slice_end_in_combined: clamped_end - self.entries[first_idx].original_offset,
202        })
203    }
204}
205
206/// `lookup_range` の結果。`byte_start..byte_end_exclusive` を S3 から fetch、
207/// 該当 frames を decompress し、結果バイト列を `[slice_start_in_combined,
208/// slice_end_in_combined)` で slice すれば最終結果。
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct RangePlan {
211    pub first_frame_idx: usize,
212    pub last_frame_idx_inclusive: usize,
213    pub byte_start: u64,
214    pub byte_end_exclusive: u64,
215    pub slice_start_in_combined: u64,
216    pub slice_end_in_combined: u64,
217}
218
219#[derive(Debug, Error)]
220pub enum IndexError {
221    #[error("index too short: {0} bytes")]
222    TooShort(usize),
223    #[error("bad index magic: {got:?}")]
224    BadMagic { got: [u8; 4] },
225    #[error("unsupported index version {0} (this build supports {INDEX_VERSION})")]
226    UnsupportedVersion(u32),
227    #[error("entry count {claimed} doesn't match buffer remaining {remaining}")]
228    EntryCountMismatch { claimed: u64, remaining: usize },
229    /// v0.8.15 H-a: an entry's `original_offset + original_size` or
230    /// `compressed_offset + compressed_size` overflows `u64`. The
231    /// downstream `binary_search_by` / `lookup_range` machinery
232    /// assumes monotonically-increasing offsets — overflow would let
233    /// a forged sidecar drive the range planner into garbage state.
234    #[error(
235        "frame index entry overflows: original_offset={ooff}, original_size={osize}, \
236         compressed_offset={coff}, compressed_size={csize}"
237    )]
238    EntryOverflow {
239        ooff: u64,
240        osize: u64,
241        coff: u64,
242        csize: u64,
243    },
244    /// v0.8.15 H-c: per-sidecar entry-count cap. Pairs with the v0.8.12
245    /// `#124` `Vec::with_capacity` clamp — refuses pathologically-large
246    /// `n` at parse time even before the `expected_remaining == input.len()`
247    /// guard, so a 32-bit target can't be tricked into running `0..n`
248    /// past the buffer.
249    #[error("frame index entry count {got} exceeds MAX_FRAMES={max}")]
250    TooManyFrames { got: u64, max: u64 },
251    /// v0.8.15 H-c: `etag_len` exceeds the maximum addressable size on
252    /// this target (32-bit) or the operator-configured cap.
253    #[error("sidecar etag_len {got} exceeds MAX_ETAG_BYTES={max}")]
254    EtagTooLong { got: u32, max: u32 },
255    /// v0.8.16 F-2: consecutive entries are not in non-decreasing
256    /// order. `binary_search_by` / `lookup_range` rely on the
257    /// invariant that `entries[i+1].original_offset >=
258    /// entries[i].original_end()` (and the same for `compressed_*`).
259    /// A forged sidecar violating that lets a Range GET drive
260    /// `RangePlan.byte_end_exclusive` to a u64-wrapped value.
261    #[error(
262        "frame index entries out of order: prev_original_end={prev_original_end}, \
263         curr_original_offset={curr_original_offset}, prev_compressed_end={prev_compressed_end}, \
264         curr_compressed_offset={curr_compressed_offset}"
265    )]
266    NonMonotonicEntries {
267        prev_original_end: u64,
268        curr_original_offset: u64,
269        prev_compressed_end: u64,
270        curr_compressed_offset: u64,
271    },
272}
273
274/// v0.8.15 H-c: hard upper bound on the number of entries
275/// [`decode_index`] will accept. 16 M × 32 B = 512 MiB sidecar
276/// body — orders of magnitude over any real workload (a typical
277/// 5 GiB object hits ~1280 frames at the 4 MiB default chunk).
278/// Above this we'd be parsing an attacker payload, not a legitimate
279/// sidecar.
280pub const MAX_FRAMES: u64 = 16 * 1024 * 1024;
281/// v0.8.15 H-c: hard upper bound on the etag-length field. AWS S3
282/// ETags are ≤ 64 bytes including quotes; MinIO / Garage match. The
283/// 4 KiB cap leaves room for non-canonical multipart ETags
284/// (`<hex>-<n>`) without admitting attacker-controlled payloads.
285pub const MAX_ETAG_BYTES: u32 = 4096;
286
287/// v0.8.4 #73 H-2: emit the **v2** layout (with `source_etag` /
288/// `source_compressed_size`). Pre-v0.8.4 deployments that PUT under v1 are
289/// still readable (decode_index dispatches on the version field) — only the
290/// writer path is bumped here.
291pub fn encode_index(idx: &FrameIndex) -> Bytes {
292    let etag_bytes = idx.source_etag.as_deref().unwrap_or("").as_bytes();
293    let mut buf = BytesMut::with_capacity(
294        HEADER_FIXED_V2 + etag_bytes.len() + idx.entries.len() * ENTRY_BYTES,
295    );
296    buf.put_slice(INDEX_MAGIC);
297    buf.put_u32_le(INDEX_VERSION);
298    buf.put_u64_le(idx.entries.len() as u64);
299    buf.put_u64_le(idx.total_original_size());
300    buf.put_u64_le(idx.total_padded_size);
301    // v2 additions
302    buf.put_u64_le(idx.source_compressed_size.unwrap_or(0));
303    buf.put_u32_le(etag_bytes.len() as u32);
304    buf.put_slice(etag_bytes);
305    for e in &idx.entries {
306        buf.put_u64_le(e.original_offset);
307        buf.put_u64_le(e.original_size);
308        buf.put_u64_le(e.compressed_offset);
309        buf.put_u64_le(e.compressed_size);
310    }
311    buf.freeze()
312}
313
314/// v0.8.4 #73 H-2: legacy v1 encoder retained for the back-compat unit test
315/// (`sidecar_header_back_compat_old_format_no_source_etag`) which has to
316/// synthesize a v1 buffer to prove decode_index still parses it. Production
317/// callers should always go through [`encode_index`] which emits v2.
318#[doc(hidden)]
319pub fn encode_index_v1_for_test(idx: &FrameIndex) -> Bytes {
320    let mut buf = BytesMut::with_capacity(HEADER_FIXED_V1 + idx.entries.len() * ENTRY_BYTES);
321    buf.put_slice(INDEX_MAGIC);
322    buf.put_u32_le(INDEX_VERSION_V1);
323    buf.put_u64_le(idx.entries.len() as u64);
324    buf.put_u64_le(idx.total_original_size());
325    buf.put_u64_le(idx.total_padded_size);
326    for e in &idx.entries {
327        buf.put_u64_le(e.original_offset);
328        buf.put_u64_le(e.original_size);
329        buf.put_u64_le(e.compressed_offset);
330        buf.put_u64_le(e.compressed_size);
331    }
332    buf.freeze()
333}
334
335pub fn decode_index(mut input: Bytes) -> Result<FrameIndex, IndexError> {
336    if input.len() < HEADER_FIXED_V1 {
337        return Err(IndexError::TooShort(input.len()));
338    }
339    let mut magic = [0u8; 4];
340    magic.copy_from_slice(&input[..4]);
341    if &magic != INDEX_MAGIC {
342        return Err(IndexError::BadMagic { got: magic });
343    }
344    input.advance(4);
345    let version = input.get_u32_le();
346    let n = input.get_u64_le();
347    let _total_original = input.get_u64_le();
348    let total_padded_size = input.get_u64_le();
349    // v0.8.15 H-c: hard cap on `n` *before* any size arithmetic. The
350    // existing `expected_remaining == input.len()` check is a
351    // necessary condition but not sufficient — on a 32-bit target,
352    // `n as usize` truncates a 33-bit value and the buffer check
353    // would silently pass with the wrong loop count. Reject early.
354    if n > MAX_FRAMES {
355        return Err(IndexError::TooManyFrames {
356            got: n,
357            max: MAX_FRAMES,
358        });
359    }
360    // Dispatch on version. v1 jumps straight to the entry table; v2 reads
361    // the additional fixed fields + variable-length etag before the entries.
362    let (source_compressed_size, source_etag) = match version {
363        v if v == INDEX_VERSION_V1 => (None, None),
364        v if v == INDEX_VERSION => {
365            // v2 fixed-header tail: source_compressed_size (u64) + etag_len (u32).
366            if input.len() < 8 + 4 {
367                return Err(IndexError::TooShort(input.len()));
368            }
369            let scs = input.get_u64_le();
370            let etag_len_u32 = input.get_u32_le();
371            // v0.8.15 H-c: bound `etag_len` *before* the `as usize`
372            // cast so the buffer check on a 32-bit WASM target can't
373            // be tricked into a usize-truncated value.
374            if etag_len_u32 > MAX_ETAG_BYTES {
375                return Err(IndexError::EtagTooLong {
376                    got: etag_len_u32,
377                    max: MAX_ETAG_BYTES,
378                });
379            }
380            let etag_len = etag_len_u32 as usize;
381            if input.len() < etag_len {
382                return Err(IndexError::TooShort(input.len()));
383            }
384            // Slice off the etag bytes; treat decode failure as "no etag" so
385            // a corrupted etag field still leaves a usable index (the GET
386            // path will fall back to full read on the missing binding).
387            let etag_bytes = input.split_to(etag_len);
388            let etag = if etag_len == 0 {
389                None
390            } else {
391                std::str::from_utf8(&etag_bytes).ok().map(str::to_owned)
392            };
393            (if scs == 0 { None } else { Some(scs) }, etag)
394        }
395        other => return Err(IndexError::UnsupportedVersion(other)),
396    };
397    // v0.8.15 H-c: `n * ENTRY_BYTES` cannot overflow `usize` here
398    // because `n <= MAX_FRAMES = 16M` and `ENTRY_BYTES = 32`, and on
399    // 32-bit targets the resulting value fits in `usize` (≤ 512
400    // MiB). The `as usize` cast on `n` is now bounded by the same
401    // ceiling.
402    let expected_remaining = (n as usize).saturating_mul(ENTRY_BYTES);
403    if input.len() != expected_remaining {
404        return Err(IndexError::EntryCountMismatch {
405            claimed: n,
406            remaining: input.len(),
407        });
408    }
409    // v0.8.12 HIGH-14 fix: clamp the initial allocation the way the
410    // CpuZstd / CpuGzip decompress path does (see
411    // `DECOMPRESS_BOOTSTRAP_CAPACITY` in `lib.rs`, landed in #89).
412    // A forged sidecar with `n = 100_000_000` paired with a 3.2 GiB
413    // body (the only way the `expected_remaining` check above passes
414    // for that `n`) would otherwise commit ~3.2 GiB of `FrameIndexEntry`
415    // slots up front, on top of the 3.2 GiB body bytes already in
416    // RAM. The honest cap is 4096 entries (128 KiB at
417    // `ENTRY_BYTES = 32`) — large enough that single-PUT framed and
418    // typical multipart objects don't pay any growth cost, small
419    // enough that an adversarial sidecar can't drive multi-GiB
420    // pre-allocations behind the bounded `expected_remaining`
421    // check. The `push` loop below grows the vector naturally and
422    // is itself bounded by `expected_remaining == input.len()`.
423    const BOOTSTRAP_ENTRIES: usize = 4096;
424    let initial_cap = (n as usize).min(BOOTSTRAP_ENTRIES);
425    let mut entries = Vec::with_capacity(initial_cap);
426    for _ in 0..n {
427        let original_offset = input.get_u64_le();
428        let original_size = input.get_u64_le();
429        let compressed_offset = input.get_u64_le();
430        let compressed_size = input.get_u64_le();
431        // v0.8.15 H-a: refuse entries whose `offset + size` overflows
432        // `u64`. The downstream `binary_search_by` / `lookup_range`
433        // machinery relies on monotone offsets — a wrapped value
434        // would let a forged sidecar drive `RangePlan.byte_end_exclusive`
435        // to garbage.
436        if original_offset.checked_add(original_size).is_none()
437            || compressed_offset.checked_add(compressed_size).is_none()
438        {
439            return Err(IndexError::EntryOverflow {
440                ooff: original_offset,
441                osize: original_size,
442                coff: compressed_offset,
443                csize: compressed_size,
444            });
445        }
446        entries.push(FrameIndexEntry {
447            original_offset,
448            original_size,
449            compressed_offset,
450            compressed_size,
451        });
452    }
453    // v0.8.16 F-2: inter-entry monotonicity. v0.8.15 H-a closed the
454    // per-entry `offset + size` overflow but did NOT verify that
455    // entries are in non-decreasing order. The downstream
456    // `binary_search_by` in `lookup_range` assumes sorted entries
457    // — feed it a sidecar with `[ooff=100,...],[ooff=0,...]` and the
458    // partition point logic returns garbage, then `start - entries[
459    // first_idx].original_offset` underflows `u64` (wraps in
460    // release, panics in dev) and the resulting `RangePlan` drives
461    // an arbitrary backend GET range. Reject out-of-order entries
462    // here with a dedicated typed error.
463    for win in entries.windows(2) {
464        let prev = &win[0];
465        let curr = &win[1];
466        if curr.original_offset < prev.original_end()
467            || curr.compressed_offset < prev.compressed_end()
468        {
469            return Err(IndexError::NonMonotonicEntries {
470                prev_original_end: prev.original_end(),
471                curr_original_offset: curr.original_offset,
472                prev_compressed_end: prev.compressed_end(),
473                curr_compressed_offset: curr.compressed_offset,
474            });
475        }
476    }
477    Ok(FrameIndex {
478        total_padded_size,
479        entries,
480        source_etag,
481        source_compressed_size,
482    })
483}
484
485/// Object body の bytes 全体を scan して FrameIndex を構築する。
486/// `multipart_e2e.rs` 等で full-scan path として使用。
487pub fn build_index_from_body(body: &Bytes) -> Result<FrameIndex, crate::multipart::FrameError> {
488    let mut entries = Vec::new();
489    let mut original_off: u64 = 0;
490    // FrameIter は padding を skip してしまうので、自前で位置追跡しながら parse する
491    let mut cursor = 0usize;
492    let mut iter_buf = body.clone();
493    while cursor < body.len() {
494        // padding magic を skip
495        if cursor + 4 <= body.len() && &body[cursor..cursor + 4] == crate::multipart::PADDING_MAGIC
496        {
497            // PADDING_HEADER_BYTES = 4 magic + 8 length
498            if cursor + crate::multipart::PADDING_HEADER_BYTES > body.len() {
499                break;
500            }
501            let pad_len = u64::from_le_bytes(body[cursor + 4..cursor + 12].try_into().unwrap());
502            // v0.8.16 F-3: was `pad_len as usize`, silently
503            // truncating on 32-bit. A forged `S4P1 || u64::MAX`
504            // padding header advanced the cursor by `0xFFFF_FFFF`
505            // on 64-bit (skipping past `body.len()` into the next
506            // iteration's break) and by `0xFFFF_FFFF` truncated
507            // on 32-bit (different behaviour by target). Use
508            // try_from + checked_add so a malformed body fails
509            // closed with a typed `FrameError` instead of either
510            // wandering off the end of the buffer or silently
511            // skipping the bad frame.
512            let pad_len_usize = usize::try_from(pad_len)
513                .map_err(|_| crate::multipart::FrameError::PayloadTooLarge(pad_len))?;
514            let next_cursor = cursor
515                .checked_add(crate::multipart::PADDING_HEADER_BYTES)
516                .and_then(|n| n.checked_add(pad_len_usize))
517                .ok_or(crate::multipart::FrameError::PayloadTooLarge(pad_len))?;
518            cursor = next_cursor;
519            if cursor > body.len() {
520                break;
521            }
522            iter_buf = body.slice(cursor..);
523            continue;
524        }
525        // data frame
526        if cursor + crate::multipart::FRAME_HEADER_BYTES > body.len() {
527            break;
528        }
529        let (header, _payload, rest) = crate::multipart::read_frame(iter_buf.clone())?;
530        // v0.8.16 F-3: `header.compressed_size as usize` had the
531        // same 32-bit-truncation hazard as the padding cursor
532        // arithmetic above. Use try_from so a forged 4 GiB+ frame
533        // surfaces as `PayloadTooLarge` instead of wandering off.
534        let compressed_size_usize = usize::try_from(header.compressed_size)
535            .map_err(|_| crate::multipart::FrameError::PayloadTooLarge(header.compressed_size))?;
536        let frame_total = crate::multipart::FRAME_HEADER_BYTES
537            .checked_add(compressed_size_usize)
538            .ok_or(crate::multipart::FrameError::PayloadTooLarge(
539                header.compressed_size,
540            ))?;
541        entries.push(FrameIndexEntry {
542            original_offset: original_off,
543            original_size: header.original_size,
544            compressed_offset: cursor as u64,
545            compressed_size: frame_total as u64,
546        });
547        // v0.8.16 F-3: `original_off +=` was a plain `+`, panicking
548        // in dev / wrapping in release on a forged body whose
549        // cumulative original sizes overflow u64. Use checked_add
550        // → typed error.
551        original_off = original_off.checked_add(header.original_size).ok_or(
552            crate::multipart::FrameError::PayloadTooLarge(header.original_size),
553        )?;
554        cursor = cursor.checked_add(frame_total).ok_or(
555            crate::multipart::FrameError::PayloadTooLarge(header.compressed_size),
556        )?;
557        iter_buf = rest;
558    }
559    Ok(FrameIndex {
560        total_padded_size: body.len() as u64,
561        entries,
562        // The caller (s4-server `put_object`) stamps the version-binding
563        // fields after the backend PUT returns the authoritative ETag —
564        // build_index_from_body itself only sees the post-compress bytes
565        // and cannot fabricate a server-blessed ETag.
566        source_etag: None,
567        source_compressed_size: None,
568    })
569}
570
571/// `<key>` から sidecar key を生成。
572pub fn sidecar_key(object_key: &str) -> String {
573    format!("{object_key}{SIDECAR_SUFFIX}")
574}
575
576/// v0.8.15 M-1: the per-object sidecar key suffix. Exposed publicly so
577/// the listener-side reserved-name guard
578/// (`s4-server::routing::is_reserved_object_key`) and the list-filter
579/// `ends_with(".s4index")` calls share one source of truth.
580pub const SIDECAR_SUFFIX: &str = ".s4index";
581
582/// v0.8.15 M-1: classify a candidate user-PUT object key as a
583/// reserved sidecar name. The S4 gateway uses `<key>.s4index` for
584/// its internal Range-GET fast-path; a user PUT under that name
585/// would either be hidden from `ListObjectsV2` (the filter strips
586/// `.s4index` suffixes) or get collected by the sidecar-cleanup
587/// path on `DeleteObject`. Returning a reserved-key error at the
588/// listener edge stops both before the user can be surprised.
589pub fn is_reserved_sidecar_key(object_key: &str) -> bool {
590    object_key.ends_with(SIDECAR_SUFFIX)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::CodecKind;
597    use crate::multipart::{FrameHeader, pad_to_minimum, write_frame};
598
599    fn sample_index() -> FrameIndex {
600        FrameIndex {
601            total_padded_size: 200,
602            entries: vec![
603                FrameIndexEntry {
604                    original_offset: 0,
605                    original_size: 100,
606                    compressed_offset: 0,
607                    compressed_size: 50,
608                },
609                FrameIndexEntry {
610                    original_offset: 100,
611                    original_size: 80,
612                    compressed_offset: 60, // gap of 10 = padding
613                    compressed_size: 40,
614                },
615                FrameIndexEntry {
616                    original_offset: 180,
617                    original_size: 50,
618                    compressed_offset: 100,
619                    compressed_size: 30,
620                },
621            ],
622            // Default-constructed in the v0.8.4 #73 H-2 sample so this fixture
623            // still drives the lookup_range / encode_decode / build_from_body
624            // paths that don't care about the version-binding fields.
625            source_etag: None,
626            source_compressed_size: None,
627        }
628    }
629
630    #[test]
631    fn encode_decode_roundtrip() {
632        let idx = sample_index();
633        let bytes = encode_index(&idx);
634        let decoded = decode_index(bytes).unwrap();
635        assert_eq!(decoded, idx);
636    }
637
638    /// v0.8.4 #73 H-2: v2 round-trip with the new `source_etag` /
639    /// `source_compressed_size` fields populated.
640    #[test]
641    fn encode_decode_roundtrip_v2_with_source_binding() {
642        let mut idx = sample_index();
643        idx.source_etag = Some("\"deadbeefcafe\"".into());
644        idx.source_compressed_size = Some(987_654);
645        let bytes = encode_index(&idx);
646        // First 4 bytes magic + next 4 bytes LE = INDEX_VERSION (2).
647        assert_eq!(&bytes[..4], INDEX_MAGIC);
648        let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
649        assert_eq!(version, INDEX_VERSION, "writer must always emit v2");
650        let decoded = decode_index(bytes).unwrap();
651        assert_eq!(decoded, idx);
652    }
653
654    /// v0.8.4 #73 H-2: a sidecar produced by a pre-v0.8.4 deployment
655    /// (= raw v1 bytes) must still decode cleanly under the v2 reader
656    /// with `source_etag = None` / `source_compressed_size = None`. The
657    /// GET path treats the `None` shape as "legacy — verify skip" so
658    /// existing on-disk sidecars keep serving partial fetches without a
659    /// flag day. This locks in the `decode_index` dispatch on the
660    /// `version` field that makes the back-compat path real.
661    #[test]
662    fn sidecar_header_back_compat_old_format_no_source_etag() {
663        let v2_idx = {
664            let mut idx = sample_index();
665            idx.source_etag = Some("\"unused\"".into());
666            idx.source_compressed_size = Some(42);
667            idx
668        };
669        // Round-trip through the v1 encoder — i.e. simulate decoding a
670        // sidecar that was written by a pre-v0.8.4 server. The version-
671        // binding fields are dropped on the way through (v1 has no slot
672        // for them) and must come back as `None`.
673        let v1_bytes = encode_index_v1_for_test(&v2_idx);
674        // Sanity: the on-wire version field is v1.
675        let version = u32::from_le_bytes(v1_bytes[4..8].try_into().unwrap());
676        assert_eq!(version, INDEX_VERSION_V1);
677        let decoded = decode_index(v1_bytes).expect("v1 sidecar must still decode");
678        // Frame entries + total_padded_size survive (the partial-fetch
679        // logic still works), but the new v2-only fields surface as None
680        // so the GET path knows it cannot do an etag-bind verify and
681        // applies the legacy "best-effort + fallback to full GET" rule.
682        assert_eq!(decoded.entries, v2_idx.entries);
683        assert_eq!(decoded.total_padded_size, v2_idx.total_padded_size);
684        assert_eq!(decoded.source_etag, None);
685        assert_eq!(decoded.source_compressed_size, None);
686    }
687
688    #[test]
689    fn lookup_range_within_single_frame() {
690        let idx = sample_index();
691        // 元 byte [10, 50) は frame 0 (original 0..100) の中
692        let plan = idx.lookup_range(10, 50).unwrap();
693        assert_eq!(plan.first_frame_idx, 0);
694        assert_eq!(plan.last_frame_idx_inclusive, 0);
695        assert_eq!(plan.byte_start, 0);
696        assert_eq!(plan.byte_end_exclusive, 50); // frame 0 全体
697        assert_eq!(plan.slice_start_in_combined, 10);
698        assert_eq!(plan.slice_end_in_combined, 50);
699    }
700
701    #[test]
702    fn lookup_range_spans_frames() {
703        let idx = sample_index();
704        // [50, 150) は frame 0 後半 + frame 1 前半
705        let plan = idx.lookup_range(50, 150).unwrap();
706        assert_eq!(plan.first_frame_idx, 0);
707        assert_eq!(plan.last_frame_idx_inclusive, 1);
708        assert_eq!(plan.byte_start, 0);
709        assert_eq!(plan.byte_end_exclusive, 100); // frame 0 (0..50) + frame 1 (60..100)
710        assert_eq!(plan.slice_start_in_combined, 50);
711        assert_eq!(plan.slice_end_in_combined, 150);
712    }
713
714    #[test]
715    fn lookup_range_at_end_clamps() {
716        let idx = sample_index();
717        // total original = 100 + 80 + 50 = 230、要求 200..1000 → 200..230 にクランプ
718        let plan = idx.lookup_range(200, 1000).unwrap();
719        assert_eq!(plan.first_frame_idx, 2);
720        assert_eq!(plan.last_frame_idx_inclusive, 2);
721        // frame 2 全体 (compressed_offset=100, size=30 → byte 100..130)
722        assert_eq!(plan.byte_start, 100);
723        assert_eq!(plan.byte_end_exclusive, 130);
724    }
725
726    #[test]
727    fn lookup_range_out_of_bounds_returns_none() {
728        let idx = sample_index();
729        assert!(idx.lookup_range(500, 600).is_none());
730    }
731
732    #[test]
733    fn build_index_from_real_body_skips_padding() {
734        // 2 frame + 中間 padding を組んで、index が正しく構築されることを確認
735        let mut buf = BytesMut::new();
736        let p1 = Bytes::from_static(b"AAAA");
737        write_frame(
738            &mut buf,
739            FrameHeader {
740                codec: CodecKind::Passthrough,
741                original_size: 100,
742                compressed_size: p1.len() as u64,
743                crc32c: 0,
744            },
745            &p1,
746        );
747        let frame1_end = buf.len();
748        // pad to 5000 bytes
749        pad_to_minimum(&mut buf, 5000);
750        let pad_end = buf.len();
751        let p2 = Bytes::from_static(b"BBBB");
752        write_frame(
753            &mut buf,
754            FrameHeader {
755                codec: CodecKind::Passthrough,
756                original_size: 80,
757                compressed_size: p2.len() as u64,
758                crc32c: 0,
759            },
760            &p2,
761        );
762
763        let idx = build_index_from_body(&buf.freeze()).unwrap();
764        assert_eq!(idx.entries.len(), 2);
765        assert_eq!(idx.entries[0].original_offset, 0);
766        assert_eq!(idx.entries[0].compressed_offset, 0);
767        assert_eq!(idx.entries[0].original_size, 100);
768        assert_eq!(idx.entries[0].compressed_size, frame1_end as u64);
769        assert_eq!(idx.entries[1].original_offset, 100);
770        assert_eq!(idx.entries[1].compressed_offset, pad_end as u64);
771        assert_eq!(idx.entries[1].original_size, 80);
772        assert_eq!(idx.total_original_size(), 180);
773    }
774}