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}