nx_https_get_complete.nx source
↩ module page · 350 lines · 15393 B
1// nx_https_get_complete.nx -- step 4 of the nx_https_client
2// wiring arc. THE LAST piece before nx_https_get(url, store)
3// works end-to-end.
4//
5// Takes a connected TLS 1.3 session (state == CONNECTED, app
6// traffic keys derived in step 3c.5) + a path + a host + an out
7// buffer. Encrypts an HTTP/1.1 GET request as an application_data
8// TLS record under client_app_traffic_key + sends + reads the
9// server's encrypted response records in a loop + decrypts each
10// under server_app_traffic_key + accumulates plaintext into
11// out_buf until peer-close (close_notify alert OR TCP EOF).
12//
13// Returns total bytes accumulated into out_buf on success
14// (POSITIVE). Negative -NX_HTTPS_GC_* verdict on failure.
15//
16// Composes 4 shipped substrate primitives:
17// nx_http_client_build_request -- 4-line HTTP/1.1 GET
18// nx_tls13_record_encrypt -- AEAD wrap
19// nx_tls13_read_record_from_fd -- partial-read framer
20// nx_tls13_record_decrypt -- AEAD unwrap
21//
22// HTTP/1.1 Connection: close semantics:
23// The shipped nx_http_client_build_request emits "Connection:
24// close" so the server closes after responding. We read until
25// EOF (or close_notify) and return whatever was accumulated.
26// Per-response chunking + Content-Length parsing is a later
27// refinement (the calling Browser arc parses HTTP body from
28// the returned bytes).
29//
30// Public API:
31// nx_https_get_complete(
32// session, fd, path, path_len, host, host_len,
33// out_buf, out_cap
34// ) -> POSITIVE bytes_received | NEGATIVE -NX_HTTPS_GC_* code
35// nx_https_gc_verdict_is_valid(v) -> 0|1
36//
37// Sealed verdict:
38// NX_HTTPS_GC_OK positive rc = bytes received
39// NX_HTTPS_GC_BAD_STATE session not at CONNECTED
40// NX_HTTPS_GC_BUILD_FAIL request builder returned non-positive
41// NX_HTTPS_GC_ENCRYPT_FAIL record encrypt verdict non-OK
42// NX_HTTPS_GC_WRITE_FAIL sys_write returned non-positive
43// NX_HTTPS_GC_READ_FAIL record read verdict non-recoverable
44// NX_HTTPS_GC_DECRYPT_FAIL record decrypt verdict non-OK
45// NX_HTTPS_GC_BUF_OVERFLOW response exceeds out_cap
46//
47// Per Cardinals 9 (single-responsibility -- ONE round trip), 12
48// (defensive at boundaries -- cap on response size + bounds check
49// on every record), 19 (composes shipped primitives unchanged),
50// 22 (composition -- 4 shipped primitives compose into one
51// orchestrator), 23 (preamble names the Connection-close
52// semantics + queued chunking refinement).
53//
54// license_tier: INDEPENDENT_REDERIVE
55// genealogy_id: international-research-sources/ietf/rfc_8446 + rfc_9112
56// lineage_id: nishi_https_get_complete_q10
57
58// nx_safety_envelope:
59// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
60// sil_target: SIL1
61// evidence: [bulk_applied_2026-05-19, https-get-complete-step-4]
62// verdict: NOT_YET_EVALUATED
63
64import "nx_syscalls.nx"
65import "nx_tls13.nx"
66import "nx_tls13_record.nx"
67import "nx_tls13_read_record_from_fd.nx"
68import "nx_tls13_client_session.nx"
69import "nx_http_client.nx"
70import "nx_chacha20_poly1305.nx"
71
72const NX_HTTPS_GC_OK: i64 = 1
73const NX_HTTPS_GC_BAD_STATE: i64 = 2
74const NX_HTTPS_GC_BUILD_FAIL: i64 = 3
75const NX_HTTPS_GC_ENCRYPT_FAIL: i64 = 4
76const NX_HTTPS_GC_WRITE_FAIL: i64 = 5
77const NX_HTTPS_GC_READ_FAIL: i64 = 6
78const NX_HTTPS_GC_DECRYPT_FAIL: i64 = 7
79const NX_HTTPS_GC_BUF_OVERFLOW: i64 = 8
80const NX_HTTPS_GC_VERDICT_N: i64 = 9
81
82// Max bytes for the encrypted request record we build.
83// HTTP GET request is typically ~80-200 bytes; we cap at 4KB to
84// allow long paths/URLs.
85const NX_HTTPS_GC_REQ_BUF_BYTES: i64 = 8192
86
87// Max bytes per response record buffer (TLS record max + header).
88const NX_HTTPS_GC_RESP_RECORD_BYTES: i64 = 16645
89
90func nx_https_gc_verdict_is_valid(v: i64) -> i64 {
91 if v < NX_HTTPS_GC_OK { return 0 }
92 if v >= NX_HTTPS_GC_VERDICT_N { return 0 }
93 return 1
94}
95
96func _gc_pn(v: i64) -> i64 {
97 let b: *u8 = sys_mmap(24)
98 var x: i64 = v
99 if x < 0 { x = 0 - x }
100 var i: i64 = 22
101 if x == 0 { b[i] = 0x30 as u8; i = i - 1 }
102 else { while x > 0 { b[i] = (0x30 + (x - (x/10)*10)) as u8; x = x / 10; i = i - 1 } }
103 sys_write(2, ((b as i64) + i + 1) as *u8, 22 - i)
104 return 0
105}
106
107// Write exactly `n` bytes to fd via looping sys_write.
108// Returns 0 on success, -1 on any sys_write error.
109func _gc_write_n(fd: i64, buf: *u8, n: i64) -> i64 {
110 var off: i64 = 0
111 while off < n {
112 let w: i64 = sys_write(fd, (buf as i64 + off) as *u8, n - off)
113 if w <= 0 { return 0 - 1 }
114 off = off + w
115 }
116 return 0
117}
118
119// --- browser-faithful termination helpers (ADDITIVE; only used to RETURN-EARLY once a
120// Content-Length body is fully received, so the measured time is real latency and not the
121// server keep-alive idle timeout. Responses WITHOUT a Content-Length are unaffected = byte-
122// identical to the old read-until-close path). ---
123func _gc_lc(c: i64) -> i64 { if c >= 65 { if c <= 90 { return c + 32 } } return c }
124// find end-of-headers ("\r\n\r\n"); returns index of the first '\r' or -1.
125func _gc_hdr_end(buf: *u8, n: i64) -> i64 {
126 var i: i64 = 0
127 while i + 4 <= n {
128 if buf[i]==(13 as u8) { if buf[i+1]==(10 as u8) { if buf[i+2]==(13 as u8) { if buf[i+3]==(10 as u8) { return i } } } }
129 i = i + 1
130 }
131 return 0 - 1
132}
133// case-insensitive match of pat at buf[off], bounded by end.
134func _gc_ci_match(buf: *u8, off: i64, end: i64, pat: *u8, patlen: i64) -> i64 {
135 if off + patlen > end { return 0 }
136 var j: i64 = 0
137 while j < patlen { if _gc_lc(buf[off+j] as i64) != _gc_lc(pat[j] as i64) { return 0 } j = j + 1 }
138 return 1
139}
140// parse the Content-Length header value within buf[0..hdr_end]; -1 if absent.
141// matched only at a line start (offset 0 or preceded by '\n') so "X-Content-Length:" can't false-match.
142func _gc_clen(buf: *u8, hdr_end: i64) -> i64 {
143 let pat: *u8 = "content-length:" as *u8
144 var i: i64 = 0
145 while i < hdr_end {
146 var atline: i64 = 0
147 if i == 0 { atline = 1 } else { if buf[i-1]==(10 as u8) { atline = 1 } }
148 if atline == 1 { if _gc_ci_match(buf, i, hdr_end, pat, 15) == 1 {
149 var p: i64 = i + 15
150 while p < hdr_end { if buf[p]==(32 as u8) { p = p + 1 } else { break } } // skip OWS
151 var v: i64 = 0; var any: i64 = 0; var go: i64 = 1
152 while go == 1 { if p >= hdr_end { go = 0 } else { let c: i64 = buf[p] as i64; if c >= 48 { if c <= 57 { v = v*10 + (c - 48); any = 1; p = p + 1 } else { go = 0 } } else { go = 0 } } }
153 if any == 1 { return v }
154 return 0 - 1
155 } }
156 i = i + 1
157 }
158 return 0 - 1
159}
160
161// Send the HTTP GET as an encrypted TLS record + drain the
162// server's response into out_buf. See preamble for semantics.
163// nx_https_req_complete: send a PREBUILT request over the connected TLS session + read the full response.
164// The shared TLS send/recv core -- nx_https_get_complete builds a plain GET and delegates here; the CC range
165// fetch builds a "Range: bytes=S-E" GET and delegates here too. Existing GET behavior is byte-identical
166// (same request bytes, same recv loop). (2026-07-04 additive extraction; no logic change -- proven by the
167// unchanged nx_https_get_complete_test.)
168func nx_https_req_complete(
169 s: *Tls13ClientSession,
170 fd: i64,
171 req: *u8, req_len: i64,
172 out_buf: *u8, out_cap: i64
173) -> i64 {
174 if s.state != NX_TLS13_CSESSION_STATE_CONNECTED {
175 return 0 - NX_HTTPS_GC_BAD_STATE
176 }
177 if req_len <= 0 { return 0 - NX_HTTPS_GC_BUILD_FAIL }
178
179 // ---- Encrypt as TLS application_data record ----
180 // Layout (matching nx_tls13_record_encrypt's three-buffer split):
181 // header(5) + ciphertext(req_len + 1) + tag(16) = req_len + 22
182 let rec_buf: *u8 = sys_mmap(req_len + 64)
183 let header_out: *u8 = rec_buf
184 let ct_out: *u8 = rec_buf + NX_TLS13_RECORD_HEADER_LEN
185 let tag_out: *u8 = rec_buf + NX_TLS13_RECORD_HEADER_LEN + req_len + 1
186
187 let enc_v: i64 = nx_tls13_record_encrypt_v2(
188 s.cipher_suite,
189 s.client_app_traffic_key,
190 s.client_app_iv,
191 s.client_app_seq,
192 req, req_len,
193 NX_TLS13_CT_APPLICATION_DATA,
194 0, // no padding
195 header_out, ct_out, tag_out
196 )
197 s.client_app_seq = s.client_app_seq + 1
198 if enc_v != NX_TLS13_REC_VERDICT_OK { return 0 - NX_HTTPS_GC_ENCRYPT_FAIL }
199
200 // ---- Write encrypted request record ----
201 let total_rec_len: i64 = NX_TLS13_RECORD_HEADER_LEN + req_len + 1 + NX_TLS13_RECORD_TAG_LEN
202 let wr_v: i64 = _gc_write_n(fd, rec_buf, total_rec_len)
203 if wr_v < 0 { return 0 - NX_HTTPS_GC_WRITE_FAIL }
204
205 // ---- Loop reading + decrypting response records ----
206 var accumulated: i64 = 0
207 var t_recv: i64 = 0
208 var t_dec: i64 = 0
209 var nrecs: i64 = 0
210 var body_target: i64 = 0 // >0 once Content-Length body length is known; 0 = read-to-close (unchanged path)
211 var hdr_parsed: i64 = 0
212 // Reusable per-record scratch -- allocate ONCE, not per record. The per-record
213 // sys_mmap churn (~6/record, never freed) was the measured transfer overhead
214 // (~13ms/record) -- the cipher itself is ~0.26ms/record.
215 let rec_in: *u8 = sys_mmap(NX_HTTPS_GC_RESP_RECORD_BYTES)
216 let plaintext: *u8 = sys_mmap(NX_HTTPS_GC_RESP_RECORD_BYTES)
217 let plaintext_ct_p: *i64 = sys_mmap(16) as *i64
218 let plaintext_len_p: *i64 = sys_mmap(16) as *i64
219
220 while accumulated < out_cap {
221 let _r0: i64 = sys_now_ms()
222 let rec_in_total: i64 = nx_tls13_read_record_from_fd(
223 fd, rec_in, NX_HTTPS_GC_RESP_RECORD_BYTES
224 )
225 t_recv = t_recv + (sys_now_ms() - _r0)
226 nrecs = nrecs + 1
227 // EOF or peer-close is a NORMAL termination of "Connection:
228 // close" responses. Any other negative verdict is an error.
229 if rec_in_total < 0 {
230 let nv: i64 = 0 - rec_in_total
231 if nv == NX_TLS13_READ_REC_EOF {
232 sys_write(2, "nishi-xfer recv=" as *u8, 16); _gc_pn(t_recv)
233 sys_write(2, "ms dec=" as *u8, 7); _gc_pn(t_dec); sys_write(2, "ms\n" as *u8, 3)
234 return accumulated
235 }
236 if nv == NX_TLS13_READ_REC_PAYLOAD_EOF { return accumulated }
237 return 0 - NX_HTTPS_GC_READ_FAIL
238 }
239
240 // Split header/ciphertext/tag
241 let rec_in_header: *u8 = rec_in
242 let rec_in_ct: *u8 = rec_in + NX_TLS13_RECORD_HEADER_LEN
243 let rec_in_ct_len: i64 = rec_in_total - NX_TLS13_RECORD_HEADER_LEN - NX_TLS13_RECORD_TAG_LEN
244 let rec_in_tag: *u8 = rec_in + rec_in_total - NX_TLS13_RECORD_TAG_LEN
245
246 let _d0: i64 = sys_now_ms()
247 let dec_v: i64 = nx_tls13_record_decrypt_v2(
248 s.cipher_suite,
249 s.server_app_traffic_key,
250 s.server_app_iv,
251 s.server_app_seq,
252 rec_in_header,
253 rec_in_ct, rec_in_ct_len,
254 rec_in_tag,
255 plaintext,
256 plaintext_ct_p, plaintext_len_p
257 )
258 t_dec = t_dec + (sys_now_ms() - _d0)
259 s.server_app_seq = s.server_app_seq + 1
260 if dec_v != NX_TLS13_REC_VERDICT_OK { return 0 - NX_HTTPS_GC_DECRYPT_FAIL }
261
262 // Process by content type. RFC 8446 ยง6 close_notify alert
263 // (level=1=warning, description=0=close_notify) terminates
264 // the session gracefully -- return what we have.
265 if *plaintext_ct_p == NX_TLS13_CT_ALERT {
266 // Alert payload is 2 bytes: [level][description]; close_notify = description 0.
267 // Any alert ends the response gracefully -- emit the native transfer split first.
268 sys_write(2, "nishi-xfer recv=" as *u8, 16); _gc_pn(t_recv)
269 sys_write(2, "ms dec=" as *u8, 7); _gc_pn(t_dec)
270 sys_write(2, "ms (macbuild=" as *u8, 13); _gc_pn(nx_cp_mac_ms())
271 sys_write(2, " poly=" as *u8, 6); _gc_pn(nx_cp_poly_ms())
272 sys_write(2, " cha=" as *u8, 5); _gc_pn(nx_cp_cha_ms())
273 sys_write(2, ") nrecs=" as *u8, 8); _gc_pn(nrecs)
274 sys_write(2, " aead_calls=" as *u8, 12); _gc_pn(nx_cp_calls())
275 sys_write(2, " scratch=" as *u8, 9); _gc_pn(nx_cp_scratch()); sys_write(2, "\n" as *u8, 1)
276 return accumulated
277 }
278
279 if *plaintext_ct_p == NX_TLS13_CT_APPLICATION_DATA {
280 // Append plaintext into out_buf
281 let avail: i64 = out_cap - accumulated
282 let to_copy: i64 = *plaintext_len_p
283 if to_copy > avail { return 0 - NX_HTTPS_GC_BUF_OVERFLOW }
284 var i: i64 = 0
285 while i < to_copy {
286 out_buf[accumulated + i] = plaintext[i]
287 i = i + 1
288 }
289 accumulated = accumulated + to_copy
290 // browser-faithful early return: once Content-Length is known and the full body is in,
291 // stop instead of blocking until the server's keep-alive close (which falsely measured
292 // the idle timeout). Additive: only fires for Content-Length responses fully received.
293 if hdr_parsed == 0 {
294 let he: i64 = _gc_hdr_end(out_buf, accumulated)
295 if he >= 0 {
296 hdr_parsed = 1
297 let cl: i64 = _gc_clen(out_buf, he)
298 if cl >= 0 { body_target = he + 4 + cl }
299 }
300 }
301 if body_target > 0 { if accumulated >= body_target {
302 sys_write(2, "nishi-xfer recv=" as *u8, 16); _gc_pn(t_recv)
303 sys_write(2, "ms dec=" as *u8, 7); _gc_pn(t_dec); sys_write(2, "ms cl-stop\n" as *u8, 11)
304 return accumulated
305 } }
306 }
307
308 // Other content types (handshake, change_cipher_spec) are
309 // unexpected post-CONNECTED but we tolerate them by skipping
310 // (defense against post-handshake messages that some real
311 // servers send, e.g. NewSessionTicket).
312 }
313
314 return accumulated
315}
316
317// nx_https_get_complete: build a plain GET for `path` and send it via the shared core (unchanged public API).
318func nx_https_get_complete(
319 s: *Tls13ClientSession,
320 fd: i64,
321 path: *u8, path_len: i64,
322 host: *u8, host_len: i64,
323 out_buf: *u8, out_cap: i64
324) -> i64 {
325 let req: *u8 = sys_mmap(NX_HTTPS_GC_REQ_BUF_BYTES)
326 let req_len: i64 = nx_http_client_build_request(path, path_len, host, host_len, req)
327 return nx_https_req_complete(s, fd, req, req_len, out_buf, out_cap)
328}
329
330// Cookie-aware variant: builds a plain GET carrying a "Cookie: <cookie>\r\n" header (when
331// cookie_len>0) via nx_http_client_build_request_cookie, then delegates to the same shared TLS
332// send/recv core. Lets a redirect-following fetch replay a session across hops. Additive; the
333// original nx_https_get_complete is untouched (Cardinal 19).
334func nx_https_get_complete_cookie(
335 s: *Tls13ClientSession,
336 fd: i64,
337 path: *u8, path_len: i64,
338 host: *u8, host_len: i64,
339 cookie: *u8, cookie_len: i64,
340 out_buf: *u8, out_cap: i64
341) -> i64 {
342 let req: *u8 = sys_mmap(NX_HTTPS_GC_REQ_BUF_BYTES)
343 let req_len: i64 = nx_http_client_build_request_cookie(path, path_len, host, host_len, cookie, cookie_len, req)
344 return nx_https_req_complete(s, fd, req, req_len, out_buf, out_cap)
345}
346
347// Compile-only smoke. Real KAT in nx_https_get_complete_test.nx.
348func main() -> i64 {
349 return 0
350}