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/// ヘッダ部から `name:`(小文字で渡す)の値を返す(前後の空白は除く)。
89fn header_value<'a>(raw: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
90    let end = body_start(raw)?;
91    let head = raw.get(..end)?;
92    for line in head.split(|&b| b == b'\n') {
93        let line = line.strip_suffix(b"\r").unwrap_or(line);
94        if line.len() > name.len()
95            && line
96                .iter()
97                .zip(name.iter())
98                .all(|(a, b)| a.to_ascii_lowercase() == *b)
99        {
100            let v = line.get(name.len()..)?;
101            let start = v.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(v.len());
102            return v.get(start..);
103        }
104    }
105    None
106}
107
108/// 途中で切れた応答を `Range: bytes=N-` で**続きから**取り直せるなら、その N(受信済み本文長)。
109///
110/// 【2026-09-26】QEMU の実画面で、GitHub Pages からの数 MB の取得がホスト側接続の
111/// リセット(RST)で途中切れになることがあった(パケットキャプチャで確認。
112/// ゲスト側 TCP は受信窓どおりに ACK しており正常)。最初から取り直すと同じ長さを
113/// また運ぶことになり、再び切れやすい。サーバが `Accept-Ranges: bytes` を返していて、
114/// 本文を 1 バイト以上受け取っており、まだ足りないときだけ `Some`。
115pub fn resume_offset(raw: &[u8]) -> Option<usize> {
116    if !looks_truncated(raw) {
117        return None;
118    }
119    let ar = header_value(raw, b"accept-ranges:")?;
120    if !ar.eq_ignore_ascii_case(b"bytes") {
121        return None;
122    }
123    let got = raw.len().checked_sub(body_start(raw)?)?;
124    (got > 0).then_some(got)
125}
126
127/// 途中まで受け取った応答 `partial`(200・全体の `Content-Length` 付き)に、
128/// `Range: bytes=off-` への応答 `resp` の本文を継ぎ足した応答を返す。
129///
130/// `resp` が 206 で、`Content-Range: bytes off-.../全体長` の開始位置と全体長が
131/// `partial` と一致するときだけ継ぎ足す(ファイルが差し替わっていたら混ぜない)。
132/// `resp` 自体が途中で切れていても、伸びた分は返す(次の再開に使う)。
133pub fn merge_range_response(partial: &[u8], off: usize, resp: &[u8]) -> Option<alloc::vec::Vec<u8>> {
134    let status_ok = resp.starts_with(b"HTTP/1.1 206") || resp.starts_with(b"HTTP/1.0 206");
135    if !status_ok {
136        return None;
137    }
138    let total = content_length(partial)?;
139    let cr = header_value(resp, b"content-range:")?;
140    let cr = cr.strip_prefix(b"bytes ")?;
141    let dash = cr.iter().position(|&b| b == b'-')?;
142    let slash = cr.iter().position(|&b| b == b'/')?;
143    let parse = |s: &[u8]| -> Option<usize> { core::str::from_utf8(s).ok()?.trim().parse().ok() };
144    let start = parse(cr.get(..dash)?)?;
145    let whole = parse(cr.get(slash + 1..)?)?;
146    if start != off || whole != total {
147        return None;
148    }
149    let body = resp.get(body_start(resp)?..)?;
150    let pstart = body_start(partial)?;
151    if partial.len().checked_sub(pstart)? != off {
152        return None;
153    }
154    let mut out = alloc::vec::Vec::with_capacity(partial.len() + body.len());
155    out.extend_from_slice(partial);
156    out.extend_from_slice(body);
157    Some(out)
158}