Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/mdurl/_parse.py: 99%

140 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-25 06:37 +0000

1# Copyright Joyent, Inc. and other Node contributors. 

2# 

3# Permission is hereby granted, free of charge, to any person obtaining a 

4# copy of this software and associated documentation files (the 

5# "Software"), to deal in the Software without restriction, including 

6# without limitation the rights to use, copy, modify, merge, publish, 

7# distribute, sublicense, and/or sell copies of the Software, and to permit 

8# persons to whom the Software is furnished to do so, subject to the 

9# following conditions: 

10# 

11# The above copyright notice and this permission notice shall be included 

12# in all copies or substantial portions of the Software. 

13# 

14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 

15# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 

16# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 

17# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 

18# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 

19# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 

20# USE OR OTHER DEALINGS IN THE SOFTWARE. 

21 

22 

23# Changes from joyent/node: 

24# 

25# 1. No leading slash in paths, 

26# e.g. in `url.parse('http://foo?bar')` pathname is ``, not `/` 

27# 

28# 2. Backslashes are not replaced with slashes, 

29# so `http:\\example.org\` is treated like a relative path 

30# 

31# 3. Trailing colon is treated like a part of the path, 

32# i.e. in `http://example.org:foo` pathname is `:foo` 

33# 

34# 4. Nothing is URL-encoded in the resulting object, 

35# (in joyent/node some chars in auth and paths are encoded) 

36# 

37# 5. `url.parse()` does not have `parseQueryString` argument 

38# 

39# 6. Removed extraneous result properties: `host`, `path`, `query`, etc., 

40# which can be constructed using other parts of the url. 

41 

42from __future__ import annotations 

43 

44from collections import defaultdict 

45import re 

46 

47from mdurl._url import URL 

48 

49# Reference: RFC 3986, RFC 1808, RFC 2396 

50 

51# define these here so at least they only have to be 

52# compiled once on the first module load. 

53PROTOCOL_PATTERN = re.compile(r"^([a-z0-9.+-]+:)", flags=re.IGNORECASE) 

54PORT_PATTERN = re.compile(r":[0-9]*$") 

55 

56# Special case for a simple path URL 

57SIMPLE_PATH_PATTERN = re.compile(r"^(//?(?!/)[^?\s]*)(\?[^\s]*)?$") 

58 

59# RFC 2396: characters reserved for delimiting URLs. 

60# We actually just auto-escape these. 

61DELIMS = ("<", ">", '"', "`", " ", "\r", "\n", "\t") 

62 

63# RFC 2396: characters not allowed for various reasons. 

64UNWISE = ("{", "}", "|", "\\", "^", "`") + DELIMS 

65 

66# Allowed by RFCs, but cause of XSS attacks. Always escape these. 

67AUTO_ESCAPE = ("'",) + UNWISE 

68# Characters that are never ever allowed in a hostname. 

69# Note that any invalid chars are also handled, but these 

70# are the ones that are *expected* to be seen, so we fast-path 

71# them. 

72NON_HOST_CHARS = ("%", "/", "?", ";", "#") + AUTO_ESCAPE 

73HOST_ENDING_CHARS = ("/", "?", "#") 

74HOSTNAME_MAX_LEN = 255 

75HOSTNAME_PART_PATTERN = re.compile(r"^[+a-z0-9A-Z_-]{0,63}$") 

76HOSTNAME_PART_START = re.compile(r"^([+a-z0-9A-Z_-]{0,63})(.*)$") 

77# protocols that can allow "unsafe" and "unwise" chars. 

78 

79# protocols that never have a hostname. 

80HOSTLESS_PROTOCOL = defaultdict( 

81 bool, 

82 { 

83 "javascript": True, 

84 "javascript:": True, 

85 }, 

86) 

87# protocols that always contain a // bit. 

