From 6fe110be47c0fc34bc9dce6d377ce9b48ab27c2e Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Sun, 30 Aug 2026 15:34:15 +0200 Subject: feat(url): classify host as name/ipv4/ipv6 in parse and build parse() now sets hosttype ("name"/"ipv4"/"ipv6") plus a matching hostname/ipv4/ipv6 field alongside host. build() accepts those same fields when host is absent, erroring if more than one is set. classify_host is exported for standalone use; it classifies by shape (colon present -> ipv6, dotted-quad shape -> ipv4) rather than validating the address. --- docs/url.html | 82 +++++++++++++++++++++++ src/url.lua | 53 +++++++++++++-- test/urltest.lua | 196 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 6 deletions(-) diff --git a/docs/url.html b/docs/url.html index e03b094..788f409 100644 --- a/docs/url.html +++ b/docs/url.html @@ -140,6 +140,14 @@ Lower level components, if specified, take precedence over high level components of the URL grammar.

+

+If host is not set, the host is taken from whichever single +one of hostname, ipv4 or ipv6 is set +(hosttype is ignored when building). It is an error to set more +than one of hostname, ipv4 or ipv6 while +host is absent. +

+

The function returns a string with the built URL.

@@ -169,6 +177,62 @@ The function returns a string with the built <path> component.

+ + +

+url.classify_host(host) +

+ +

+Classifies a raw <host> string into "name", +"ipv4" or "ipv6". This is the same classification +parse uses to fill in hosttype, +exposed for callers that have a host string to classify without a full +URL to parse. +

+ +

+Host is a host string as found in a URL's authority. Any +: in host is taken as a sign of an IPv6 literal, so +the enclosing [...] brackets URLs normally require for one are +optional here: they are stripped when present, but classification does +not depend on them. +

+ +

+The function returns two values: hosttype, one of +"name", "ipv4" or "ipv6"; and host, +the input with any enclosing [...] brackets stripped, if +present. +

+ +

+Note: classification is done by exclusion of shape, not by validating +the address. A host is "ipv4" if it merely has the shape of +four dot-separated digit groups — octet ranges are not checked, +so "999.1.1.1" classifies as "ipv4" — and +"ipv6" if it merely contains a :, whether or not that +is a well-formed IPv6 address. Anything left over is "name", +whether or not it is actually a valid hostname. +

+ +
+-- load url module
+url = require("socket.url")
+
+print(url.classify_host("example.com"))
+-- name    example.com
+
+print(url.classify_host("192.168.1.1"))
+-- ipv4    192.168.1.1
+
+print(url.classify_host("[::1]"))
+-- ipv6    ::1
+
+print(url.classify_host("::1"))
+-- ipv6    ::1
+
+

@@ -230,12 +294,28 @@ parsed_url = {
  fragment = string,
  userinfo = string,
  host = string,
+  hosttype = string,
+  hostname = string,
+  ipv4 = string,
+  ipv6 = string,
  port = string,
  user = string,
  password = string
} +

+When a host is present, hosttype is set to one of +"name", "ipv4" or "ipv6", describing the +syntax of host. Exactly one of hostname, ipv4 +or ipv6 is then also set to the same value as host, +matching hosttype — the other two are left nil. +In case of an ipv6 address the brackets are stripped, both in host and ipv6. +This classification is done by +classify_host, by exclusion of +shape rather than by validating the address (see its notes). +

+
 -- load url module
 url = require("socket.url")
@@ -248,6 +328,8 @@ parsed_url = url.parse("http://www.example.com/cgilua/index.lua?a=2#there")
 --   query = "a=2",
 --   fragment = "there",
 --   host = "www.puc-rio.br",
+--   hosttype = "name",
+--   hostname = "www.puc-rio.br",
 -- }
 
 parsed_url = url.parse("ftp://root:passwd@unsafe.org/pub/virus.exe;type=i")
diff --git a/src/url.lua b/src/url.lua
index aef1698..be82421 100644
--- a/src/url.lua
+++ b/src/url.lua
@@ -122,6 +122,25 @@ local function absolute_path(base_path, relative_path)
     return path
 end
 
