Skip to main content

atmos/kernel/
http_body.rs

1//! HTTP 応答から**本文の範囲**を求める(純粋モジュール)。
2//!
3//! 【2026-08-05】まとめ取得(keep-alive)で
4//! **ヘッダを含めて本文として返す**バグを出した。
5//! キャッシュへ `HTTP/1.1 200 OK...` ごと書き込まれ、
6//! Font Awesome が 1 つも登録されず、
7//! ページ上のアイコンがすべて空の四角になっていた。
8//!
9//! 境界の計算はネットワークもハードウェアも要らないので、
10//! ここへ切り出して単体試験で固定する。
11
12/// ヘッダの終わり(=**本文の開始位置**)を返す。
13///
14/// `\r\n\r\n` の**直後**を指す。見つからなければ `None`。
15pub fn body_start(raw: &[u8]) -> Option<usize> {
16    raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
17}
18
19/// `Content-Length` の値を返す(ヘッダ部だけを見る)。
20pub fn content_length(raw: &[u8]) -> Option<usize> {
21    let end = body_start(raw)?;
22    // ヘッダは ASCII なのでバイト列のまま探す。
23    // 文字列の添字操作は UTF-8 の途中で切ると panic しうる。
24    const KEY: &[u8] = b"content-length:";
25    let head = &raw[..end];
26    let mut i = 0usize;
27    while i + KEY.len() <= head.len() {
28        let hit = head[i..i + KEY.len()]
29            .iter()
30            .zip(KEY.iter())
31            .all(|(a, b)| a.to_ascii_lowercase() == *b);
32        if hit {
33            // 値は行末(CR)まで。先頭の空白は読み飛ばす。
34            let mut j = i + KEY.len();
35            let mut v: usize = 0;
36            let mut seen = false;
37            while j < head.len() && head[j] != 13 {
38                match head[j] {
39                    d @ 48..=57 => {
40                        v = v.checked_mul(10)?.checked_add((d - 48) as usize)?;
41                        seen = true;
42                    }
43                    32 | 9 if !seen => {}
44                    _ => break,
45                }
46                j += 1;
47            }
48            return if seen { Some(v) } else { None };
49        }
50        i += 1;
51    }
52    None
53}
54
55/// 本文の範囲 `start..end` を返す。
56///
57/// **ここを間違えるとヘッダ込みで保存される**(実際に起きた)。
58/// 足りていなければ `None`(まだ読み切っていない)。
59pub fn body_range(raw: &[u8]) -> Option<(usize, usize)> {
60    let start = body_start(raw)?;
61    let len = content_length(raw)?;
62    let end = start.checked_add(len)?;
63    if raw.len() >= end {
64        Some((start, end))
65    } else {
66        None
67    }
68}
69
70/// 応答が**途中で切れている**か。
71///
72/// `Content-Length` が分かっていて、受信済み本文がそれより短ければ `true`。
73/// `chunked`(長さ不明)は判定できないので `false`。
74///
75/// 【2026-08-05】この計算は `tls.rs` に 3 つの写しがあった
76/// (`find_header_end` / `parse_content_length` / `response_looks_truncated`)。
77/// 片方だけ直す事故を防ぐためここへ一本化した。
78pub fn looks_truncated(raw: &[u8]) -> bool {
79    let Some(start) = body_start(raw) else {
80        return false;
81    };
82    let Some(len) = content_length(raw) else {
83        return false;
84    };
85    raw.len().saturating_sub(start) < len
86}
87
88/// 応答(ヘッダ込みの生バイト列)が**最後まで届いたか**。
89///
90/// - ヘッダの終わりが無ければ不完全。
91/// - 本文を持たない状態(1xx・204・304)は完全。
92/// - `Transfer-Encoding: chunked` は終端(長さ 0 のチャンク)まで届いていれば完全。
93/// - `Content-Length` があれば、その長さに達していれば完全。
94/// - どちらも無ければ接続の切断が本文の終わり(HTTP/1.1 §6.3)なので完全とみなす。
95///
96/// 【2026-10-16】XHR(`tls::https_request`)は接続が途中で切れても手元の分を 200 として
97/// 返していた。Cloudflare が chunked で返す papers.json(150,481 B)が 129,610 B で切れ、
98/// jQuery の JSON 解析が黙って失敗して論文リストが約 3 回に 1 回出なかった。
99/// 長さの分かる応答(Content-Length・chunked・本文の無いステータス)が揃ったか。
100/// 接続の終わりで区切る応答(長さ情報なし)は常に false(読み続ける)。
101/// 【2026-10-18】読み取りループがこれを見ずに接続が閉じる(またはタイムアウトする)まで
102/// 待っており、keep-alive の動画サーバ(googlevideo)で応答ごとに 15 秒待っていた。
103pub fn is_framed_complete(raw: &[u8]) -> bool {
104    let Some(_) = body_start(raw) else {
105        return false;
106    };
107    let status = raw
108        .get(9..12)
109        .and_then(|d| core::str::from_utf8(d).ok())
110        .and_then(|d| d.parse::<u16>().ok())
111        .unwrap_or(200);
112    let no_body = (100..200).contains(&status) || status == 204 || status == 304;
113    let chunked = header_value(raw, b"transfer-encoding:")
114        .is_some_and(|v| v.to_ascii_lowercase().windows(7).any(|w| w == b"chunked"));
115    if !no_body && !chunked && content_length(raw).is_none() {
116        return false;
117    }
118    is_complete_response(raw)
119}
120
121pub fn is_complete_response(raw: &[u8]) -> bool {
122    let Some(start) = body_start(raw) else {
123        return false;
124    };
125    let status = raw
126        .get(9..12)
127        .and_then(|d| core::str::from_utf8(d).ok())
128        .and_then(|d| d.parse::<u16>().ok())
129        .unwrap_or(200);
130    if (100..200).contains(&status) || status == 204 || status == 304 {
131        return true;
132    }
133    let chunked = header_value(raw, b"transfer-encoding:")
134        .is_some_and(|v| v.to_ascii_lowercase().windows(7).any(|w| w == b"chunked"));
135    if chunked {
136        return chunked_terminated(raw.get(start..).unwrap_or(&[]));
137    }
138    match content_length(raw) {
139        Some(len) => raw.len().saturating_sub(start) >= len,
140        None => true,
141    }
142}
143
144/// chunked 本文が終端チャンク(`0\r\n`)まで揃っているか。
145fn chunked_terminated(body: &[u8]) -> bool {
146    let mut i = 0usize;
147    loop {
148        let Some(rest) = body.get(i..) else {
149            return false;
150        };
151        let Some(eol) = rest.windows(2).position(|w| w == b"\r\n") else {
152            return false;
153        };
154        let line = rest.get(..eol).unwrap_or(&[]);
155        let size_part = line.split(|&b| b == b';').next().unwrap_or(&[]);
156        let Some(size) = core::str::from_utf8(size_part)
157            .ok()
158            .and_then(|t| usize::from_str_radix(t.trim(), 16).ok())
159        else {
160            return false;
161        };
162        if size == 0 {
163            return true;
164        }
165        // サイズ行 + 本文 + CRLF。
166        let next = i + eol + 2 + size + 2;
167        if next > body.len() {
168            return false;
169        }
170        i = next;
171    }
172}
173
174/// ヘッダ部から `name:`(小文字で渡す)の値を返す(前後の空白は除く)。
175fn header_value<'a>(raw: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
176    let end = body_start(raw)?;
177    let head = raw.get(..end)?;
178    for line in head.split(|&b| b == b'\n') {
179        let line = line.strip_suffix(b"\r").unwrap_or(line);
180        if line.len() > name.len()
181            && line
182                .iter()
183                .zip(name.iter())
184                .all(|(a, b)| a.to_ascii_lowercase() == *b)
185        {
186            let v = line.get(name.len()..)?;
187            let start = v.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(v.len());
188            return v.get(start..);
189        }
190    }
191    None
192}
193
194/// 途中で切れた応答を `Range: bytes=N-` で**続きから**取り直せるなら、その N(受信済み本文長)。
195///
196/// 【2026-09-26】QEMU の実画面で、GitHub Pages からの数 MB の取得がホスト側接続の
197/// リセット(RST)で途中切れになることがあった(パケットキャプチャで確認。
198/// ゲスト側 TCP は受信窓どおりに ACK しており正常)。最初から取り直すと同じ長さを
199/// また運ぶことになり、再び切れやすい。サーバが `Accept-Ranges: bytes` を返していて、
200/// 本文を 1 バイト以上受け取っており、まだ足りないときだけ `Some`。
201pub fn resume_offset(raw: &[u8]) -> Option<usize> {
202    if !looks_truncated(raw) {
203        return None;
204    }
205    let ar = header_value(raw, b"accept-ranges:")?;
206    if !ar.eq_ignore_ascii_case(b"bytes") {
207        return None;
208    }
209    let got = raw.len().checked_sub(body_start(raw)?)?;
210    (got > 0).then_some(got)
211}
212
213/// 途中まで受け取った応答 `partial`(200・全体の `Content-Length` 付き)に、
214/// `Range: bytes=off-` への応答 `resp` の本文を継ぎ足した応答を返す。
215///
216/// `resp` が 206 で、`Content-Range: bytes off-.../全体長` の開始位置と全体長が
217/// `partial` と一致するときだけ継ぎ足す(ファイルが差し替わっていたら混ぜない)。
218/// `resp` 自体が途中で切れていても、伸びた分は返す(次の再開に使う)。
219pub fn merge_range_response(partial: &[u8], off: usize, resp: &[u8]) -> Option<alloc::vec::Vec<u8>> {
220    let status_ok = resp.starts_with(b"HTTP/1.1 206") || resp.starts_with(b"HTTP/1.0 206");
221    if !status_ok {
222        return None;
223    }
224    let total = content_length(partial)?;
225    let cr = header_value(resp, b"content-range:")?;
226    let cr = cr.strip_prefix(b"bytes ")?;
227    let dash = cr.iter().position(|&b| b == b'-')?;
228    let slash = cr.iter().position(|&b| b == b'/')?;
229    let parse = |s: &[u8]| -> Option<usize> { core::str::from_utf8(s).ok()?.trim().parse().ok() };
230    let start = parse(cr.get(..dash)?)?;
231    let whole = parse(cr.get(slash + 1..)?)?;
232    if start != off || whole != total {
233        return None;
234    }
235    let body = resp.get(body_start(resp)?..)?;
236    let pstart = body_start(partial)?;
237    if partial.len().checked_sub(pstart)? != off {
238        return None;
239    }
240    let mut out = alloc::vec::Vec::with_capacity(partial.len() + body.len());
241    out.extend_from_slice(partial);
242    out.extend_from_slice(body);
243    Some(out)
244}