File: //lib/ruby/vendor_ruby/http/cookie.rb
# :markup: markdown
require 'http/cookie/version'
require 'time'
require 'uri'
require 'domain_name'
require 'http/cookie/ruby_compat'
module HTTP
autoload :CookieJar, 'http/cookie_jar'
end
# This class is used to represent an HTTP Cookie.
class HTTP::Cookie
# Maximum number of bytes per cookie (RFC 6265 6.1 requires 4096 at
# least)
MAX_LENGTH = 4096
# Maximum number of cookies per domain (RFC 6265 6.1 requires 50 at
# least)
MAX_COOKIES_PER_DOMAIN = 50
# Maximum number of cookies total (RFC 6265 6.1 requires 3000 at
# least)
MAX_COOKIES_TOTAL = 3000
# :stopdoc:
UNIX_EPOCH = Time.at(0)
PERSISTENT_PROPERTIES = %w[
name value
domain for_domain path
secure httponly
expires max_age
created_at accessed_at
]
# :startdoc:
# The cookie name. It may not be nil or empty.
#
# Assign a string containing any of the following characters will
# raise ArgumentError: control characters (`\x00-\x1F` and `\x7F`),
# space and separators `,;\"=`.
#
# Note that RFC 6265 4.1.1 lists more characters disallowed for use
# in a cookie name, which are these: `<>@:/[]?{}`. Using these
# characters will reduce interoperability.
#
# :attr_accessor: name
# The cookie value.
#
# Assign a string containing a control character (`\x00-\x1F` and
# `\x7F`) will raise ArgumentError.
#
# Assigning nil sets the value to an empty string and the expiration
# date to the Unix epoch. This is a handy way to make a cookie for
# expiration.
#
# Note that RFC 6265 4.1.1 lists more characters disallowed for use
# in a cookie value, which are these: ` ",;\`. Using these
# characters will reduce interoperability.
#
# :attr_accessor: value
# The cookie domain.
#
# Setting a domain with a leading dot implies that the #for_domain
# flag should be turned on. The setter accepts a DomainName object
# as well as a string-like.
#
# :attr_accessor: domain
# The path attribute value.
#
# The setter treats an empty path ("") as the root path ("/").
#
# :attr_accessor: path
# The origin of the cookie.
#
# Setting this will initialize the #domain and #path attribute
# values if unknown yet. If the cookie already has a domain value
# set, it is checked against the origin URL to see if the origin is
# allowed to issue a cookie of the domain, and ArgumentError is
# raised if the check fails.
#
# :attr_accessor: origin
# The Expires attribute value as a Time object.
#
# The setter method accepts a Time object, a string representation
# of date/time that Time.parse can understand, or `nil`.
#
# Setting this value resets #max_age to nil. When #max_age is
# non-nil, #expires returns `created_at + max_age`.
#
# :attr_accessor: expires
# The Max-Age attribute value as an integer, the number of seconds
# before expiration.
#
# The setter method accepts an integer, or a string-like that
# represents an integer which will be stringified and then
# integerized using #to_i.
#
# This value is reset to nil when #expires= is called.
#
# :attr_accessor: max_age
# :call-seq:
# new(name, value = nil)
# new(name, value = nil, **attr_hash)
# new(**attr_hash)
#
# Creates a cookie object. For each key of `attr_hash`, the setter
# is called if defined and any error (typically ArgumentError or
# TypeError) that is raised will be passed through. Each key can be
# either a downcased symbol or a string that may be mixed case.
# Support for the latter may, however, be obsoleted in future when
# Ruby 2.0's keyword syntax is adopted.
#
# If `value` is omitted or it is nil, an expiration cookie is
# created unless `max_age` or `expires` (`expires_at`) is given.
#
# e.g.
#
# new("uid", "a12345")
# new("uid", "a12345", :domain => 'example.org',
# :for_domain => true, :expired => Time.now + 7*86400)
# new("name" => "uid", "value" => "a12345", "Domain" => 'www.example.org')
#
def initialize(*args)
@origin = @domain = @path =
@expires = @max_age = nil
@for_domain = @secure = @httponly = false
@session = true
@created_at = @accessed_at = Time.now
case argc = args.size
when 1
if attr_hash = Hash.try_convert(args.last)
args.pop
else
self.name, self.value = args # value is set to nil
return
end
when 2..3
if attr_hash = Hash.try_convert(args.last)
args.pop
self.name, value = args
else
argc == 2 or
raise ArgumentError, "wrong number of arguments (#{argc} for 1-3)"
self.name, self.value = args
return
end
else
raise ArgumentError, "wrong number of arguments (#{argc} for 1-3)"
end
for_domain = false
domain = max_age = origin = nil
attr_hash.each_pair { |okey, val|
case key ||= okey
when :name
self.name = val
when :value
value = val
when :domain
domain = val
when :path
self.path = val
when :origin
origin = val
when :for_domain, :for_domain?
for_domain = val
when :max_age
# Let max_age take precedence over expires
max_age = val
when :expires, :expires_at
self.expires = val unless max_age
when :httponly, :httponly?
@httponly = val
when :secure, :secure?
@secure = val
when Symbol
setter = :"#{key}="
if respond_to?(setter)
__send__(setter, val)
else
warn "unknown attribute name: #{okey.inspect}" if $VERBOSE
next
end
when String
warn "use downcased symbol for keyword: #{okey.inspect}" if $VERBOSE
key = key.downcase.to_sym
redo
else
warn "invalid keyword ignored: #{okey.inspect}" if $VERBOSE
next
end
}
if @name.nil?
raise ArgumentError, "name must be specified"
end
@for_domain = for_domain
self.domain = domain if domain
self.origin = origin if origin
self.max_age = max_age if max_age
self.value = value.nil? && (@expires || @max_age) ? '' : value
end
autoload :Scanner, 'http/cookie/scanner'
class << self
# Tests if +target_path+ is under +base_path+ as described in RFC
# 6265 5.1.4. +base_path+ must be an absolute path.
# +target_path+ may be empty, in which case it is treated as the
# root path.
#
# e.g.
#
# path_match?('/admin/', '/admin/index') == true
# path_match?('/admin/', '/Admin/index') == false
# path_match?('/admin/', '/admin/') == true
# path_match?('/admin/', '/admin') == false
#
# path_match?('/admin', '/admin') == true
# path_match?('/admin', '/Admin') == false
# path_match?('/admin', '/admins') == false
# path_match?('/admin', '/admin/') == true
# path_match?('/admin', '/admin/index') == true
def path_match?(base_path, target_path)
base_path.start_with?('/') or return false
# RFC 6265 5.1.4
bsize = base_path.size
tsize = target_path.size
return bsize == 1 if tsize == 0 # treat empty target_path as "/"
return false unless target_path.start_with?(base_path)
return true if bsize == tsize || base_path.end_with?('/')
target_path[bsize] == ?/
end
# Parses a Set-Cookie header value `set_cookie` assuming that it
# is sent from a source URI/URL `origin`, and returns an array of
# Cookie objects. Parts (separated by commas) that are malformed
# or considered unacceptable are silently ignored.
#
# If a block is given, each cookie object is passed to the block.
#
# Available option keywords are below:
#
# :created_at
# : The creation time of the cookies parsed.
#
# :logger
# : Logger object useful for debugging
#
# ### Compatibility Note for Mechanize::Cookie users
#
# * Order of parameters changed in HTTP::Cookie.parse:
#
# Mechanize::Cookie.parse(uri, set_cookie[, log])
#
# HTTP::Cookie.parse(set_cookie, uri[, :logger => # log])
#
# * HTTP::Cookie.parse does not accept nil for `set_cookie`.
#
# * HTTP::Cookie.parse does not yield nil nor include nil in an
# returned array. It simply ignores unparsable parts.
#
# * HTTP::Cookie.parse is made to follow RFC 6265 to the extent
# not terribly breaking interoperability with broken
# implementations. In particular, it is capable of parsing
# cookie definitions containing double-quotes just as naturally
# expected.
def parse(set_cookie, origin, options = nil, &block)
if options
logger = options[:logger]
created_at = options[:created_at]
end
origin = URI(origin)
[].tap { |cookies|
Scanner.new(set_cookie, logger).scan_set_cookie { |name, value, attrs|
break if name.nil? || name.empty?
begin
cookie = new(name, value)
rescue => e
logger.warn("Invalid name or value: #{e}") if logger
next
end
cookie.created_at = created_at if created_at
attrs.each { |aname, avalue|
begin
case aname
when 'domain'
cookie.for_domain = true
# The following may negate @for_domain if the value is
# an eTLD or IP address, hence this order.
cookie.domain = avalue
when 'path'
cookie.path = avalue
when 'expires'
# RFC 6265 4.1.2.2
# The Max-Age attribute has precedence over the Expires
# attribute.
cookie.expires = avalue unless cookie.max_age
when 'max-age'
cookie.max_age = avalue
when 'secure'
cookie.secure = avalue
when 'httponly'
cookie.httponly = avalue
end
rescue => e
logger.warn("Couldn't parse #{aname} '#{avalue}': #{e}") if logger
end
}
cookie.origin = origin
cookie.acceptable? or next
yield cookie if block_given?
cookies << cookie
}
}
end
# Takes an array of cookies and returns a string for use in the
# Cookie header, like "name1=value2; name2=value2".
def cookie_value(cookies)
cookies.join('; ')
end
# Parses a Cookie header value into a hash of name-value string
# pairs. The first appearance takes precedence if multiple pairs
# with the same name occur.
def cookie_value_to_hash(cookie_value)
{}.tap { |hash|
Scanner.new(cookie_value).scan_cookie { |name, value|
hash[name] ||= value
}
}
end
end
attr_reader :name
# See #name.
def name= name
name = (String.try_convert(name) or
raise TypeError, "#{name.class} is not a String")
if name.empty?
raise ArgumentError, "cookie name cannot be empty"
elsif name.match(/[\x00-\x20\x7F,;\\"=]/)
raise ArgumentError, "invalid cookie name"
end
# RFC 6265 4.1.1
# cookie-name may not match:
# /[\x00-\x20\x7F()<>@,;:\\"\/\[\]?={}]/
@name = name
end
attr_reader :value
# See #value.
def value= value
if value.nil?
self.expires = UNIX_EPOCH
return @value = ''
end
value = (String.try_convert(value) or
raise TypeError, "#{value.class} is not a String")
if value.match(/[\x00-\x1F\x7F]/)
raise ArgumentError, "invalid cookie value"
end
# RFC 6265 4.1.1
# cookie-name may not match:
# /[^\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]/
@value = value
end
attr_reader :domain
# See #domain.
def domain= domain
case domain
when nil
@for_domain = false
if @origin
@domain_name = DomainName.new(@origin.host)
@domain = @domain_name.hostname
else
@domain_name = @domain = nil
end
return nil
when DomainName
@domain_name = domain
else
domain = (String.try_convert(domain) or
raise TypeError, "#{domain.class} is not a String")
if domain.start_with?('.')
for_domain = true
domain = domain[1..-1]
end
if domain.empty?
return self.domain = nil
end
# Do we really need to support this?
if domain.match(/\A([^:]+):[0-9]+\z/)
domain = $1
end
@domain_name = DomainName.new(domain)
end
# RFC 6265 5.3 5.
if domain_name.domain.nil? # a public suffix or IP address
@for_domain = false
else
@for_domain = for_domain unless for_domain.nil?
end
@domain = @domain_name.hostname
end
# Returns the domain, with a dot prefixed only if the domain flag is
# on.
def dot_domain
@for_domain ? '.' << @domain : @domain
end
# Returns the domain attribute value as a DomainName object.
attr_reader :domain_name
# The domain flag. (the opposite of host-only-flag)
#
# If this flag is true, this cookie will be sent to any host in the
# \#domain, including the host domain itself. If it is false, this
# cookie will be sent only to the host indicated by the #domain.
attr_accessor :for_domain
alias for_domain? for_domain
attr_reader :path
# See #path.
def path= path
path = (String.try_convert(path) or
raise TypeError, "#{path.class} is not a String")
@path = path.start_with?('/') ? path : '/'
end
attr_reader :origin
# See #origin.
def origin= origin
return origin if origin == @origin
@origin.nil? or
raise ArgumentError, "origin cannot be changed once it is set"
# Delay setting @origin because #domain= or #path= may fail
origin = URI(origin)
if URI::HTTP === origin
self.domain ||= origin.host
self.path ||= (origin + './').path
end
@origin = origin
end
# The secure flag. (secure-only-flag)
#
# A cookie with this flag on should only be sent via a secure
# protocol like HTTPS.
attr_accessor :secure
alias secure? secure
# The HttpOnly flag. (http-only-flag)
#
# A cookie with this flag on should be hidden from a client script.
attr_accessor :httponly
alias httponly? httponly
# The session flag. (the opposite of persistent-flag)
#
# A cookie with this flag on should be hidden from a client script.
attr_reader :session
alias session? session
def expires
@expires or @created_at && @max_age ? @created_at + @max_age : nil
end
# See #expires.
def expires= t
case t
when nil, Time
else
t = Time.parse(t)
end
@max_age = nil
@session = t.nil?
@expires = t
end
alias expires_at expires
alias expires_at= expires=
attr_reader :max_age
# See #max_age.
def max_age= sec
case sec
when Integer, nil
else
str = String.try_convert(sec) or
raise TypeError, "#{sec.class} is not an Integer or String"
/\A-?\d+\z/.match(str) or
raise ArgumentError, "invalid Max-Age: #{sec.inspect}"
sec = str.to_i
end
@expires = nil
if @session = sec.nil?
@max_age = nil
else
@max_age = sec
end
end
# Tests if this cookie is expired by now, or by a given time.
def expired?(time = Time.now)
if expires = self.expires
expires <= time
else
false
end
end
# Expires this cookie by setting the expires attribute value to a
# past date.
def expire!
self.expires = UNIX_EPOCH
self
end
# The time this cookie was created at. This value is used as a base
# date for interpreting the Max-Age attribute value. See #expires.
attr_accessor :created_at
# The time this cookie was last accessed at.
attr_accessor :accessed_at
# Tests if it is OK to accept this cookie if it is sent from a given
# URI/URL, `uri`.
def acceptable_from_uri?(uri)
uri = URI(uri)
return false unless URI::HTTP === uri && uri.host
host = DomainName.new(uri.host)
# RFC 6265 5.3
case
when host.hostname == @domain
true
when @for_domain # !host-only-flag
host.cookie_domain?(@domain_name)
else
@domain.nil?
end
end
# Tests if it is OK to accept this cookie considering its origin.
# If either domain or path is missing, raises ArgumentError. If
# origin is missing, returns true.
def acceptable?
case
when @domain.nil?
raise "domain is missing"
when @path.nil?
raise "path is missing"
when @origin.nil?
true
else
acceptable_from_uri?(@origin)
end
end
# Tests if it is OK to send this cookie to a given `uri`. A
# RuntimeError is raised if the cookie's domain is unknown.
def valid_for_uri?(uri)
if @domain.nil?
raise "cannot tell if this cookie is valid because the domain is unknown"
end
uri = URI(uri)
# RFC 6265 5.4
return false if secure? && !(URI::HTTPS === uri)
acceptable_from_uri?(uri) && HTTP::Cookie.path_match?(@path, uri.path)
end
# Returns a string for use in the Cookie header, i.e. `name=value`
# or `name="value"`.
def cookie_value
"#{@name}=#{Scanner.quote(@value)}"
end
alias to_s cookie_value
# Returns a string for use in the Set-Cookie header. If necessary
# information like a path or domain (when `for_domain` is set) is
# missing, RuntimeError is raised. It is always the best to set an
# origin before calling this method.
def set_cookie_value
string = cookie_value
if @for_domain
if @domain
string << "; Domain=#{@domain}"
else
raise "for_domain is specified but domain is unknown"
end
end
if @path
if !@origin || (@origin + './').path != @path
string << "; Path=#{@path}"
end
else
raise "path is unknown"
end
if @max_age
string << "; Max-Age=#{@max_age}"
elsif @expires
string << "; Expires=#{@expires.httpdate}"
end
if @httponly
string << "; HttpOnly"
end
if @secure
string << "; Secure"
end
string
end
def inspect
'#<%s:' % self.class << PERSISTENT_PROPERTIES.map { |key|
'%s=%s' % [key, instance_variable_get(:"@#{key}").inspect]
}.join(', ') << ' origin=%s>' % [@origin ? @origin.to_s : 'nil']
end
# Compares the cookie with another. When there are many cookies with
# the same name for a URL, the value of the smallest must be used.
def <=> other
# RFC 6265 5.4
# Precedence: 1. longer path 2. older creation
(@name <=> other.name).nonzero? ||
(other.path.length <=> @path.length).nonzero? ||
(@created_at <=> other.created_at).nonzero? ||
@value <=> other.value
end
include Comparable
# YAML serialization helper for Syck.
def to_yaml_properties
PERSISTENT_PROPERTIES.map { |name| "@#{name}" }
end
# YAML serialization helper for Psych.
def encode_with(coder)
PERSISTENT_PROPERTIES.each { |key|
coder[key.to_s] = instance_variable_get(:"@#{key}")
}
end
# YAML deserialization helper for Syck.
def init_with(coder)
yaml_initialize(coder.tag, coder.map)
end
# YAML deserialization helper for Psych.
def yaml_initialize(tag, map)
expires = nil
@origin = nil
map.each { |key, value|
case key
when 'expires'
# avoid clobbering max_age
expires = value
when *PERSISTENT_PROPERTIES
__send__(:"#{key}=", value)
end
}
self.expires = expires if self.max_age.nil?
end
end