code wiki / (root) / nx_url_canonical.nx

nx_url_canonical.nx source

↩ module page · 155 lines · 5517 B

1// url.nx -- URL percent-encoding + decoding (RFC 3986). 2// 3// Used by: HTTP query strings, path segments, form-urlencoded 4// POST bodies, JWT segments. Complements base64.nx / hex.nx for 5// the URL-safe text-encoding niche. 6// 7// Encoding rules (RFC 3986 §2.3): 8// Unreserved: A-Z a-z 0-9 - . _ ~ (encoded as-is) 9// Reserved: : / ? # [ ] @ ! $ & ' ( ) * + , ; = 10// -- reserved chars DEPEND on context. We offer two encoders: 11// url_encode_component -- encodes everything reserved 12// url_encode_path -- preserves / for path segments 13// All other bytes: encoded as %HH (two uppercase hex digits). 14// 15// Invariants: 16// UR1 Output is deterministic: same input -> same bytes. 17// UR2 Decoder rejects malformed sequences (%X or %XY where Y 18// isn't hex) with a negative return. No silent skip. 19// UR3 Decoder accepts both upper and lower case hex digits. 20// UR4 Round-trip exact for any byte sequence: 21// decode(encode(x)) == x 22// UR5 Encoder never outputs non-ASCII; caller always gets 23// 7-bit safe text. 24 25// nx_safety_envelope: 26// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 27// sil_target: SIL1 28// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 29// verdict: NOT_YET_EVALUATED 30 31import "nx_syscalls.nx" 32 33const URL_ERR_BAD_ESCAPE: i64 = -1 34const URL_ERR_TRUNCATED: i64 = -2 35 36// Is byte an unreserved RFC 3986 char (never needs encoding)? 37func url_unreserved(c: i64) -> i64 { 38 if c >= 0x41 { if c <= 0x5A { return 1 } } // A-Z 39 if c >= 0x61 { if c <= 0x7A { return 1 } } // a-z 40 if c >= 0x30 { if c <= 0x39 { return 1 } } // 0-9 41 if c == 0x2D { return 1 } // - 42 if c == 0x2E { return 1 } // . 43 if c == 0x5F { return 1 } // _ 44 if c == 0x7E { return 1 } // ~ 45 return 0 46} 47 48// Hex digit of a 0..15 value (uppercase, for URL convention). 49func url_hex_upper(n: i64) -> i64 { 50 if n < 10 { return 0x30 + n } // 0-9 51 return 0x41 + n - 10 // A-F 52} 53 54// Parse one hex digit ASCII -> 0..15, or -1 if invalid. 55func url_hex_val(c: i64) -> i64 { 56 if c >= 0x30 { if c <= 0x39 { return c - 0x30 } } 57 if c >= 0x41 { if c <= 0x46 { return c - 0x41 + 10 } } 58 if c >= 0x61 { if c <= 0x66 { return c - 0x61 + 10 } } 59 return -1 60} 61 62// Full encode: every non-unreserved byte percent-encoded. Use for 63// query-string values, path segments that might contain reserved 64// chars, form-urlencoded body (with plus-for-space handled separately). 65func url_encode_component(in_bytes: *u8, n: i64, out: *u8) -> i64 { 66 var i: i64 = 0 67 var out_pos: i64 = 0 68 while i < n { 69 let b: i64 = in_bytes[i] 70 if url_unreserved(b) == 1 { 71 out[out_pos] = b 72 out_pos = out_pos + 1 73 } else { 74 out[out_pos] = 0x25 // '%' 75 out[out_pos + 1] = url_hex_upper((b >> 4) & 0xF) 76 out[out_pos + 2] = url_hex_upper(b & 0xF) 77 out_pos = out_pos + 3 78 } 79 i = i + 1 80 } 81 return out_pos 82} 83 84// Path encoder: preserves '/' as a structural separator per 85// RFC 3986 §3.3. Every other non-unreserved byte gets encoded. 86func url_encode_path(in_bytes: *u8, n: i64, out: *u8) -> i64 { 87 var i: i64 = 0 88 var out_pos: i64 = 0 89 while i < n { 90 let b: i64 = in_bytes[i] 91 if url_unreserved(b) == 1 { 92 out[out_pos] = b 93 out_pos = out_pos + 1 94 } else { 95 if b == 0x2F { // '/' 96 out[out_pos] = b 97 out_pos = out_pos + 1 98 } else { 99 out[out_pos] = 0x25 100 out[out_pos + 1] = url_hex_upper((b >> 4) & 0xF) 101 out[out_pos + 2] = url_hex_upper(b & 0xF) 102 out_pos = out_pos + 3 103 } 104 } 105 i = i + 1 106 } 107 return out_pos 108} 109 110// Decoder: %HH sequences become their byte value; + is preserved 111// (caller handles form-urlencoded + -> space separately if needed). 112// Returns bytes written or a negative URL_ERR_*. 113func url_decode(in_bytes: *u8, n: i64, out: *u8) -> i64 { 114 var i: i64 = 0 115 var out_pos: i64 = 0 116 while i < n { 117 let b: i64 = in_bytes[i] 118 if b == 0x25 { // '%' 119 if i + 3 > n { return URL_ERR_TRUNCATED } 120 let h1: i64 = url_hex_val(in_bytes[i + 1]) 121 if h1 < 0 { return URL_ERR_BAD_ESCAPE } 122 let h2: i64 = url_hex_val(in_bytes[i + 2]) 123 if h2 < 0 { return URL_ERR_BAD_ESCAPE } 124 out[out_pos] = ((h1 << 4) | h2) & 0xFF 125 out_pos = out_pos + 1 126 i = i + 3 127 } else { 128 out[out_pos] = b 129 out_pos = out_pos + 1 130 i = i + 1 131 } 132 } 133 return out_pos 134} 135 136// Compile-only smoke. Expected: encode("Hello World!") -> 137// "Hello%20World%21" (12 chars -> 16 chars). 138func main() -> i64 { 139 let input: *u8 = "Hello World!" 140 let encoded: *u8 = sys_mmap(64) 141 let n_enc: i64 = url_encode_component(input, 12, encoded) 142 if n_enc != 16 { return 1 } 143 // Position 5 should be '%'. 144 if encoded[5] != 0x25 { return 2 } 145 // Position 6-7 should be '2' '0' (space encoded). 146 if encoded[6] != 0x32 { return 3 } 147 if encoded[7] != 0x30 { return 4 } 148 149 let decoded: *u8 = sys_mmap(32) 150 let n_dec: i64 = url_decode(encoded, n_enc, decoded) 151 if n_dec != 12 { return 5 } 152 if decoded[5] != 0x20 { return 6 } // space restored 153 if decoded[11] != 0x21 { return 7 } // '!' restored 154 return 0 155}