nx_4chan.nx source
↩ module page · 729 lines · 40895 B
1// nx_4chan.nx -- SOVEREIGN 4chan BROWSE / PREVIEW / DOWNLOAD adapter: the first of the media-manager
2// per-source ingesters ported off Python (mm/src/sites/fourchan.py) into the estate. Emits JSON on
3// stdout so the gallery/vault surface consumes it directly; downloads land in a plain directory that
4// `nx_mvault_walk <dir> real 4chan commit` then records into the vault (CID dedup + DSM layout).
5// COMPOSITION, NOT REIMPLEMENTATION: the vault already owns classification/dedup/layout (B1-B10), so
6// this organ deliberately stops at "validated bytes in a directory" and hands off.
7//
8// nx_4chan boards -- board list (+nsfw flag)
9// nx_4chan catalog <board> [max] -- thread previews: subject, comment, counts, THUMB+FULL urls
10// nx_4chan thread <board> <threadno> [max] -- every media item in a thread
11// nx_4chan fetch <board> <threadno> <dir> [max] -- download the thread's media into <dir>
12// nx_4chan selftest -- offline gate: parser teeth incl. neg-controls
13//
14// ★ WHY A BRACE-DEPTH PARSER AND NOT KEY-SCANNING. catalog.json embeds a `last_replies` array whose
15// post objects each carry their OWN "no": field, so scanning for `"no":` reports replies AS THREADS --
16// and it fails silently, inflating a 15-thread page into 60 "threads". Depth tracking is immune to
17// field order and to nesting. It is also STRING-AWARE: a 4chan comment routinely contains { } and ",
18// and a structural scanner that reads braces inside string data mis-nests the whole document.
19// Conveniently ALL THREE endpoints put their objects of interest at brace-depth 2:
20// catalog.json [ {page {1}, threads:[ {thread {2}, last_replies:[ {reply {3}} ]} ]} ]
21// thread.json { {1} "posts":[ {post {2}} ] }
22// boards.json { {1} "boards":[ {board {2}} ] }
23// so one scanner serves all three and `want_depth` stays a parameter rather than three constants.
24//
25// LEGAL/RATE POSTURE: read-only public JSON API, one polite delay between media fetches. General
26// capability; the operator points it where they like.
27// license_tier: ORIGINAL
28import "nx_syscalls.nx"
29import "nx_x509_trust_store.nx"
30import "nx_trust_store_load_from_certdata.nx"
31import "nx_https_fetch_follow.nx"
32import "nx_json_lib.nx"
33// POLITENESS IS COMPOSED, NOT RE-IMPLEMENTED. This organ hand-rolled fc_sleep_ms(FC_FETCH_DELAY_MS=350)
34// -- three times faster than the ~1 req/s the API asks for, blind to HTTP status so a 429 was
35// indistinguishable from a dropped connection, and PER-PROCESS, so politeness reset to zero on every
36// invocation: running fetch three times tripled the real rate with no memory of it. nx_crawl_pace is the
37// estate's incumbent pacer (per-host interval + Retry-After + exponential backoff, state PERSISTED on
38// disk across processes) and was already wired into nx_bulk_index / nx_url_index / nx_web_crawl_step.
39// Two rulers for one invariant is the duplicate-ruler defect; this deletes ours and adopts theirs.
40import "nx_crawl_pace.nx"
41
42// The two hosts are paced SEPARATELY because they are separately rate-limited: a.4cdn.org serves the
43// JSON API, i.4cdn.org serves media. A single global delay could not express that and had to guess low.
44const FC_API_HOST: *u8 = "a.4cdn.org"
45const FC_API_HOST_LEN: i64 = 10
46const FC_IMG_HOST: *u8 = "i.4cdn.org"
47const FC_IMG_HOST_LEN: i64 = 10
48const FC_MAGIC_1000000: i64 = 1000000
49const FC_MAGIC_1700000000001: i64 = 1700000000001
50
51const FC_MAGIC_4194304: i64 = 4194304
52const FC_MAGIC_2048: i64 = 2048
53const FC_MAGIC_65536: i64 = 65536
54const FC_MAGIC_1024: i64 = 1024
55const FC_MAGIC_512: i64 = 512
56
57const FC_JSON_CAP: i64 = 8388608 // catalog.json for a busy board runs ~1-3MB
58const FC_IMG_CAP: i64 = 33554432 // 32MB: /gif/ webm legitimately exceeds the manga 16MB cap
59const FC_MAX_OBJ: i64 = 4096 // objects enumerated per document (threads/posts/boards)
60const FC_OUT_CAP: i64 = 4194304 // emitted JSON
61const FC_TEXT_CAP: i64 = 8192 // one sub/com field
62const FC_MIN_BYTES: i64 = 512 // below this a "media file" is an error page or a spacer
63const FC_REDIRECTS: i64 = 6
64// Politeness delay between media fetches (ms). 4chan asks ~1 req/s on the API; media is more lenient.
65// Named here rather than buried at the call site (rule 11) so it is tunable without hunting.
66const FC_FETCH_DELAY_MS: i64 = 350
67// Bulk downloads get RATE-CHALLENGED by 4chan/Cloudflare even with the Chrome-JA3 hello -- a single
68// fetch works but a burst trips a per-IP limit. So a failed media fetch is RETRIED with exponential
69// backoff (the rate window resets on wait, not on a faster retry), and only counted failed after all
70// tries. FC_FETCH_RETRIES total attempts per media item.
71const FC_FETCH_RETRIES: i64 = 4
72
73func fc_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 }
74func fc_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
75func fc_apps(dst: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { dst[o + i] = s[i]; i = i + 1 } return o + i }
76func fc_num(dst: *u8, o: i64, v: i64) -> i64 {
77 let t: *u8 = sys_mmap(32)
78 var m: i64 = v
79 var k: i64 = 0
80 var w: i64 = o
81 if m < 0 { dst[w] = 45 as u8; w = w + 1; m = 0 - m }
82 if m == 0 { t[0] = 48 as u8; k = 1 }
83 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
84 var i: i64 = 0
85 while i < k { dst[w] = t[k - 1 - i]; w = w + 1; i = i + 1 }
86 return w
87}
88func fc_putn(v: i64) -> i64 { let b: *u8 = sys_mmap(32); let e: i64 = fc_num(b, 0, v); sys_write(1, b, e); return 0 }
89func fc_streq(a: *u8, b: *u8) -> i64 { var i: i64 = 0; while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 } if b[i] == (0 as u8) { return 1 } return 0 }
90func fc_sleep_ms(ms: i64) -> i64 {
91 let ts: *i64 = sys_mmap(16) as *i64
92 ts[0] = ms / 1000
93 ts[1] = (ms % 1000) * FC_MAGIC_1000000
94 __syscall(35, ts as i64, 0, 0, 0, 0, 0)
95 return 0
96}
97
98// ---------------------------------------------------------------------------------------------
99// STRING-AWARE brace-depth object enumerator. Records every object that OPENS at want_depth.
100// Objects at one depth cannot nest inside each other, so a single `pending` start offset suffices.
101// Returns the count; offs[i]..offs[i]+lens[i] bound object i.
102// ⚠ The escape handling is load-bearing: a comment ending in a backslash before its closing quote
103// would otherwise flip us into "in string" for the rest of the document and yield zero objects.
104func fc_scan_objects(buf: *u8, n: i64, want_depth: i64, offs: *i64, lens: *i64, cap: i64) -> i64 {
105 var depth: i64 = 0
106 var instr: i64 = 0
107 var esc: i64 = 0
108 var cnt: i64 = 0
109 var pending: i64 = 0 - 1
110 var i: i64 = 0
111 while i < n {
112 let c: i64 = buf[i] as i64
113 if instr == 1 {
114 if esc == 1 { esc = 0 } else {
115 if c == 92 { esc = 1 } else { if c == 34 { instr = 0 } }
116 }
117 } else {
118 if c == 34 { instr = 1 } else {
119 if c == 123 {
120 depth = depth + 1
121 if depth == want_depth { if pending < 0 { pending = i } }
122 } else {
123 if c == 125 {
124 if depth == want_depth {
125 if pending >= 0 {
126 if cnt < cap { offs[cnt] = pending; lens[cnt] = (i + 1) - pending; cnt = cnt + 1 }
127 pending = 0 - 1
128 }
129 }
130 depth = depth - 1
131 }
132 }
133 }
134 }
135 i = i + 1
136 }
137 return cnt
138}
139
140// ---------------------------------------------------------------------------------------------
141// Emit `src` (already JSON-de-escaped by jx_copy_str) as a JSON-SAFE, HTML-STRIPPED string body.
142// 4chan comments are HTML: <br>, <a class="quotelink">, <span class="quote">, <wbr>, plus entities.
143// <br> becomes a space; every other tag is DROPPED WITHOUT a space -- <wbr> is an intra-word break
144// hint, so emitting a space there would split words that are not actually separated.
145func fc_emit_text(dst: *u8, o0: i64, src: *u8, maxout: i64) -> i64 {
146 var o: i64 = o0
147 var i: i64 = 0
148 let n: i64 = fc_slen(src)
149 while i < n {
150 if (o - o0) >= maxout { i = n } else {
151 let c: i64 = src[i] as i64
152 if c == 60 { // '<' : a tag
153 var isbr: i64 = 0
154 if i + 2 < n { if (src[i+1] as i64) == 98 { if (src[i+2] as i64) == 114 { isbr = 1 } } } // "br"
155 var j: i64 = i + 1
156 while j < n { if (src[j] as i64) == 62 { break } j = j + 1 }
157 if isbr == 1 { dst[o] = 32 as u8; o = o + 1 }
158 i = j + 1
159 } else {
160 if c == 38 { // '&' : an entity
161 var adv: i64 = 1
162 var em: i64 = 0
163 if i + 3 < n { if (src[i+1] as i64) == 103 { if (src[i+2] as i64) == 116 { if (src[i+3] as i64) == 59 { em = 62; adv = 4 } } } } // >
164 if i + 3 < n { if (src[i+1] as i64) == 108 { if (src[i+2] as i64) == 116 { if (src[i+3] as i64) == 59 { em = 60; adv = 4 } } } } // <
165 if i + 4 < n { if (src[i+1] as i64) == 97 { if (src[i+2] as i64) == 109 { if (src[i+3] as i64) == 112 { if (src[i+4] as i64) == 59 { em = 38; adv = 5 } } } } } // &
166 if i + 5 < n { if (src[i+1] as i64) == 113 { if (src[i+2] as i64) == 117 { if (src[i+3] as i64) == 111 { if (src[i+4] as i64) == 116 { if (src[i+5] as i64) == 59 { em = 39; adv = 6 } } } } } } // "-> ' (never re-inject a quote)
167 if em == 0 { em = 38; adv = 1 }
168 // A decoded '<' or '>' is plain text in JSON, but re-emitting '"' or '\' would tear
169 // the output string, so " is mapped to an apostrophe above rather than a quote.
170 dst[o] = em as u8; o = o + 1
171 i = i + adv
172 } else {
173 if c == 34 { dst[o] = 92 as u8; o = o + 1; dst[o] = 34 as u8; o = o + 1; i = i + 1 } // \"
174 else { if c == 92 { dst[o] = 92 as u8; o = o + 1; dst[o] = 92 as u8; o = o + 1; i = i + 1 } // \\
175 else { if c < 32 { dst[o] = 32 as u8; o = o + 1; i = i + 1 }
176 else { dst[o] = c as u8; o = o + 1; i = i + 1 } } }
177 }
178 }
179 }
180 }
181 return o
182}
183
184// https://i.4cdn.org/<board>/<tim>[s].<ext> (s = the 4chan thumbnail suffix, always .jpg)
185func fc_media_url(dst: *u8, o0: i64, board: *u8, tim: i64, ext: *u8, thumb: i64) -> i64 {
186 var o: i64 = fc_apps(dst, o0, "https://i.4cdn.org/" as *u8)
187 o = fc_apps(dst, o, board)
188 dst[o] = 47 as u8; o = o + 1
189 o = fc_num(dst, o, tim)
190 if thumb == 1 { o = fc_apps(dst, o, "s.jpg" as *u8) } else { o = fc_apps(dst, o, ext) }
191 return o
192}
193
194// ---------------------------------------------------------------------------------------------
195// A COMPLETE image/video, not merely a plausible one. Images are verified header+trailer so a
196// truncated download can never land as a half-page. ⚠DOCUMENTED IMPRECISION: webm/mp4 completeness
197// is NOT verified -- there is no cheap trailer for them and we do not carry Content-Length here, so
198// video is accepted on magic + size alone. A truncated video CAN land; images cannot.
199func fc_media_complete(b: *u8, n: i64) -> i64 {
200 if n < FC_MIN_BYTES { return 0 }
201 if b[0] == (0xff as u8) { if b[1] == (0xd8 as u8) { // JPEG ffd8..ffd9
202 if b[n-2] == (0xff as u8) { if b[n-1] == (0xd9 as u8) { return 1 } }
203 return 0 } }
204 if b[0] == (0x89 as u8) { if b[1] == (0x50 as u8) { // PNG ..IEND
205 if b[n-8] == (0x49 as u8) { if b[n-7] == (0x45 as u8) { if b[n-6] == (0x4e as u8) { if b[n-5] == (0x44 as u8) { return 1 } } } }
206 return 0 } }
207 if b[0] == (0x47 as u8) { if b[1] == (0x49 as u8) { if b[2] == (0x46 as u8) { // GIF ..3b
208 if b[n-1] == (0x3b as u8) { return 1 }
209 return 0 } } }
210 if b[0] == (0x1a as u8) { if b[1] == (0x45 as u8) { if b[2] == (0xdf as u8) { if b[3] == (0xa3 as u8) { return 1 } } } } // EBML/webm
211 if n > 8 { if b[4] == (0x66 as u8) { if b[5] == (0x74 as u8) { if b[6] == (0x79 as u8) { if b[7] == (0x70 as u8) { return 1 } } } } } // mp4 ftyp
212 return 0
213}
214
215// mkdir -p: create EVERY missing component, not just the leaf. A single mkdir fails on any nested
216// staging path (`incoming/4chan/po_627472`) and the failure is INVISIBLE -- the fetch then reports
217// downloaded=0/failed=N with no reason, because the open() that failed was for a file in a directory
218// that was never created. Idempotent: EEXIST on an existing component is the normal case, not an error.
219func fc_mkdir_p(path: *u8) -> i64 {
220 let tmp: *u8 = sys_mmap(FC_MAGIC_2048)
221 let n: i64 = fc_slen(path)
222 var i: i64 = 0
223 while i < n {
224 tmp[i] = path[i]
225 // i > 0 so a LEADING '/' (absolute path) is not treated as an empty component to create
226 if i > 0 { if (path[i] as i64) == 47 {
227 tmp[i] = 0 as u8
228 __syscall(258, 0 - 100, tmp as i64, 0x1ed, 0, 0, 0)
229 tmp[i] = 47 as u8
230 } }
231 i = i + 1
232 }
233 tmp[n] = 0 as u8
234 __syscall(258, 0 - 100, tmp as i64, 0x1ed, 0, 0, 0)
235 return 0
236}
237
238// Fetch one media URL into <dir>/<name>. Runs in a FORKED CHILD so the TLS mmaps die with it --
239// a 200-file thread otherwise accumulates every connection's buffers in one address space.
240func fc_fetch_one(url: *u8, store: *TrustStore, dir: *u8, name: *u8) -> i64 {
241 let buf: *u8 = sys_mmap(FC_IMG_CAP)
242 let status: *i64 = sys_mmap(8) as *i64
243 let n: i64 = nx_https_fetch_follow_chrome(url, store, buf, FC_IMG_CAP, FC_REDIRECTS, status)
244 // FEED THE PACER THE REAL STATUS. The old code read status only to return 1, so a 429 and a dropped
245 // connection were the SAME event -- and the retry then re-fired at the same rate that caused the
246 // throttle. pace_after converts a throttle into a longer next interval, persisted per host.
247 // retry_after_s is passed 0 DELIBERATELY: this fetcher hands back the BODY, not the response headers,
248 // so Retry-After is NOT observable here. 0 makes the pacer fall back to its own exponential backoff,
249 // which is correct. Passing a guessed number would be a fabricated measurement wearing a real name.
250 var st0: i64 = 0
251 if n > 0 { st0 = status[0] }
252 pace_after(FC_IMG_HOST, FC_IMG_HOST_LEN, st0, 0)
253 if n <= 0 { return 1 }
254 if status[0] != 200 { return 1 }
255 if fc_media_complete(buf, n) == 0 { return 1 }
256 let path: *u8 = sys_mmap(FC_MAGIC_2048)
257 var o: i64 = fc_apps(path, 0, dir)
258 path[o] = 47 as u8; o = o + 1
259 o = fc_apps(path, o, name)
260 path[o] = 0 as u8
261 let fd: i64 = sys_openat_wr(path, 0x1a4)
262 if fd < 0 { return 1 }
263 sys_write(fd, buf, n)
264 sys_close(fd)
265 return 0
266}
267
268// ---------------------------------------------------------------------------------------------
269// GET a 4chan JSON document. Returns bytes, or a negative code; out_status carries the HTTP status
270// so a 404 board/thread is reported as such rather than as an empty result.
271func fc_get_json(url: *u8, store: *TrustStore, buf: *u8, out_status: *i64) -> i64 {
272 return nx_https_fetch_follow_chrome(url, store, buf, FC_JSON_CAP, FC_REDIRECTS, out_status)
273}
274
275func fc_api_url(dst: *u8, board: *u8, tail: *u8, thread: i64) -> i64 {
276 var o: i64 = fc_apps(dst, 0, "https://a.4cdn.org/" as *u8)
277 if fc_slen(board) > 0 { o = fc_apps(dst, o, board); dst[o] = 47 as u8; o = o + 1 }
278 o = fc_apps(dst, o, tail)
279 if thread > 0 { o = fc_num(dst, o, thread); o = fc_apps(dst, o, ".json" as *u8) }
280 dst[o] = 0 as u8
281 return o
282}
283
284// ---------------------------------------------------------------------------------------------
285func fc_boards(store: *TrustStore) -> i64 {
286 let url: *u8 = sys_mmap(FC_MAGIC_512)
287 fc_api_url(url, "" as *u8, "boards.json" as *u8, 0)
288 let buf: *u8 = sys_mmap(FC_JSON_CAP)
289 let st: *i64 = sys_mmap(8) as *i64
290 let n: i64 = fc_get_json(url, store, buf, st)
291 if n <= 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 }
292 if st[0] != 200 { fc_puts("{\"error\":\"http \"}\n" as *u8); return 1 }
293 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
294 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
295 let cnt: i64 = fc_scan_objects(buf, n, 2, offs, lens, FC_MAX_OBJ)
296 let out: *u8 = sys_mmap(FC_OUT_CAP)
297 var o: i64 = fc_apps(out, 0, "{\"source\":\"4chan\",\"boards\":[" as *u8)
298 let sv: *u8 = sys_mmap(FC_TEXT_CAP)
299 let iv: *i64 = sys_mmap(8) as *i64
300 var i: i64 = 0
301 var emitted: i64 = 0
302 while i < cnt {
303 let s: i64 = offs[i]
304 let e: i64 = offs[i] + lens[i]
305 if jx_get_str(buf, e, s, "board" as *u8, sv, FC_TEXT_CAP) >= 0 {
306 if emitted > 0 { out[o] = 44 as u8; o = o + 1 }
307 o = fc_apps(out, o, "{\"board\":\"" as *u8); o = fc_apps(out, o, sv)
308 o = fc_apps(out, o, "\",\"title\":\"" as *u8)
309 if jx_get_str(buf, e, s, "title" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) }
310 o = fc_apps(out, o, "\",\"nsfw\":" as *u8)
311 // ws_board=1 means WORK-SAFE, so nsfw is its INVERSE. An absent field must not silently
312 // read as "safe": jx_get_int returns -1 for absent, which we map to nsfw=1 (conservative).
313 var nsfw: i64 = 1
314 if jx_get_int(buf, e, s, "ws_board" as *u8, iv) >= 0 { if iv[0] == 1 { nsfw = 0 } }
315 o = fc_num(out, o, nsfw)
316 out[o] = 125 as u8; o = o + 1
317 emitted = emitted + 1
318 }
319 i = i + 1
320 }
321 o = fc_apps(out, o, "],\"count\":" as *u8); o = fc_num(out, o, emitted)
322 o = fc_apps(out, o, "}" as *u8); out[o] = 10 as u8; o = o + 1
323 sys_write(1, out, o)
324 return 0
325}
326
327// Shared emitter for catalog threads and thread posts. `mode` 0 = catalog (thread previews),
328// 1 = thread (media items only).
329func fc_list(store: *TrustStore, board: *u8, thread: i64, maxn: i64, mode: i64) -> i64 {
330 let url: *u8 = sys_mmap(FC_MAGIC_512)
331 if mode == 0 { fc_api_url(url, board, "catalog.json" as *u8, 0) }
332 else { fc_api_url(url, board, "thread/" as *u8, thread) }
333 let buf: *u8 = sys_mmap(FC_JSON_CAP)
334 let st: *i64 = sys_mmap(8) as *i64
335 let n: i64 = fc_get_json(url, store, buf, st)
336 if n <= 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 }
337 if st[0] == 404 { fc_puts("{\"error\":\"not found or archived\",\"count\":0}\n" as *u8); return 1 }
338 if st[0] != 200 { fc_puts("{\"error\":\"http status\",\"count\":0}\n" as *u8); return 1 }
339 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
340 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
341 let cnt: i64 = fc_scan_objects(buf, n, 2, offs, lens, FC_MAX_OBJ)
342 let out: *u8 = sys_mmap(FC_OUT_CAP)
343 var o: i64 = fc_apps(out, 0, "{\"source\":\"4chan\",\"board\":\"" as *u8)
344 o = fc_apps(out, o, board)
345 if mode == 1 { o = fc_apps(out, o, "\",\"thread\":" as *u8); o = fc_num(out, o, thread); o = fc_apps(out, o, ",\"media\":[" as *u8) }
346 else { o = fc_apps(out, o, "\",\"threads\":[" as *u8) }
347 let sv: *u8 = sys_mmap(FC_TEXT_CAP)
348 let ev: *u8 = sys_mmap(64)
349 let iv: *i64 = sys_mmap(8) as *i64
350 var i: i64 = 0
351 var emitted: i64 = 0
352 while i < cnt {
353 if emitted >= maxn { i = cnt } else {
354 let s: i64 = offs[i]
355 let e: i64 = offs[i] + lens[i]
356 var no: i64 = 0 - 1
357 if jx_get_int(buf, e, s, "no" as *u8, iv) >= 0 { no = iv[0] }
358 var tim: i64 = 0 - 1
359 if jx_get_int(buf, e, s, "tim" as *u8, iv) >= 0 { tim = iv[0] }
360 var hasext: i64 = 0
361 if jx_get_str(buf, e, s, "ext" as *u8, ev, 64) >= 0 { hasext = 1 }
362 // mode 1 keeps ONLY posts carrying media; mode 0 keeps every thread (a textual OP is
363 // still a browsable thread) and simply reports empty urls for it.
364 var keep: i64 = 1
365 if mode == 1 { keep = 0; if tim > 0 { if hasext == 1 { keep = 1 } } }
366 if no <= 0 { keep = 0 }
367 if keep == 1 {
368 if emitted > 0 { out[o] = 44 as u8; o = o + 1 }
369 o = fc_apps(out, o, "{\"no\":" as *u8); o = fc_num(out, o, no)
370 if mode == 0 {
371 o = fc_apps(out, o, ",\"sub\":\"" as *u8)
372 if jx_get_str(buf, e, s, "sub" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) }
373 o = fc_apps(out, o, "\",\"com\":\"" as *u8)
374 if jx_get_str(buf, e, s, "com" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) }
375 o = fc_apps(out, o, "\",\"replies\":" as *u8)
376 var rep: i64 = 0
377 if jx_get_int(buf, e, s, "replies" as *u8, iv) >= 0 { rep = iv[0] }
378 o = fc_num(out, o, rep)
379 o = fc_apps(out, o, ",\"images\":" as *u8)
380 var img: i64 = 0
381 if jx_get_int(buf, e, s, "images" as *u8, iv) >= 0 { img = iv[0] }
382 o = fc_num(out, o, img)
383 } else {
384 o = fc_apps(out, o, ",\"filename\":\"" as *u8)
385 if jx_get_str(buf, e, s, "filename" as *u8, sv, FC_TEXT_CAP) >= 0 { o = fc_emit_text(out, o, sv, FC_TEXT_CAP) }
386 o = fc_apps(out, o, "\",\"w\":" as *u8)
387 var w: i64 = 0
388 if jx_get_int(buf, e, s, "w" as *u8, iv) >= 0 { w = iv[0] }
389 o = fc_num(out, o, w)
390 o = fc_apps(out, o, ",\"h\":" as *u8)
391 var h: i64 = 0
392 if jx_get_int(buf, e, s, "h" as *u8, iv) >= 0 { h = iv[0] }
393 o = fc_num(out, o, h)
394 o = fc_apps(out, o, ",\"bytes\":" as *u8)
395 var fs: i64 = 0
396 if jx_get_int(buf, e, s, "fsize" as *u8, iv) >= 0 { fs = iv[0] }
397 o = fc_num(out, o, fs)
398 o = fc_apps(out, o, ",\"kind\":\"" as *u8)
399 var vid: i64 = 0
400 if hasext == 1 { if fc_streq(ev, ".webm" as *u8) == 1 { vid = 1 } }
401 if hasext == 1 { if fc_streq(ev, ".mp4" as *u8) == 1 { vid = 1 } }
402 if vid == 1 { o = fc_apps(out, o, "video" as *u8) } else { o = fc_apps(out, o, "image" as *u8) }
403 o = fc_apps(out, o, "\"" as *u8)
404 }
405 o = fc_apps(out, o, ",\"thumb\":\"" as *u8)
406 if tim > 0 { if hasext == 1 { o = fc_media_url(out, o, board, tim, ev, 1) } }
407 o = fc_apps(out, o, "\",\"full\":\"" as *u8)
408 if tim > 0 { if hasext == 1 { o = fc_media_url(out, o, board, tim, ev, 0) } }
409 o = fc_apps(out, o, "\"}" as *u8)
410 emitted = emitted + 1
411 }
412 i = i + 1
413 }
414 }
415 o = fc_apps(out, o, "],\"count\":" as *u8); o = fc_num(out, o, emitted)
416 o = fc_apps(out, o, "}" as *u8); out[o] = 10 as u8; o = o + 1
417 sys_write(1, out, o)
418 return 0
419}
420
421// ---------------------------------------------------------------------------------------------
422func fc_fetch(store: *TrustStore, board: *u8, thread: i64, dir: *u8, maxn: i64) -> i64 {
423 let url: *u8 = sys_mmap(FC_MAGIC_512)
424 fc_api_url(url, board, "thread/" as *u8, thread)
425 let buf: *u8 = sys_mmap(FC_JSON_CAP)
426 let st: *i64 = sys_mmap(8) as *i64
427 let n: i64 = fc_get_json(url, store, buf, st)
428 if n <= 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 }
429 if st[0] != 200 { fc_puts("{\"error\":\"thread not available\"}\n" as *u8); return 1 }
430 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
431 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
432 let cnt: i64 = fc_scan_objects(buf, n, 2, offs, lens, FC_MAX_OBJ)
433 fc_mkdir_p(dir) // every component, idempotent
434 let ev: *u8 = sys_mmap(64)
435 let iv: *i64 = sys_mmap(8) as *i64
436 let u: *u8 = sys_mmap(FC_MAGIC_1024)
437 let name: *u8 = sys_mmap(128)
438 var got: i64 = 0
439 var failed: i64 = 0
440 var seen: i64 = 0
441 var i: i64 = 0
442 while i < cnt {
443 if got >= maxn { i = cnt } else {
444 let s: i64 = offs[i]
445 let e: i64 = offs[i] + lens[i]
446 var tim: i64 = 0 - 1
447 if jx_get_int(buf, e, s, "tim" as *u8, iv) >= 0 { tim = iv[0] }
448 var hasext: i64 = 0
449 if jx_get_str(buf, e, s, "ext" as *u8, ev, 64) >= 0 { hasext = 1 }
450 if tim > 0 { if hasext == 1 {
451 seen = seen + 1
452 // <tim><ext>: 4chan's tim is unique per board and already a de-dup key, so re-running
453 // the same thread overwrites rather than accumulating copies. Content-level dedup is
454 // the vault's job (CID), not this organ's -- one deduplicator, not two.
455 var no2: i64 = fc_num(name, 0, tim)
456 no2 = fc_apps(name, no2, ev)
457 name[no2] = 0 as u8
458 let ue: i64 = fc_media_url(u, 0, board, tim, ev, 0)
459 u[ue] = 0 as u8
460 let pp: *u8 = sys_mmap(FC_MAGIC_2048)
461 var po: i64 = fc_apps(pp, 0, dir); pp[po] = 47 as u8; po = po + 1
462 po = fc_apps(pp, po, name); pp[po] = 0 as u8
463 let stt: *i64 = sys_mmap(8) as *i64
464 var okf: i64 = 0
465 var tries: i64 = 0
466 while tries < FC_FETCH_RETRIES {
467 // THE INTERVAL IS THE PACER'S JOB NOW. pace_before blocks until this host's next
468 // allowed slot: a >=1s base (PACE_BASE_MS), widened by exponential backoff whenever
469 // pace_after saw a throttle, and PERSISTED on disk so a second `fetch` invocation
470 // cannot reset politeness to zero the way the old per-process sleep did.
471 pace_before(FC_IMG_HOST, FC_IMG_HOST_LEN)
472 let pid: i64 = sys_fork()
473 if pid == 0 { let rc: i64 = fc_fetch_one(u, store, dir, name); sys_exit(rc) }
474 sys_wait4(pid, stt, 0)
475 // Trust the FILE, not the exit status: confirm the child actually left bytes behind.
476 let fd: i64 = sys_openat_rd(pp)
477 if fd >= 0 { sys_close(fd); okf = 1; tries = FC_FETCH_RETRIES } else { tries = tries + 1 }
478 }
479 if okf == 1 { got = got + 1 } else { failed = failed + 1 }
480 } }
481 i = i + 1
482 }
483 }
484 let out: *u8 = sys_mmap(FC_MAGIC_2048)
485 var o: i64 = fc_apps(out, 0, "{\"source\":\"4chan\",\"board\":\"" as *u8)
486 o = fc_apps(out, o, board)
487 o = fc_apps(out, o, "\",\"thread\":" as *u8); o = fc_num(out, o, thread)
488 o = fc_apps(out, o, ",\"media_seen\":" as *u8); o = fc_num(out, o, seen)
489 o = fc_apps(out, o, ",\"downloaded\":" as *u8); o = fc_num(out, o, got)
490 o = fc_apps(out, o, ",\"failed\":" as *u8); o = fc_num(out, o, failed)
491 o = fc_apps(out, o, ",\"dir\":\"" as *u8); o = fc_apps(out, o, dir)
492 // The vault, not this organ, owns classification/dedup/layout -- name the handoff explicitly so
493 // the next step is discoverable from the output itself rather than from tribal knowledge.
494 o = fc_apps(out, o, "\",\"vault_next\":\"nx_mvault_walk <dir> real 4chan commit\"}" as *u8)
495 out[o] = 10 as u8; o = o + 1
496 sys_write(1, out, o)
497 if got <= 0 { return 1 }
498 return 0
499}
500
501// ---------------------------------------------------------------------------------------------
502// OFFLINE GATE. Every tooth runs on a fixture built at RUNTIME (a detector that scans source finds
503// its own fixture). The neg-controls are the point: two of them encode bugs this organ WOULD have
504// had with the obvious implementation.
505func fc_selftest() -> i64 {
506 var pass: i64 = 0
507 var total: i64 = 0
508 let offs: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
509 let lens: *i64 = sys_mmap(8 * FC_MAX_OBJ) as *i64
510 let iv: *i64 = sys_mmap(8) as *i64
511 let sv: *u8 = sys_mmap(FC_TEXT_CAP)
512
513 // A catalog-shaped fixture: ONE thread carrying a last_replies array of TWO replies. The naive
514 // `"no":`-scanner reports 3 threads here; the depth scanner must report exactly 1.
515 let fx: *u8 = sys_mmap(FC_MAGIC_65536)
516 var f: i64 = fc_apps(fx, 0, "[{\"page\":1,\"threads\":[" as *u8)
517 f = fc_apps(fx, f, "{\"no\":111,\"sub\":\"A\",\"com\":\"x\",\"tim\":1700000000001,\"ext\":\".jpg\",\"replies\":7,\"images\":3," as *u8)
518 f = fc_apps(fx, f, "\"last_replies\":[{\"no\":222,\"com\":\"r1\"},{\"no\":333,\"com\":\"r2\"}]}" as *u8)
519 f = fc_apps(fx, f, "]}]" as *u8)
520
521 total = total + 1
522 let c1: i64 = fc_scan_objects(fx, f, 2, offs, lens, FC_MAX_OBJ)
523 fc_puts("T1 depth-scan threads=" as *u8); fc_putn(c1); fc_puts(" (want 1)\n" as *u8)
524 if c1 == 1 { pass = pass + 1 }
525
526 // T2 neg-control: the SAME fixture at depth 3 must find the two replies -- proving T1's answer
527 // comes from depth discrimination and not from the scanner simply finding one object always.
528 total = total + 1
529 let c2: i64 = fc_scan_objects(fx, f, 3, offs, lens, FC_MAX_OBJ)
530 fc_puts("T2 neg-control replies-at-depth3=" as *u8); fc_putn(c2); fc_puts(" (want 2)\n" as *u8)
531 if c2 == 2 { pass = pass + 1 }
532
533 // T3 field extraction inside the thread's bounds
534 total = total + 1
535 fc_scan_objects(fx, f, 2, offs, lens, FC_MAX_OBJ)
536 var t3: i64 = 0
537 if jx_get_int(fx, offs[0] + lens[0], offs[0], "no" as *u8, iv) >= 0 { if iv[0] == 111 { t3 = t3 + 1 } }
538 if jx_get_int(fx, offs[0] + lens[0], offs[0], "tim" as *u8, iv) >= 0 { if iv[0] == FC_MAGIC_1700000000001 { t3 = t3 + 1 } }
539 if jx_get_int(fx, offs[0] + lens[0], offs[0], "replies" as *u8, iv) >= 0 { if iv[0] == 7 { t3 = t3 + 1 } }
540 fc_puts("T3 fields ok=" as *u8); fc_putn(t3); fc_puts("/3\n" as *u8)
541 if t3 == 3 { pass = pass + 1 }
542
543 // T4 THE BOUNDING TOOTH (neg-control for the commonest silent bug): a record whose field is
544 // ABSENT must NOT inherit the NEXT record's value. Unbounded, jx_get_int scans to end-of-buffer
545 // and returns 9 here; bounded to the record it must honestly report absent (-1).
546 total = total + 1
547 let fx2: *u8 = sys_mmap(FC_MAGIC_65536)
548 var g: i64 = fc_apps(fx2, 0, "[{\"p\":1,\"t\":[{\"no\":1},{\"no\":2,\"replies\":9}]}]" as *u8)
549 let c4: i64 = fc_scan_objects(fx2, g, 2, offs, lens, FC_MAX_OBJ)
550 var t4: i64 = 0
551 if c4 == 2 {
552 let bounded: i64 = jx_get_int(fx2, offs[0] + lens[0], offs[0], "replies" as *u8, iv)
553 let leaked: i64 = jx_get_int(fx2, g, offs[0], "replies" as *u8, iv)
554 if bounded < 0 { t4 = 1 }
555 fc_puts("T4 bounded=" as *u8); fc_putn(bounded); fc_puts(" unbounded-leaks=" as *u8); fc_putn(leaked); fc_puts(" (bounded must be -1)\n" as *u8)
556 }
557 if t4 == 1 { pass = pass + 1 }
558
559 // T5 a STRING value must not read as a number -- "absent" and "zero" must stay distinguishable
560 total = total + 1
561 let fx3: *u8 = sys_mmap(FC_MAGIC_1024)
562 let h: i64 = fc_apps(fx3, 0, "{\"a\":{\"n\":\"12\"}}" as *u8)
563 var t5: i64 = 0
564 if jx_get_int(fx3, h, 0, "n" as *u8, iv) < 0 { t5 = 1 }
565 fc_puts("T5 string-value-not-int ok=" as *u8); fc_putn(t5); fc_puts("\n" as *u8)
566 if t5 == 1 { pass = pass + 1 }
567
568 // T6 braces INSIDE a string are data. A structure-blind scanner mis-nests and finds 2 objects.
569 total = total + 1
570 let fx4: *u8 = sys_mmap(FC_MAGIC_1024)
571 let k: i64 = fc_apps(fx4, 0, "[{\"p\":[{\"com\":\"a { b } c \\\" d\",\"no\":5}]}]" as *u8)
572 let c6: i64 = fc_scan_objects(fx4, k, 2, offs, lens, FC_MAX_OBJ)
573 fc_puts("T6 string-aware objects=" as *u8); fc_putn(c6); fc_puts(" (want 1)\n" as *u8)
574 if c6 == 1 { pass = pass + 1 }
575
576 // T7 HTML/entity scrub, and the output must stay JSON-safe
577 total = total + 1
578 let src: *u8 = sys_mmap(FC_MAGIC_1024)
579 fc_apps(src, 0, "hi<br>there<wbr>x >quote & \"q\"" as *u8)
580 src[fc_slen("hi<br>there<wbr>x >quote & \"q\"" as *u8)] = 0 as u8
581 let ob: *u8 = sys_mmap(FC_MAGIC_1024)
582 let oe: i64 = fc_emit_text(ob, 0, src, FC_MAGIC_1024)
583 ob[oe] = 0 as u8
584 var t7: i64 = 1
585 var z: i64 = 0
586 while z < oe { if (ob[z] as i64) == 60 { if z + 1 < oe { if (ob[z+1] as i64) == 98 { t7 = 0 } } } z = z + 1 } // no surviving <b..> tag
587 // a bare (unescaped) double-quote in the output would tear the JSON string
588 z = 0
589 while z < oe { if (ob[z] as i64) == 34 { if z == 0 { t7 = 0 } else { if (ob[z-1] as i64) != 92 { t7 = 0 } } } z = z + 1 }
590 fc_puts("T7 scrub -> [" as *u8); fc_puts(ob); fc_puts("] ok=" as *u8); fc_putn(t7); fc_puts("\n" as *u8)
591 if t7 == 1 { pass = pass + 1 }
592
593 // T8 media-url shape (thumbnail is ALWAYS .jpg even for a .webm post -- a real 4chan rule that a
594 // naive "use ext for both" implementation gets wrong and only notices as broken previews)
595 total = total + 1
596 let ub: *u8 = sys_mmap(FC_MAGIC_512)
597 let ue1: i64 = fc_media_url(ub, 0, "gif" as *u8, FC_MAGIC_1700000000001, ".webm" as *u8, 1)
598 ub[ue1] = 0 as u8
599 var t8: i64 = 0
600 if fc_streq(ub, "https://i.4cdn.org/gif/1700000000001s.jpg" as *u8) == 1 { t8 = 1 }
601 fc_puts("T8 thumb-url [" as *u8); fc_puts(ub); fc_puts("] ok=" as *u8); fc_putn(t8); fc_puts("\n" as *u8)
602 if t8 == 1 { pass = pass + 1 }
603
604 // T9 truncated JPEG must be REJECTED (header valid, trailer missing) and a complete one accepted
605 total = total + 1
606 let jb: *u8 = sys_mmap(FC_MAGIC_1024)
607 var q: i64 = 0
608 while q < FC_MIN_BYTES + 8 { jb[q] = 65 as u8; q = q + 1 }
609 jb[0] = 0xff as u8; jb[1] = 0xd8 as u8
610 let bad: i64 = fc_media_complete(jb, FC_MIN_BYTES + 8)
611 jb[FC_MIN_BYTES + 6] = 0xff as u8; jb[FC_MIN_BYTES + 7] = 0xd9 as u8
612 let good: i64 = fc_media_complete(jb, FC_MIN_BYTES + 8)
613 fc_puts("T9 truncated=" as *u8); fc_putn(bad); fc_puts(" complete=" as *u8); fc_putn(good); fc_puts(" (want 0 then 1)\n" as *u8)
614 if bad == 0 { if good == 1 { pass = pass + 1 } }
615
616 // T10 \uXXXX must DECODE, not leak as text. Caught live: 4chan sends "Anime & Manga" and the
617 // old jx_copy_str emitted "Anime u0026 Manga" -- a corruption visible only to a human reading a
618 // title, which is why no structural test ever saw it. The backslash is CONSTRUCTED as a byte: a
619 // bare one in a literal is ambiguous to the nx lexer.
620 total = total + 1
621 let ux: *u8 = sys_mmap(FC_MAGIC_512)
622 var uo: i64 = fc_apps(ux, 0, "Anime " as *u8)
623 ux[uo] = 92 as u8; uo = uo + 1
624 uo = fc_apps(ux, uo, "u0026 Manga" as *u8)
625 ux[uo] = 34 as u8; uo = uo + 1 // closing quote ends the JSON string
626 let ud: *u8 = sys_mmap(FC_MAGIC_512)
627 jx_copy_str(ux, uo, 0, ud, FC_MAGIC_512)
628 var t10: i64 = 0
629 if fc_streq(ud, "Anime & Manga" as *u8) == 1 { t10 = 1 }
630 var t10bad: i64 = 0
631 if fc_streq(ud, "Anime u0026 Manga" as *u8) == 1 { t10bad = 1 } // the exact old output
632 fc_puts("T10 u-escape [" as *u8); fc_puts(ud); fc_puts("] ok=" as *u8); fc_putn(t10)
633 fc_puts(" old-corruption-present=" as *u8); fc_putn(t10bad); fc_puts("\n" as *u8)
634 if t10 == 1 { if t10bad == 0 { pass = pass + 1 } }
635
636 // T11 mkdir -p must create EVERY component. Scratch lives in /tmp/<gate>/ and is torn down at
637 // SETUP, not teardown: a crashed run never reaches a teardown, and a gate that is not idempotent
638 // reports honestly on its first run and lies on every run after.
639 total = total + 1
640 let rootp: *u8 = sys_mmap(FC_MAGIC_512); let r0: i64 = fc_apps(rootp, 0, "/tmp/nx4c_gate_p" as *u8); rootp[r0] = 0 as u8
641 let d1: *u8 = sys_mmap(FC_MAGIC_512); let r1: i64 = fc_apps(d1, 0, "/tmp/nx4c_gate_p/a" as *u8); d1[r1] = 0 as u8
642 let d2: *u8 = sys_mmap(FC_MAGIC_512); let r2: i64 = fc_apps(d2, 0, "/tmp/nx4c_gate_p/a/b" as *u8); d2[r2] = 0 as u8
643 let fpath: *u8 = sys_mmap(FC_MAGIC_512); let r3: i64 = fc_apps(fpath, 0, "/tmp/nx4c_gate_p/a/b/probe.bin" as *u8); fpath[r3] = 0 as u8
644 __syscall(263, 0 - 100, fpath as i64, 0, 0, 0, 0) // unlink probe
645 __syscall(263, 0 - 100, d2 as i64, 0x200, 0, 0, 0) // rmdir b (AT_REMOVEDIR)
646 __syscall(263, 0 - 100, d1 as i64, 0x200, 0, 0, 0) // rmdir a
647 __syscall(263, 0 - 100, rootp as i64, 0x200, 0, 0, 0) // rmdir root
648 // NEG CONTROL: with the parents gone this open MUST fail. Without it the tooth would pass even if
649 // fc_mkdir_p did nothing at all, because the directory might already exist from a previous run.
650 let pre: i64 = sys_openat_wr(fpath, 0x1a4)
651 if pre >= 0 { sys_close(pre) }
652 fc_mkdir_p(d2)
653 let post: i64 = sys_openat_wr(fpath, 0x1a4)
654 var t11: i64 = 0
655 if pre < 0 { if post >= 0 { t11 = 1 } }
656 if post >= 0 { sys_close(post) }
657 fc_puts("T11 mkdir-p neg-control-pre=" as *u8); fc_putn(pre); fc_puts(" post=" as *u8); fc_putn(post)
658 fc_puts(" ok=" as *u8); fc_putn(t11); fc_puts("\n" as *u8)
659 if t11 == 1 { pass = pass + 1 }
660
661 fc_puts("NX4CHAN-SELFTEST passed " as *u8); fc_putn(pass); fc_puts("/" as *u8); fc_putn(total)
662 if pass == total { fc_puts(" verdict=GREEN\n" as *u8); return 0 }
663 fc_puts(" verdict=RED\n" as *u8)
664 return 1
665}
666
667func fc_atoi(s: *u8) -> i64 {
668 var v: i64 = 0
669 var i: i64 = 0
670 while s[i] != (0 as u8) { let c: i64 = s[i] as i64; if c >= 48 { if c <= 57 { v = (v * 10) + (c - 48) } } i = i + 1 }
671 return v
672}
673
674// Fetch ONE media file (board/<file>, e.g. 1700s.jpg thumb or 1700.png full) into <dir>/<file>.
675// Backs BOTH the same-origin image PROXY (the gallery CSP blocks direct i.4cdn.org loads) and
676// save-to-gallery. url is built from board+file (both charset-whitelisted by the caller for execve).
677func fc_saveone(store: *TrustStore, board: *u8, file: *u8, dir: *u8) -> i64 {
678 let u: *u8 = sys_mmap(FC_MAGIC_512)
679 var uo: i64 = fc_apps(u, 0, "https://i.4cdn.org/" as *u8)
680 uo = fc_apps(u, uo, board); u[uo] = 47 as u8; uo = uo + 1
681 uo = fc_apps(u, uo, file); u[uo] = 0 as u8
682 fc_mkdir_p(dir)
683 let rc: i64 = fc_fetch_one(u, store, dir, file)
684 if rc != 0 { fc_puts("{\"error\":\"fetch failed\"}\n" as *u8); return 1 }
685 let out: *u8 = sys_mmap(FC_MAGIC_1024)
686 var o: i64 = fc_apps(out, 0, "{\"saved\":\"" as *u8)
687 o = fc_apps(out, o, dir); out[o] = 47 as u8; o = o + 1
688 o = fc_apps(out, o, file); o = fc_apps(out, o, "\"}\n" as *u8)
689 sys_write(1, out, o)
690 return 0
691}
692func main(argc: i64, argv: *i64) -> i64 {
693 if argc < 2 {
694 fc_puts("usage: nx_4chan boards | catalog <board> [max] | thread <board> <no> [max] | fetch <board> <no> <dir> [max] | selftest\n" as *u8)
695 sys_exit(2); return 2
696 }
697 let verb: *u8 = argv[1] as *u8
698 if fc_streq(verb, "selftest" as *u8) == 1 { let r: i64 = fc_selftest(); sys_exit(r); return r }
699
700 let tr: i64 = nx_trust_store_load_from_certdata("data/mozilla_certdata.txt" as *u8, 512, FC_MAGIC_4194304)
701 if tr <= 0 { fc_puts("{\"error\":\"certdata load failed (run from the tree root)\"}\n" as *u8); sys_exit(1); return 1 }
702 let store: *TrustStore = tr as *TrustStore
703
704 if fc_streq(verb, "boards" as *u8) == 1 { let r: i64 = fc_boards(store); sys_exit(r); return r }
705 if fc_streq(verb, "catalog" as *u8) == 1 {
706 if argc < 3 { fc_puts("usage: nx_4chan catalog <board> [max]\n" as *u8); sys_exit(2); return 2 }
707 var mx: i64 = FC_MAX_OBJ
708 if argc > 3 { mx = fc_atoi(argv[3] as *u8) }
709 let r: i64 = fc_list(store, argv[2] as *u8, 0, mx, 0); sys_exit(r); return r
710 }
711 if fc_streq(verb, "thread" as *u8) == 1 {
712 if argc < 4 { fc_puts("usage: nx_4chan thread <board> <no> [max]\n" as *u8); sys_exit(2); return 2 }
713 var mx2: i64 = FC_MAX_OBJ
714 if argc > 4 { mx2 = fc_atoi(argv[4] as *u8) }
715 let r: i64 = fc_list(store, argv[2] as *u8, fc_atoi(argv[3] as *u8), mx2, 1); sys_exit(r); return r
716 }
717 if fc_streq(verb, "fetch" as *u8) == 1 {
718 if argc < 5 { fc_puts("usage: nx_4chan fetch <board> <no> <dir> [max]\n" as *u8); sys_exit(2); return 2 }
719 var mx3: i64 = FC_MAX_OBJ
720 if argc > 5 { mx3 = fc_atoi(argv[5] as *u8) }
721 let r: i64 = fc_fetch(store, argv[2] as *u8, fc_atoi(argv[3] as *u8), argv[4] as *u8, mx3); sys_exit(r); return r
722 }
723 if fc_streq(verb, "saveone" as *u8) == 1 {
724 if argc < 5 { fc_puts("usage: nx_4chan saveone <board> <file> <dir>\n" as *u8); sys_exit(2); return 2 }
725 let r: i64 = fc_saveone(store, argv[2] as *u8, argv[3] as *u8, argv[4] as *u8); sys_exit(r); return r
726 }
727 fc_puts("unknown verb\n" as *u8)
728 sys_exit(2); return 2
729}