88SLASHED_PROTOCOL = defaultdict( 

89 bool, 

90 { 

91 "http": True, 

92 "https": True, 

93 "ftp": True, 

94 "gopher": True, 

95 "file": True, 

96 "http:": True, 

97 "https:": True, 

98 "ftp:": True, 

99 "gopher:": True, 

100 "file:": True, 

101 }, 

102) 

103 

104 

105class MutableURL: 

106 def __init__(self) -> None: 

107 self.protocol: str | None = None 

108 self.slashes: bool = False 

109 self.auth: str | None = None 

110 self.port: str | None = None 

111 self.hostname: str | None = None 

112 self.hash: str | None = None 

113 self.search: str | None = None 

114 self.pathname: str | None = None 

115 

116 def parse(self, url: str, slashes_denote_host: bool) -> "MutableURL": 

117 lower_proto = "" 

118 slashes = False 

119 rest = url 

120 

121 # trim before proceeding. 

122 # This is to support parse stuff like " http://foo.com \n" 

123 rest = rest.strip() 

124 

125 if not slashes_denote_host and len(url.split("#")) == 1: 

126 # Try fast path regexp 

127 simple_path = SIMPLE_PATH_PATTERN.match(rest) 

128 if simple_path: 

129 self.pathname = simple_path.group(1) 

130 if simple_path.group(2): 

131 self.search = simple_path.group(2) 

132 return self 

133 

134 proto = "" 

135 proto_match = PROTOCOL_PATTERN.match(rest) 

136 if proto_match: 

137 proto = proto_match.group() 

138 lower_proto = proto.lower() 

139 self.protocol = proto 

140 rest = rest[len(proto) :] 

141 

142 # figure out if it's got a host 

143 # user@server is *always* interpreted as a hostname, and url 

144 # resolution will treat //foo/bar as host=foo,path=bar because that's 

145 # how the browser resolves relative URLs. 

146 if slashes_denote_host or proto or re.search(r"^//[^@/]+@[^@/]+", rest): 

147 slashes = rest.startswith("//") 

148 if slashes and not (proto and HOSTLESS_PROTOCOL[proto]): 

149 rest = rest[2:] 

150 self.slashes = True 

151 

152 if not HOSTLESS_PROTOCOL[proto] and ( 

153 slashes or (proto and not SLASHED_PROTOCOL[proto]) 

154 ): 

155 # there's a hostname. 

156 # the first instance of /, ?, ;, or # ends the host. 

157 # 

158 # If there is an @ in the hostname, then non-host chars *are* allowed 

159 # to the left of the last @ sign, unless some host-ending character 

160 # comes *before* the @-sign. 

161 # URLs are obnoxious. 

162 # 

163 # ex: 

164 # http://a@b@c/ => user:a@b host:c 

165 # http://a@b?@c => user:a host:c path:/?@c 

166 

167 # v0.12 TODO(isaacs): This is not quite how Chrome does things. 

168 # Review our test case against browsers more comprehensively. 

169 

170 # find the first instance of any hostEndingChars 

171 host_end = -1 

172 for i in range(len(HOST_ENDING_CHARS)): 

173 hec = rest.find(HOST_ENDING_CHARS[i]) 

174 if hec != -1 and (host_end == -1 or hec < host_end): 

175 host_end = hec 

176 

177 # at this point, either we have an explicit point where the 

178 # auth portion cannot go past, or the last @ char is the decider. 

179 if host_end == -1: 

180 # atSign can be anywhere. 

181 at_sign = rest.rfind("@") 

182 else: 

183 # atSign must be in auth portion. 

184 # http://a@b/c@d => host:b auth:a path:/c@d 

185 at_sign = rest.rfind("@", 0, host_end + 1) 

186 

187 # Now we have a portion which is definitely the auth. 

188 # Pull that off. 

189 if at_sign != -1: 

190 auth = rest[:at_sign] 

191 rest = rest[at_sign + 1 :] 

192 self.auth = auth 

193 

194 # the host is the remaining to the left of the first non-host char 

195 host_end = -1 