+-----------------------------------------------------------------------------
+-- Classifies a raw host string into "name", "ipv4" or "ipv6"
+-- Input
+--   raw: host part of the authority, as found in the URL (with its
+--     enclosing [...] brackets, if any)
+-- Returns
+--   hosttype: "name", "ipv4" or "ipv6"
+--   host: raw, with any enclosing [...] brackets stripped
+-----------------------------------------------------------------------------
+function _M.classify_host(raw)
+    if string.find(raw, ":", 1, true) then
+        return "ipv6", string.match(raw, "^%[?(.-)%]?$")
+    end
+    if string.match(raw, "^%d+%.%d+%.%d+%.%d+$") then
+        return "ipv4", raw
+    end
+    return "name", raw
+end
+
 -----------------------------------------------------------------------------
 -- Parses a url and returns a table with all its parts according to RFC 2396
 -- The following grammar describes the names given to the URL parts
@@ -137,6 +156,10 @@ end
 --   been preserved:
 --     scheme, authority, userinfo, user, password, host, port,
 --     path, params, query, fragment
+--   plus, when a host is present:
+--     hosttype: "name", "ipv4" or "ipv6", describing the syntax of host
+--     hostname, ipv4 or ipv6: same value as host, keyed by hosttype
+--       (only one of these three is ever set)
 -- Obs:
 --   the leading '/' in {/} is considered part of 
 -----------------------------------------------------------------------------
@@ -180,8 +203,10 @@ function _M.parse(url, default)
     authority = string.gsub(authority, ":([^:%]]*)$",
         function(p) parsed.port = p; return "" end)
     if authority ~= "" then
-        -- IPv6?
-        parsed.host = string.match(authority, "^%[(.+)%]$") or authority
+        parsed.hosttype, parsed.host = _M.classify_host(authority)
+        if parsed.hosttype == "name" then parsed.hostname = parsed.host
+        elseif parsed.hosttype == "ipv4" then parsed.ipv4 = parsed.host
+        else parsed.ipv6 = parsed.host end
     end
     local userinfo = parsed.userinfo
     if not userinfo then return parsed end
@@ -195,7 +220,11 @@ end
 -- Rebuilds a parsed URL from its components.
 -- Components are protected if any reserved or unallowed characters are found
 -- Input
---   parsed: parsed URL, as returned by parse
+--   parsed: parsed URL, as returned by parse. If parsed.host is absent,
+--     the host is taken from whichever single one of hostname/ipv4/ipv6
+--     is set (parsed.hosttype is ignored for building). It is an error
+--     (raised with error()) for more than one of hostname/ipv4/ipv6 to
+--     be set when parsed.host is absent.
 -- Returns
 --   a stringing with the corresponding URL
 -----------------------------------------------------------------------------
@@ -206,9 +235,21 @@ function _M.build(parsed)
     if parsed.params then url = url .. ";" .. parsed.params end
     if parsed.query then url = url .. "?" .. parsed.query end
     local authority = parsed.authority
-    if parsed.host then
-        authority = parsed.host
-        if string.find(authority, ":") then -- IPv6?
+    local host = parsed.host
+    if not host then
+        local set = 0
+        if parsed.hostname then host, set = parsed.hostname, set+1 end
+        if parsed.ipv4 then host, set = parsed.ipv4, set+1 end
+        if parsed.ipv6 then host, set = parsed.ipv6, set+1 end
+        if set > 1 then
+            base.error("url.build: ambiguous host, more than one of " ..
+                "hostname/ipv4/ipv6 is set")
+        end
+    end
+    if host then
+        local hosttype
+        hosttype, authority = _M.classify_host(host)
+        if hosttype == "ipv6" then
             authority = "[" .. authority .. "]"
         end
         if parsed.port then authority = authority .. ":" .. base.tostring(parsed.port) end
diff --git a/test/urltest.lua b/test/urltest.lua
index 9a3c470..8c50f50 100644
--- a/test/urltest.lua
+++ b/test/urltest.lua
@@ -95,6 +95,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:pass$%?#wd@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "user:pass$%?#wd",
     password = "pass$%?#wd",
@@ -109,6 +111,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:pass?#wd@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "user:pass?#wd",
     password = "pass?#wd",
@@ -123,6 +127,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:pass-wd@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "user:pass-wd",
     password = "pass-wd",
@@ -137,6 +143,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:pass#wd@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "user:pass#wd",
     password = "pass#wd",
@@ -151,6 +159,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:pass#wd@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "user:pass#wd",
     password = "pass#wd",
@@ -164,6 +174,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -178,6 +190,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:password@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "user:password",
     user = "user",
