nx_url_resolve.nx source
↩ module page · 367 lines · 14949 B
1// nx_url_resolve.nx -- RFC 3986 §5.2 relative-URL resolution.
2//
3// Given a base URL (assumed absolute: scheme://authority/path...)
4// and a reference URL (absolute, scheme-relative, path-absolute,
5// path-relative, or fragment-only), compute the resolved absolute
6// URL and write it to caller-supplied output buffer.
7//
8// This is the substrate primitive Arc B2 needs to turn the raw
9// <img src="..."> values harvested from a Google response into
10// fetchable absolute URLs.
11//
12// Covered cases (in roughly the order RFC 3986 §5.2 specifies):
13// - rel is absolute (has its own scheme): output = rel verbatim
14// - rel starts with "//": output = base.scheme + ":" + rel
15// - rel starts with "/": output = base.scheme + "://" + base.authority + rel
16// - rel is empty or "#frag": output = base with optional new fragment
17// - rel is path-relative: output = base.scheme + "://" + base.authority +
18// merged-and-dot-resolved path + rel.query/frag
19//
20// Out of scope (deferred to a real-Google-response-driven slice):
21// - userinfo (user:pass@host)
22// - IPv6 [..] hosts
23// - punycode/IDN
24// - normalize_case of host (spec says lower)
25// - percent-decode/re-encode round-trip
26//
27// nx_safety_envelope:
28// intended_use: "Relative-URL resolution for HTML href/src in
29// the bits-up browser substrate."
30// sil_target: SIL1
31// evidence: [RFC3986_section_5_2_canonical_basis,
32// bounded_output_buffer,
33// substrate_honest_truncation_signal]
34// hazard_register: [bug-tape-path-traversal-via-dot-dot,
35// bug-tape-host-substitution-via-malformed-base]
36// residual_risk: "Caller is responsible for sandboxing fetch
37// targets; resolver does not enforce origin
38// policy."
39// verdict: NOT_YET_EVALUATED
40
41import "nx_syscalls.nx"
42
43// Return values.
44const NX_URL_RESOLVE_OK: i64 = 0
45const NX_URL_RESOLVE_TRUNC: i64 = -1 // output buffer too small
46const NX_URL_RESOLVE_BAD_BASE: i64 = -2 // base is not a usable absolute URL
47
48func _ur_lc(b: i64) -> i64 {
49 if b >= 65 { if b <= 90 { return b + 32 } }
50 return b
51}
52
53// Find ':' before any '/' or '?' or '#' -- presence of a scheme.
54// Returns the offset of ':' or -1.
55func _ur_find_scheme_colon(s: *u8, slen: i64) -> i64 {
56 if slen < 2 { return 0 - 1 }
57 // First byte must be alpha.
58 let c0: i64 = _ur_lc(s[0])
59 if c0 < 97 { return 0 - 1 }
60 if c0 > 122 { return 0 - 1 }
61 var i: i64 = 1
62 while i < slen {
63 let b: i64 = s[i]
64 if b == 58 { return i } // ':'
65 if b == 47 { return 0 - 1 } // '/'
66 if b == 63 { return 0 - 1 } // '?'
67 if b == 35 { return 0 - 1 } // '#'
68 let lcb: i64 = _ur_lc(b)
69 var ok: i64 = 0
70 if lcb >= 97 { if lcb <= 122 { ok = 1 } }
71 if b >= 48 { if b <= 57 { ok = 1 } } // digit
72 if b == 43 { ok = 1 } // '+'
73 if b == 45 { ok = 1 } // '-'
74 if b == 46 { ok = 1 } // '.'
75 if ok == 0 { return 0 - 1 }
76 i = i + 1
77 }
78 return 0 - 1
79}
80
81// Write byte to out, returning new pos, or -1 if would overflow.
82func _ur_putc(out: *u8, pos: i64, cap: i64, b: i64) -> i64 {
83 if pos >= cap { return 0 - 1 }
84 out[pos] = b as u8
85 return pos + 1
86}
87
88// Copy src[so..so+sn] to out, returning new pos, or -1 on overflow.
89func _ur_copy(out: *u8, pos: i64, cap: i64, src: *u8, so: i64, sn: i64) -> i64 {
90 var p: i64 = pos
91 var i: i64 = 0
92 while i < sn {
93 if p >= cap { return 0 - 1 }
94 out[p] = src[so + i]
95 p = p + 1
96 i = i + 1
97 }
98 return p
99}
100
101// Remove "." and ".." segments per RFC 3986 §5.2.4. Operates on a
102// path string (which may start with '/'). Writes resolved path to
103// out starting at pos.
104//
105// Algorithm: scan path left-to-right, maintaining a stack of
106// already-emitted segments (their start offsets in out). For each
107// segment:
108// - "." -> skip
109// - ".." -> pop last emitted segment
110// - other -> append
111// Preserves leading '/' if original path had one.
112func _ur_resolve_dot_segments(path: *u8, plen: i64,
113 out: *u8, pos_in: i64, cap: i64,
114 seg_off: *i64, seg_cnt_p: *i64) -> i64 {
115 var p: i64 = pos_in
116 var seg_cnt: i64 = 0
117 var i: i64 = 0
118 var has_leading_slash: i64 = 0
119 var last_was_dot: i64 = 0 // last segment was "." or ".."
120 if plen > 0 { if path[0] == 47 { has_leading_slash = 1; i = 1 } }
121 while i < plen {
122 // Find segment bounds: seg_start..seg_end where seg_end is next '/' or plen.
123 let seg_start: i64 = i
124 var seg_end: i64 = plen
125 var scan: i64 = i
126 while scan < plen {
127 if path[scan] == 47 { seg_end = scan; scan = plen }
128 else { scan = scan + 1 }
129 }
130 let seg_len: i64 = seg_end - seg_start
131 // Advance i past the segment AND the trailing '/' (if any).
132 i = seg_end
133 if i < plen { if path[i] == 47 { i = i + 1 } }
134 // Classify.
135 var kind: i64 = 0 // 0 normal, 1 ".", 2 ".."
136 if seg_len == 1 { if path[seg_start] == 46 { kind = 1 } }
137 if seg_len == 2 { if path[seg_start] == 46 { if path[seg_start + 1] == 46 { kind = 2 } } }
138 if kind == 0 {
139 var emit_slash: i64 = 0
140 if has_leading_slash == 1 { emit_slash = 1 }
141 else { if seg_cnt > 0 { emit_slash = 1 } }
142 if emit_slash == 1 {
143 p = _ur_putc(out, p, cap, 47)
144 if p < 0 { return 0 - 1 }
145 }
146 seg_off[seg_cnt] = p
147 p = _ur_copy(out, p, cap, path, seg_start, seg_len)
148 if p < 0 { return 0 - 1 }
149 seg_cnt = seg_cnt + 1
150 last_was_dot = 0
151 }
152 if kind == 2 {
153 if seg_cnt > 0 {
154 seg_cnt = seg_cnt - 1
155 p = seg_off[seg_cnt]
156 if has_leading_slash == 1 { if p > pos_in { p = p - 1 } } // drop preceding '/'
157 }
158 last_was_dot = 1
159 }
160 if kind == 1 { last_was_dot = 1 }
161 }
162 // Trailing-slash preservation per RFC 3986 §5.2.4 step 2D:
163 // - input ended with '/' -> output ends with '/'
164 // - input's final segment was "." or ".." -> output ends with '/'
165 if plen > 0 {
166 var need_trailing: i64 = 0
167 if path[plen - 1] == 47 { need_trailing = 1 }
168 if last_was_dot == 1 { need_trailing = 1 }
169 if need_trailing == 1 {
170 // For absolute paths we always want at least "/"; for empty
171 // output that means emit "/".
172 if p == pos_in {
173 if has_leading_slash == 1 {
174 p = _ur_putc(out, p, cap, 47)
175 if p < 0 { return 0 - 1 }
176 }
177 } else {
178 if out[p - 1] != 47 {
179 p = _ur_putc(out, p, cap, 47)
180 if p < 0 { return 0 - 1 }
181 }
182 }
183 }
184 }
185 seg_cnt_p[0] = seg_cnt
186 return p
187}
188
189// Top-level: resolve `rel` against `base`, emit into `out`.
190// Returns NX_URL_RESOLVE_OK on success or a negative error code.
191// out_len_p receives the byte length written.
192func nx_url_resolve(base: *u8, base_len: i64,
193 rel: *u8, rel_len: i64,
194 out: *u8, out_cap: i64,
195 out_len_p: *i64) -> i64 {
196 var pos: i64 = 0
197
198 // Case 1: rel is empty -> resolved = base.
199 if rel_len == 0 {
200 pos = _ur_copy(out, pos, out_cap, base, 0, base_len)
201 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
202 out_len_p[0] = pos
203 return NX_URL_RESOLVE_OK
204 }
205
206 // Case 2: rel has its own scheme -> use rel verbatim.
207 let rel_scheme_colon: i64 = _ur_find_scheme_colon(rel, rel_len)
208 if rel_scheme_colon > 0 {
209 pos = _ur_copy(out, pos, out_cap, rel, 0, rel_len)
210 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
211 out_len_p[0] = pos
212 return NX_URL_RESOLVE_OK
213 }
214
215 // Need base to be absolute (have a scheme) for any of the
216 // remaining cases.
217 let base_scheme_colon: i64 = _ur_find_scheme_colon(base, base_len)
218 if base_scheme_colon <= 0 { return NX_URL_RESOLVE_BAD_BASE }
219
220 // Extract base.scheme + ":" prefix length.
221 let scheme_prefix_len: i64 = base_scheme_colon + 1
222
223 // Base must have "//" right after the scheme ':' for our subset.
224 if scheme_prefix_len + 2 > base_len { return NX_URL_RESOLVE_BAD_BASE }
225 if base[scheme_prefix_len] != 47 { return NX_URL_RESOLVE_BAD_BASE }
226 if base[scheme_prefix_len + 1] != 47 { return NX_URL_RESOLVE_BAD_BASE }
227
228 // Find base authority (host[:port]) end -- first '/' or '?' or '#' or end.
229 let auth_start: i64 = scheme_prefix_len + 2
230 var auth_end: i64 = base_len
231 var ae_scan: i64 = auth_start
232 while ae_scan < base_len {
233 let b: i64 = base[ae_scan]
234 if b == 47 { auth_end = ae_scan; ae_scan = base_len }
235 else { if b == 63 { auth_end = ae_scan; ae_scan = base_len }
236 else { if b == 35 { auth_end = ae_scan; ae_scan = base_len }
237 else { ae_scan = ae_scan + 1 } } }
238 }
239
240 // Base path = from auth_end up to '?' or '#' or end.
241 var path_end: i64 = base_len
242 var pe_scan: i64 = auth_end
243 while pe_scan < base_len {
244 let pb: i64 = base[pe_scan]
245 if pb == 63 { path_end = pe_scan; pe_scan = base_len }
246 else { if pb == 35 { path_end = pe_scan; pe_scan = base_len }
247 else { pe_scan = pe_scan + 1 } }
248 }
249 let base_path_off: i64 = auth_end
250 let base_path_len: i64 = path_end - auth_end
251
252 // Case 3: rel starts with "//" -> scheme-relative.
253 if rel_len >= 2 {
254 if rel[0] == 47 { if rel[1] == 47 {
255 // out = base.scheme + ":" + rel
256 pos = _ur_copy(out, pos, out_cap, base, 0, scheme_prefix_len)
257 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
258 pos = _ur_copy(out, pos, out_cap, rel, 0, rel_len)
259 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
260 out_len_p[0] = pos
261 return NX_URL_RESOLVE_OK
262 } }
263 }
264
265 // For all remaining cases we emit base.scheme + "://" + base.authority first.
266 pos = _ur_copy(out, pos, out_cap, base, 0, auth_end)
267 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
268
269 // Case 4: rel starts with "/" -> path-absolute.
270 if rel[0] == 47 {
271 // Apply remove-dot-segments to rel-as-path; append rel-as-suffix.
272 // Simpler: take rel up to '?' or '#' as path, the rest as suffix.
273 var rel_path_end: i64 = rel_len
274 var rpe1_scan: i64 = 0
275 while rpe1_scan < rel_len {
276 let rb: i64 = rel[rpe1_scan]
277 if rb == 63 { rel_path_end = rpe1_scan; rpe1_scan = rel_len }
278 else { if rb == 35 { rel_path_end = rpe1_scan; rpe1_scan = rel_len }
279 else { rpe1_scan = rpe1_scan + 1 } }
280 }
281 let seg_off: *i64 = sys_mmap(2048) as *i64 // 256 segments
282 let seg_cnt_p: *i64 = sys_mmap(8) as *i64
283 pos = _ur_resolve_dot_segments(rel, rel_path_end, out, pos, out_cap, seg_off, seg_cnt_p)
284 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
285 if rel_path_end < rel_len {
286 pos = _ur_copy(out, pos, out_cap, rel, rel_path_end, rel_len - rel_path_end)
287 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
288 }
289 out_len_p[0] = pos
290 return NX_URL_RESOLVE_OK
291 }
292
293 // Case 5: rel is fragment-only -> base path + base query + rel fragment.
294 if rel[0] == 35 {
295 // emit base path + query (everything from auth_end up to '#' or end of base).
296 var base_no_frag_end: i64 = base_len
297 var bnf_scan: i64 = auth_end
298 while bnf_scan < base_len {
299 if base[bnf_scan] == 35 { base_no_frag_end = bnf_scan; bnf_scan = base_len }
300 else { bnf_scan = bnf_scan + 1 }
301 }
302 pos = _ur_copy(out, pos, out_cap, base, auth_end, base_no_frag_end - auth_end)
303 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
304 pos = _ur_copy(out, pos, out_cap, rel, 0, rel_len)
305 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
306 out_len_p[0] = pos
307 return NX_URL_RESOLVE_OK
308 }
309
310 // Case 6: rel is query-only.
311 if rel[0] == 63 {
312 // base path (no query no frag) + rel.
313 pos = _ur_copy(out, pos, out_cap, base, auth_end, base_path_len)
314 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
315 pos = _ur_copy(out, pos, out_cap, rel, 0, rel_len)
316 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
317 out_len_p[0] = pos
318 return NX_URL_RESOLVE_OK
319 }
320
321 // Case 7: rel is path-relative. Merge base path dirname + rel path
322 // then apply remove-dot-segments.
323 var rel_path_end: i64 = rel_len
324 var rpe_scan: i64 = 0
325 while rpe_scan < rel_len {
326 let rb2: i64 = rel[rpe_scan]
327 if rb2 == 63 { rel_path_end = rpe_scan; rpe_scan = rel_len }
328 else { if rb2 == 35 { rel_path_end = rpe_scan; rpe_scan = rel_len }
329 else { rpe_scan = rpe_scan + 1 } }
330 }
331
332 // Find dirname of base path = up to and including last '/' (or "/" if no '/').
333 var dir_end: i64 = base_path_off
334 var i: i64 = base_path_off
335 while i < base_path_off + base_path_len {
336 if base[i] == 47 { dir_end = i + 1 }
337 i = i + 1
338 }
339 if dir_end == base_path_off {
340 // base path had no '/' -- use "/" as base dirname.
341 pos = _ur_putc(out, pos, out_cap, 47)
342 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
343 }
344
345 // Compose merged path into a scratch buffer, then dot-segment-resolve.
346 let scratch_cap: i64 = 4096
347 let scratch: *u8 = sys_mmap(scratch_cap)
348 var sp: i64 = 0
349 sp = _ur_copy(scratch, sp, scratch_cap, base, base_path_off, dir_end - base_path_off)
350 if sp < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
351 sp = _ur_copy(scratch, sp, scratch_cap, rel, 0, rel_path_end)
352 if sp < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
353
354 let seg_off2: *i64 = sys_mmap(2048) as *i64
355 let seg_cnt_p2: *i64 = sys_mmap(8) as *i64
356 pos = _ur_resolve_dot_segments(scratch, sp, out, pos, out_cap, seg_off2, seg_cnt_p2)
357 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
358
359 // Append rel query+fragment suffix (if any).
360 if rel_path_end < rel_len {
361 pos = _ur_copy(out, pos, out_cap, rel, rel_path_end, rel_len - rel_path_end)
362 if pos < 0 { out_len_p[0] = 0; return NX_URL_RESOLVE_TRUNC }
363 }
364
365 out_len_p[0] = pos
366 return NX_URL_RESOLVE_OK
367}