196 for i in range(len(NON_HOST_CHARS)): 

197 hec = rest.find(NON_HOST_CHARS[i]) 

198 if hec != -1 and (host_end == -1 or hec < host_end): 

199 host_end = hec 

200 # if we still have not hit it, then the entire thing is a host. 

201 if host_end == -1: 

202 host_end = len(rest) 

203 

204 if host_end > 0 and rest[host_end - 1] == ":": 

205 host_end -= 1 

206 host = rest[:host_end] 

207 rest = rest[host_end:] 

208 

209 # pull out port. 

210 self.parse_host(host) 

211 

212 # we've indicated that there is a hostname, 

213 # so even if it's empty, it has to be present. 

214 self.hostname = self.hostname or "" 

215 

216 # if hostname begins with [ and ends with ] 

217 # assume that it's an IPv6 address. 

218 ipv6_hostname = self.hostname.startswith("[") and self.hostname.endswith( 

219 "]" 

220 ) 

221 

222 # validate a little. 

223 if not ipv6_hostname: 

224 hostparts = self.hostname.split(".") 

225 l = len(hostparts) # noqa: E741 

226 i = 0 

227 while i < l: 

228 part = hostparts[i] 

229 if not part: 

230 i += 1 # emulate statement3 in JS for loop 

231 continue 

232 if not HOSTNAME_PART_PATTERN.search(part): 

233 newpart = "" 

234 k = len(part) 

235 j = 0 

236 while j < k: 

237 if ord(part[j]) > 127: 

238 # we replace non-ASCII char with a temporary placeholder 

239 # we need this to make sure size of hostname is not 

240 # broken by replacing non-ASCII by nothing 

241 newpart += "x" 

242 else: 

243 newpart += part[j] 

244 j += 1 # emulate statement3 in JS for loop 

245 

246 # we test again with ASCII char only 

247 if not HOSTNAME_PART_PATTERN.search(newpart): 

248 valid_parts = hostparts[:i] 

249 not_host = hostparts[i + 1 :] 

250 bit = HOSTNAME_PART_START.search(part) 

251 if bit: 

252 valid_parts.append(bit.group(1)) 

253 not_host.insert(0, bit.group(2)) 

254 if not_host: 

255 rest = ".".join(not_host) + rest 

256 self.hostname = ".".join(valid_parts) 

257 break 

258 i += 1 # emulate statement3 in JS for loop 

259 

260 if len(self.hostname) > HOSTNAME_MAX_LEN: 

261 self.hostname = "" 

262 

263 # strip [ and ] from the hostname 

264 # the host field still retains them, though 

265 if ipv6_hostname: 

266 self.hostname = self.hostname[1:-1] 

267 

268 # chop off from the tail first. 

269 hash = rest.find("#") # noqa: A001 

270 if hash != -1: 

271 # got a fragment string. 

272 self.hash = rest[hash:] 

273 rest = rest[:hash] 

274 qm = rest.find("?") 

275 if qm != -1: 

276 self.search = rest[qm:] 

277 rest = rest[:qm] 

278 if rest: 

279 self.pathname = rest 

280 if SLASHED_PROTOCOL[lower_proto] and self.hostname and not self.pathname: 

281 self.pathname = "" 

282 

283 return self 

284 

285 def parse_host(self, host: str) -> None: 

286 port_match = PORT_PATTERN.search(host) 

287 if port_match: 

288 port = port_match.group() 

289 if port != ":": 

290 self.port = port[1:] 

291 host = host[: -len(port)] 

292 if host: 

293 self.hostname = host 

294 

295 

296def url_parse(url: URL | str, *, slashes_denote_host: bool = False) -> URL: 

297 if isinstance(url, URL): 

298 return url 

299 u = MutableURL() 

300 u.parse(url, slashes_denote_host) 

301 return URL( 

302 u.protocol, u.slashes, u.auth, u.port, u.hostname, u.hash, u.search, u.pathname 

303 )