code wiki / (root) / nx_url_resolve.nx

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}