parse_uri_authority.pony

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
primitive ParseURIAuthority
  """
  Parse a standalone authority string (`[userinfo@]host[:port]`).

  Use this for HTTP CONNECT request-targets (authority-form) where the
  request-target is `host:port` without a scheme or `//` prefix.
  `ParseURI` would misinterpret `host:port` as `scheme:path`.

  The `@` delimiter is identified by scanning for the last `@` in the
  authority string, following the WHATWG URL Standard convention. RFC 3986
  grammar makes the first `@` the delimiter (userinfo cannot contain
  literal `@`, only `%40`), but using the last `@` is more robust against
  real-world URIs with unencoded `@` in userinfo.
  """
  fun apply(raw: String val): (URIAuthority val | URIParseError val) =>
    _parse(raw, 0, raw.size())

  fun _parse(raw: String val, start: USize, end_pos: USize)
    : (URIAuthority val | URIParseError val)
  =>
    // Find @ for userinfo
    var userinfo: (String | None) = None
    var host_start = start

    // Check for IP-literal first — if the host starts with '[', @ before
    // the closing ']' is part of the IP-literal, not a userinfo delimiter.
    var at_pos: USize = end_pos // sentinel: no @ found
    try
      if (host_start < end_pos) and (raw(host_start)? == '[') then
        // IP-literal: find closing ']' first, then look for @ before '['
        // Actually, in authority "[::1]:8080", there's no userinfo.
        // But in "user@[::1]:8080", the @ is before the '['.
        // We need to find the LAST @ before any '[' or just the last @.
        // RFC 3986: userinfo = *( unreserved / pct-encoded / sub-delims / ":" )
        // The @ delimiter separates userinfo from host.
        // Scan for @ only before the '['.
        None // no @ possible when first char is '['
      else
        // Scan for last @ (RFC 3986 says first @ delimits, but in practice
        // the last @ is used since userinfo can contain encoded @)
        var i = start
        while i < end_pos do
          if raw(i)? == '@' then
            at_pos = i
          end
          i = i + 1
        end
      end
    else
      _Unreachable()
    end

    if at_pos < end_pos then
      let ui: String val = raw.substring(start.isize(), at_pos.isize())
      userinfo = ui
      host_start = at_pos + 1
    end

    // Check for IP-literal (starts with '[')
    try
      if (host_start < end_pos) and (raw(host_start)? == '[') then
        // Find closing ']'
        var bracket_end: USize = host_start + 1
        var found = false
        while bracket_end < end_pos do
          if raw(bracket_end)? == ']' then
            found = true
            break
          end
          bracket_end = bracket_end + 1
        end
        if not found then
          return InvalidHost
        end

        // Validate IP-literal content
        let literal_content: String val =
          raw.substring((host_start + 1).isize(), bracket_end.isize())
        if not _valid_ip_literal(literal_content) then
          return InvalidHost
        end

        let host_str: String val =
          raw.substring(host_start.isize(), (bracket_end + 1).isize())

        // After ']', optional ':port'
        let after_bracket = bracket_end + 1
        if after_bracket < end_pos then
          try
            if raw(after_bracket)? == ':' then
              let port_str: String val =
                raw.substring((after_bracket + 1).isize(), end_pos.isize())
              match _parse_port(port_str)
              | let p: U16 =>
                return URIAuthority(userinfo, host_str, p)
              | None =>
                return URIAuthority(userinfo, host_str, None)
              | let e: URIParseError val =>
                return e
              end
            else
              return InvalidHost
            end
          else
            _Unreachable()
          end
        end
        return URIAuthority(userinfo, host_str, None)
      end
    else
      _Unreachable()
    end

    // Regular host (reg-name or IPv4) — find ':' for port, scanning from end
    // to handle IPv6 addresses (though those should be in brackets)
    var colon_pos: USize = end_pos // sentinel
    try
      var i = end_pos
      while i > host_start do
        i = i - 1
        if raw(i)? == ':' then
          colon_pos = i
          break
        end
      end
    else
      _Unreachable()
    end

    if colon_pos < end_pos then
      let host_str: String val =
        raw.substring(host_start.isize(), colon_pos.isize())
      // RFC 3986 reg-name cannot contain ':'. The last-colon scan above
      // leaves ':' in host_str when the input has multiple colons.
      if host_str.contains(":") then
        return InvalidHost
      end
      let port_str: String val =
        raw.substring((colon_pos + 1).isize(), end_pos.isize())
      match \exhaustive\ _parse_port(port_str)
      | let p: U16 =>
        URIAuthority(userinfo, host_str, p)
      | None =>
        URIAuthority(userinfo, host_str, None)
      | let e: URIParseError val =>
        e
      end
    else
      let host_val: String val =
        raw.substring(host_start.isize(), end_pos.isize())
      URIAuthority(userinfo, host_val, None)
    end

  fun _parse_port(port_str: String val): (U16 | None | URIParseError val) =>
    if port_str.size() == 0 then
      // Empty port (e.g., "host:") — RFC 3986 allows empty port
      return None
    end

    // All characters must be digits
    for c in port_str.values() do
      if (c < '0') or (c > '9') then
        return InvalidPort
      end
    end

    // Parse as U64 first to detect overflow
    try
      let value = port_str.u64()?
      if value > 65535 then
        return InvalidPort
      end
      value.u16()
    else
      InvalidPort
    end

  fun _valid_ip_literal(content: String val): Bool =>
    if content.size() == 0 then
      return false
    end

    // IPvFuture: starts with 'v' (case-insensitive)
    try
      let first = content(0)?
      if (first == 'v') or (first == 'V') then
        return _valid_ipvfuture(content)
      end
    else
      _Unreachable() // content.size() > 0 guaranteed above
    end

    // Otherwise must be IPv6
    _valid_ipv6(content)

  fun _valid_ipv6(content: String val): Bool =>
    """
    RFC 3986 IPv6address grammar. The content is the text between brackets,
    with no brackets.
    """
    if content.size() == 0 then return false end

    // Find :: position (at most one allowed)
    var dc_pos: ISize = -1
    try
      var i: USize = 0
      while (i + 1) < content.size() do
        if (content(i)? == ':') and (content(i + 1)? == ':') then
          dc_pos = i.isize()
          break
        end
        i = i + 1
      end
    else
      _Unreachable()
    end
    let has_dc = dc_pos != ISize(-1)

    if has_dc then
      let after = dc_pos.usize() + 2
      try
        var i = after
        while (i + 1) < content.size() do
          if (content(i)? == ':') and (content(i + 1)? == ':') then
            return false
          end
          i = i + 1
        end
      else
        _Unreachable()
      end
    end

    if has_dc then
      let left: String val =
        content.substring(0, dc_pos)
      let right: String val =
        content.substring(dc_pos + 2)

      let left_groups = _count_h16_groups(left)
      if left_groups == USize.max_value() then return false end

      if right.size() == 0 then
        // Ends with :: — left side can be 0..7 groups
        return left_groups <= 7
      end

      (let right_h16, let has_ipv4) = _parse_right_side(right)
      if right_h16 == USize.max_value() then return false end

      let total_represented =
        left_groups + right_h16 + if has_ipv4 then USize(2) else USize(0) end
      // :: expands to fill 8 groups, so left + right < 8
      total_represented <= 7
    else
      // No :: — must be exactly 8 h16 groups, or 6 h16 + IPv4
      _parse_no_dc(content)
    end

  fun _count_h16_groups(s: String val): USize =>
    """
    Count colon-separated h16 groups. Returns USize.max_value() on invalid
    input. An empty string returns 0.
    """
    if s.size() == 0 then return 0 end
    var count: USize = 0
    var group_start: USize = 0
    try
      var i: USize = 0
      while i < s.size() do
        if s(i)? == ':' then
          if not _is_h16(s, group_start, i) then
            return USize.max_value()
          end
          count = count + 1
          group_start = i + 1
        end
        i = i + 1
      end
    else
      _Unreachable()
    end
    // Last group after final colon (or entire string if no colons)
    if not _is_h16(s, group_start, s.size()) then
      return USize.max_value()
    end
    count + 1

  fun _is_h16(s: String val, from: USize, to: USize): Bool =>
    """
    1*4HEXDIG
    """
    let len = to - from
    if (len < 1) or (len > 4) then return false end
    try
      var i = from
      while i < to do
        let c = s(i)?
        if not _is_hexdig(c) then return false end
        i = i + 1
      end
    else
      _Unreachable()
    end
    true

  fun _is_hexdig(c: U8): Bool =>
    ((c >= '0') and (c <= '9')) or
      ((c >= 'A') and (c <= 'F')) or
      ((c >= 'a') and (c <= 'f'))

  fun _parse_right_side(s: String val): (USize, Bool) =>
    """
    Parse the portion after :: into h16 count and whether it ends with IPv4.
    Returns (USize.max_value(), false) on invalid input.
    """
    if s.contains(".") then
      var last_colon = ISize(-1)
      try
        var i = s.size()
        while i > 0 do
          i = i - 1
          if s(i)? == ':' then
            last_colon = i.isize()
            break
          end
        end
      else
        _Unreachable()
      end

      if last_colon == ISize(-1) then
        // No colon — entire right side is the IPv4 address
        if _valid_ipv4(s) then
          return (0, true)
        else
          return (USize.max_value(), false)
        end
      end

      let h16_part: String val = s.substring(0, last_colon)
      let ipv4_part: String val =
        s.substring(last_colon + 1)

      if not _valid_ipv4(ipv4_part) then
        return (USize.max_value(), false)
      end

      let h16_count = _count_h16_groups(h16_part)
      if h16_count == USize.max_value() then
        return (USize.max_value(), false)
      end
      (h16_count, true)
    else
      let h16_count = _count_h16_groups(s)
      if h16_count == USize.max_value() then
        return (USize.max_value(), false)
      end
      (h16_count, false)
    end

  fun _parse_no_dc(content: String val): Bool =>
    """
    No :: present — must be 8 h16 groups or 6 h16 ':' IPv4address.
    """
    if content.contains(".") then
      // RFC 3986: 6( h16 ":" ) IPv4address
      var last_colon_before_dot = ISize(-1)
      try
        var i: USize = 0
        while i < content.size() do
          if content(i)? == ':' then
            last_colon_before_dot = i.isize()
          elseif content(i)? == '.' then
            break
          end
          i = i + 1
        end
      else
        _Unreachable()
      end

      if last_colon_before_dot == ISize(-1) then return false end

      let h16_part: String val =
        content.substring(0, last_colon_before_dot)
      let ipv4_part: String val =
        content.substring(last_colon_before_dot + 1)

      if not _valid_ipv4(ipv4_part) then return false end

      let h16_count = _count_h16_groups(h16_part)
      h16_count == 6
    else
      _count_h16_groups(content) == 8
    end

  fun _valid_ipv4(s: String val): Bool =>
    """
    RFC 3986 IPv4address =
    dec-octet "." dec-octet "." dec-octet "." dec-octet
    """
    var octet_count: USize = 0
    var octet_start: USize = 0
    try
      var i: USize = 0
      while i < s.size() do
        if s(i)? == '.' then
          if not _valid_dec_octet(s, octet_start, i) then return false end
          octet_count = octet_count + 1
          octet_start = i + 1
        elseif not ((s(i)? >= '0') and (s(i)? <= '9')) then
          return false
        end
        i = i + 1
      end
    else
      _Unreachable()
    end
    if not _valid_dec_octet(s, octet_start, s.size()) then return false end
    octet_count = octet_count + 1
    octet_count == 4

  fun _valid_dec_octet(s: String val, from: USize, to: USize): Bool =>
    """
    0-255 with no leading zeros (except bare "0").
    """
    let len = to - from
    if (len == 0) or (len > 3) then return false end
    try
      if len == 1 then
        return (s(from)? >= '0') and (s(from)? <= '9')
      end
      // No leading zero for multi-digit
      if s(from)? == '0' then return false end
      var val': U16 = 0
      var i = from
      while i < to do
        let c = s(i)?
        if (c < '0') or (c > '9') then return false end
        val' = (val' * 10) + (c - '0').u16()
        i = i + 1
      end
      val' <= 255
    else
      _Unreachable()
      false
    end

  fun _valid_ipvfuture(content: String val): Bool =>
    // IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" )
    // Must have at least "v" + hexdig + "." + char = 4 chars
    if content.size() < 4 then
      return false
    end

    // Find the '.' separator
    var dot_pos: USize = 0
    var found_dot = false
    try
      var i: USize = 1  // skip 'v'
      while i < content.size() do
        if content(i)? == '.' then
          dot_pos = i
          found_dot = true
          break
        end
        i = i + 1
      end
    else
      _Unreachable()
    end

    if not found_dot then
      return false
    end

    // Must have at least one hex digit between 'v' and '.'
    if dot_pos < 2 then
      return false
    end

    // Validate hex digits between 'v' and '.'
    try
      var i: USize = 1
      while i < dot_pos do
        let c = content(i)?
        if not (
          ((c >= '0') and (c <= '9')) or
            ((c >= 'A') and (c <= 'F')) or
            ((c >= 'a') and (c <= 'f')))
        then
          return false
        end
        i = i + 1
      end
    else
      _Unreachable()
    end

    // Must have at least one char after '.'
    if (dot_pos + 1) >= content.size() then
      return false
    end

    // Validate chars after '.': unreserved / sub-delims / ":"
    try
      var i: USize = dot_pos + 1
      while i < content.size() do
        let c = content(i)?
        if not (_is_unreserved(c) or _is_sub_delim(c) or (c == ':')) then
          return false
        end
        i = i + 1
      end
    else
      _Unreachable()
    end

    true

  fun _is_unreserved(c: U8): Bool =>
    ((c >= 'A') and (c <= 'Z')) or
      ((c >= 'a') and (c <= 'z')) or
      ((c >= '0') and (c <= '9')) or
      (c == '-') or (c == '.') or (c == '_') or (c == '~')

  fun _is_sub_delim(c: U8): Bool =>
    (c == '!') or (c == '$') or (c == '&') or (c == '\'') or
      (c == '(') or (c == ')') or (c == '*') or (c == '+') or
      (c == ',') or (c == ';') or (c == '=')