@@ -193,6 +207,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -207,6 +223,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -221,6 +239,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -234,6 +254,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -248,6 +270,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -261,6 +285,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -275,6 +301,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -284,6 +312,8 @@ check_parse_url{
     url = "//userinfo@host:port/path;params?query#fragment",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -297,6 +327,8 @@ check_parse_url{
     url = "//userinfo@host:port/path",
     authority = "userinfo@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -307,6 +339,8 @@ check_parse_url{
     url = "//userinfo@host/path",
     authority = "userinfo@host",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     userinfo = "userinfo",
     user = "userinfo",
     path = "/path",
@@ -316,6 +350,8 @@ check_parse_url{
     url = "//user:password@host/path",
     authority = "user:password@host",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     userinfo = "user:password",
     password = "password",
     user = "user",
@@ -326,6 +362,8 @@ check_parse_url{
     url = "//user:@host/path",
     authority = "user:@host",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     userinfo = "user:",
     password = "",
     user = "user",
@@ -336,6 +374,8 @@ check_parse_url{
     url = "//user@host:port/path",
     authority = "user@host:port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     userinfo = "user",
     user = "user",
     port = "port",
@@ -347,6 +387,8 @@ check_parse_url{
     authority = "host:port",
     port = "port",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     path = "/path",
 }
 
@@ -354,6 +396,8 @@ check_parse_url{
     url = "//host/path",
     authority = "host",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
     path = "/path",
 }
 
@@ -361,6 +405,8 @@ check_parse_url{
     url = "//host",
     authority = "host",
     host = "host",
+    hosttype = "name",
+    hostname = "host",
 }
 
 check_parse_url{
@@ -379,6 +425,8 @@ check_parse_url{
     url = "http://[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html",
     scheme = "http",
     host = "FEDC:BA98:7654:3210:FEDC:BA98:7654:3210",
+    hosttype = "ipv6",
+    ipv6 = "FEDC:BA98:7654:3210:FEDC:BA98:7654:3210",
     authority = "[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80",
     port = "80",
     path = "/index.html"
@@ -388,6 +436,8 @@ check_parse_url{
     url = "http://[1080:0:0:0:8:800:200C:417A]/index.html",
     scheme = "http",
     host = "1080:0:0:0:8:800:200C:417A",
+    hosttype = "ipv6",
+    ipv6 = "1080:0:0:0:8:800:200C:417A",
     authority = "[1080:0:0:0:8:800:200C:417A]",
     path = "/index.html"
 }
@@ -396,6 +446,8 @@ check_parse_url{
     url = "http://[3ffe:2a00:100:7031::1]",
     scheme = "http",
     host = "3ffe:2a00:100:7031::1",
+    hosttype = "ipv6",
+    ipv6 = "3ffe:2a00:100:7031::1",
     authority = "[3ffe:2a00:100:7031::1]",
 }
 
@@ -403,6 +455,8 @@ check_parse_url{
     url = "http://[1080::8:800:200C:417A]/foo",
     scheme = "http",
     host = "1080::8:800:200C:417A",
+    hosttype = "ipv6",
+    ipv6 = "1080::8:800:200C:417A",
     authority = "[1080::8:800:200C:417A]",
     path = "/foo"
 }
@@ -411,6 +465,8 @@ check_parse_url{
     url = "http://[::192.9.5.5]/ipng",
     scheme = "http",
     host = "::192.9.5.5",
+    hosttype = "ipv6",
+    ipv6 = "::192.9.5.5",
     authority = "[::192.9.5.5]",
     path = "/ipng"
 }
@@ -419,6 +475,8 @@ check_parse_url{
     url = "http://[::FFFF:129.144.52.38]:80/index.html",
     scheme = "http",
     host = "::FFFF:129.144.52.38",
+    hosttype = "ipv6",
+    ipv6 = "::FFFF:129.144.52.38",
     port = "80",
     authority = "[::FFFF:129.144.52.38]:80",
     path = "/index.html"
@@ -428,6 +486,8 @@ check_parse_url{
     url = "http://[2010:836B:4179::836B:4179]",
     scheme = "http",
     host = "2010:836B:4179::836B:4179",
+    hosttype = "ipv6",
+    ipv6 = "2010:836B:4179::836B:4179",
     authority = "[2010:836B:4179::836B:4179]",
 }
 
@@ -435,6 +495,8 @@ check_parse_url{
     url = "//userinfo@[::FFFF:129.144.52.38]:port/path;params?query#fragment",
     authority = "userinfo@[::FFFF:129.144.52.38]:port",
     host = "::FFFF:129.144.52.38",
+    hosttype = "ipv6",
+    ipv6 = "::FFFF:129.144.52.38",
     port = "port",
     userinfo = "userinfo",
     user = "userinfo",
@@ -449,6 +511,8 @@ check_parse_url{
     scheme = "scheme",
     authority = "user:password@[::192.9.5.5]:port",
     host = "::192.9.5.5",
+    hosttype = "ipv6",
+    ipv6 = "::192.9.5.5",
     port = "port",
     userinfo = "user:password",
     user = "user",
@@ -459,6 +523,138 @@ check_parse_url{
     fragment = "fragment"
 }
 
+print("testing host classification (hosttype/hostname/ipv4/ipv6)")
+check_parse_url{
+    url = "http://example.com/path",
+    scheme = "http",
+    authority = "example.com",
+    host = "example.com",
+    hosttype = "name",
+    hostname = "example.com",
+    path = "/path",
+}
+
+check_parse_url{
+    url = "http://192.168.1.1:8080/path",
+    scheme = "http",
+    authority = "192.168.1.1:8080",
+    host = "192.168.1.1",
+    hosttype = "ipv4",
+    ipv4 = "192.168.1.1",
+    port = "8080",
+    path = "/path",
+}
+
+-- octet ranges aren't validated: any dotted-quad shape is classified ipv4,
+-- even with an out-of-range octet like this one
+check_parse_url{
+    url = "http://999.1.1.1/path",
+    scheme = "http",
+    authority = "999.1.1.1",
+    host = "999.1.1.1",
+    hosttype = "ipv4",
+    ipv4 = "999.1.1.1",
+    path = "/path",
+}
+
+check_parse_url{
+    url = "http://[::1]:8080/path",
+    scheme = "http",
+    authority = "[::1]:8080",
+    host = "::1",
+    hosttype = "ipv6",
+    ipv6 = "::1",
+    port = "8080",
+    path = "/path",
+}
+
+check_parse_url{
+    url = "http://[2010:836B:4179::836B:4179]/",
+    scheme = "http",
+    authority = "[2010:836B:4179::836B:4179]",
+    host = "2010:836B:4179::836B:4179",
+    hosttype = "ipv6",
+    ipv6 = "2010:836B:4179::836B:4179",
+    path = "/",
+}
+
+-- no authority at all: no host, no hosttype
+check_parse_url{
+    url = "relative/path",
+    path = "relative/path",
+}
+
+check_build_url{
+    url = "http://example.com/path",
+    scheme = "http",
+    hostname = "example.com",
+    path = "/path",
+}
+
+check_build_url{
+    url = "http://1.2.3.4/path",
+    scheme = "http",
+    ipv4 = "1.2.3.4",
+    path = "/path",
+}
+
+check_build_url{
+    url = "http://[::1]/path",
+    scheme = "http",
+    ipv6 = "::1",
+    path = "/path",
+}
+
+-- an already-bracketed ipv6/host value must not be bracketed again
+check_build_url{
+    url = "http://[::1]/path",
+    scheme = "http",
+    ipv6 = "[::1]",
+    path = "/path",
+}
+
+check_build_url{
+    url = "http://[::1]/path",
+    scheme = "http",
+    host = "[::1]",
+    path = "/path",
+}
+
+local check_classify_host = function(raw, expect_hosttype, expect_host)
+    local hosttype, host = socket.url.classify_host(raw)
+    if hosttype ~= expect_hosttype or host ~= expect_host then
+        io.write("classify_host: for '", raw, "' expected ", expect_hosttype,
+            " '", expect_host, "' but got ", tostring(hosttype), " '",
+            tostring(host), "'\n")
+        os.exit()
+    end
+end
+
+check_classify_host("example.com", "name", "example.com")
+check_classify_host("999.1.1.1", "ipv4", "999.1.1.1")
+check_classify_host("192.168.1.1", "ipv4", "192.168.1.1")
+check_classify_host("[::1]", "ipv6", "::1")
+check_classify_host("2010:836B:4179::836B:4179", "ipv6", "2010:836B:4179::836B:4179")
+
+-- legacy 'host' field takes precedence over hostname/ipv4/ipv6 if both given
+check_build_url{
+    url = "http://legacy.example/path",
+    scheme = "http",
+    host = "legacy.example",
+    hostname = "ignored.example",
+    path = "/path",
+}
+
+-- ambiguous: more than one of hostname/ipv4/ipv6 set, no 'host' to disambiguate
+do
+    local ok = pcall(socket.url.build,
+        {scheme = "http", ipv4 = "1.2.3.4", hostname = "example.com", path = "/path"})
+    if ok then
+        print("build: expected error for ambiguous host, got none")
+        os.exit()
+    end
+end
+
 print("testing URL building")
 check_build_url {
     url = "scheme://user:password@host:port/path;params?query#fragment",
-- 
cgit v1.2.3-55-g6feb