diff --git a/lib/http/cookie.rb b/lib/http/cookie.rb index 2c6ac2b..3f32a2b 100644 --- a/lib/http/cookie.rb +++ b/lib/http/cookie.rb @@ -131,6 +131,19 @@ class HTTP::Cookie class << self include URIFix if defined?(URIFix) + # Normalizes a given path. If it is empty, the root path '/' is + # returned. If a URI object is given, returns a new URI object + # with the path part normalized. + def normalize_path(uri) + # Currently does not replace // to / + case uri + when URI + uri.path.empty? ? uri + '/' : uri + else + uri.empty? ? '/' : uri + end + end + # Parses a Set-Cookie header value +set_cookie+ into an array of # Cookie objects. Parts (separated by commas) that are malformed # are ignored. @@ -277,20 +290,8 @@ class HTTP::Cookie @domain = @domain_name.hostname end - def normalize_uri_path(uri) - # Currently does not replace // to / - uri.path.empty? ? uri + '/' : uri - end - private :normalize_uri_path - - def normalize_path(path) - # Currently does not replace // to / - path.empty? ? '/' : path - end - private :normalize_path - def path=(path) - @path = normalize_path(path) + @path = HTTP::Cookie.normalize_path(path) end def origin=(origin) @@ -298,7 +299,7 @@ class HTTP::Cookie raise ArgumentError, "origin cannot be changed once it is set" origin = URI(origin) self.domain ||= origin.host - self.path ||= (normalize_uri_path(origin) + './').path + self.path ||= (HTTP::Cookie.normalize_path(origin) + './').path acceptable_from_uri?(origin) or raise ArgumentError, "unacceptable cookie sent from URI #{origin}" @origin = origin @@ -351,7 +352,7 @@ class HTTP::Cookie raise "cannot tell if this cookie is valid because the domain is unknown" end return false if secure? && uri.scheme != 'https' - acceptable_from_uri?(uri) && normalize_path(uri.path).start_with?(@path) + acceptable_from_uri?(uri) && HTTP::Cookie.normalize_path(uri.path).start_with?(@path) end # Returns a string for use in a Cookie header value, @@ -375,7 +376,7 @@ class HTTP::Cookie if @for_domain || @domain != DomainName.new(origin.host).hostname string << "; domain=#{@domain}" end - if (normalize_uri_path(origin) + './').path != @path + if (HTTP::Cookie.normalize_path(origin) + './').path != @path string << "; path=#{@path}" end if expires = @expires diff --git a/lib/http/cookie_jar.rb b/lib/http/cookie_jar.rb index 8b21214..fbcad58 100644 --- a/lib/http/cookie_jar.rb +++ b/lib/http/cookie_jar.rb @@ -1,6 +1,4 @@ -module HTTP - autoload :Cookie, 'http/cookie' -end +require 'http/cookie' ## # This class is used to manage the Cookies that have been returned from @@ -8,15 +6,25 @@ end class HTTP::CookieJar autoload :AbstractSaver, 'http/cookie_jar/abstract_saver' + autoload :AbstractStore, 'http/cookie_jar/abstract_store' - attr_reader :jar + attr_reader :store - def initialize - @jar = {} + def initialize(store = :hash, options = nil) + case store + when Symbol + @store = AbstractStore.implementation(store).new(options) + when AbstractStore + options.empty? or + raise ArgumentError, 'wrong number of arguments (%d for 1)' % (1 + options.size) + @store = store + else + raise TypeError, 'wrong object given as cookie store: %s' % store.inspect + end end - def initialize_copy other # :nodoc: - @jar = Marshal.load Marshal.dump other.jar + def initialize_copy(other) + @store = other.instance_eval { @store.dup } end # Add a +cookie+ to the jar and return self. @@ -24,16 +32,8 @@ class HTTP::CookieJar if cookie.domain.nil? || cookie.path.nil? raise ArgumentError, "a cookie with unknown domain or path cannot be added" end - normal_domain = cookie.domain_name.hostname - - path_cookies = ((@jar[normal_domain] ||= {})[cookie.path] ||= {}) - - if cookie.expired? - path_cookies.delete(cookie.name) - else - path_cookies[cookie.name] = cookie - end + @store.add(cookie) self end alias << add @@ -53,12 +53,23 @@ class HTTP::CookieJar each(url) { return false } return true else - @jar.empty? + @store.empty? end end - # Iterate over cookies. If +uri+ is given, cookies not for the - # URL/URI are excluded. + # Iterates over all cookies that are not expired. + # + # Available option keywords are below: + # + # * +uri+ + # + # Specify a URI/URL indicating the destination of the cookies + # being selected. Every cookie yielded should be good to send to + # the given URI, i.e. cookie.valid_for_uri?(uri) evaluates to + # true. + # + # If (and only if) this option is given, last access time of each + # cookie is updated to the current time. def each(uri = nil, &block) block_given? or return enum_for(__method__, uri) @@ -68,11 +79,7 @@ class HTTP::CookieJar } end - @jar.each { |domain, paths| - paths.each { |path, hash| - hash.each_value(&block) - } - } + @store.each(uri, &block) self end include Enumerable @@ -202,22 +209,13 @@ class HTTP::CookieJar # Clear the cookie jar and return self. def clear - @jar.clear + @store.clear self end - protected - # Remove expired cookies and return self. - def cleanup session = false - @jar.each do |domain, paths| - paths.each do |path, hash| - hash.delete_if { |cookie_name, cookie| - cookie.expired? || (session && cookie.session?) - } - end - end + def cleanup(session = false) + @store.cleanup session self end end - diff --git a/lib/http/cookie_jar/abstract_store.rb b/lib/http/cookie_jar/abstract_store.rb new file mode 100644 index 0000000..0ff2a37 --- /dev/null +++ b/lib/http/cookie_jar/abstract_store.rb @@ -0,0 +1,90 @@ +require 'http/cookie_jar' + +class HTTP::CookieJar::AbstractStore + class << self + @@class_map = {} + + # Gets an implementation class by the name, optionally trying to + # load "http/cookie_jar/*_store" if not found. If loading fails, + # KeyError is raised. + def implementation(symbol) + @@class_map.fetch(symbol) + rescue KeyError + begin + require 'http/cookie_jar/%s_store' % symbol + @@class_map.fetch(symbol) + rescue LoadError, KeyError => e + raise KeyError, 'cookie store unavailable: %s' % symbol.inspect + end + end + + def inherited(subclass) + @@class_map[class_to_symbol(subclass)] = subclass + end + + def class_to_symbol(klass) + klass.name[/[^:]+?(?=Store$|$)/].downcase.to_sym + end + end + + def default_options + {} + end + private :default_options + + def initialize(options = nil) + options ||= {} + @logger = options[:logger] + # Initializes each instance variable of the same name as option + # keyword. + default_options.each_pair { |key, default| + instance_variable_set("@#{key}", options.key?(key) ? options[key] : default) + } + end + + def initialize_copy(other) + raise + self + end + + def add(cookie) + raise + self + end + + # Iterates over all cookies that are not expired. + # + # Available option keywords are below: + # + # * +uri+ + # + # Specify a URI object indicating the destination of the cookies + # being selected. Every cookie yielded should be good to send to + # the given URI, i.e. cookie.valid_for_uri?(uri) evaluates to + # true. + # + # If (and only if) this option is given, last access time of each + # cookie is updated to the current time. + def each(options = nil, &block) + raise + self + end + include Enumerable + + def clear + raise + self + end + + def cleanup(session = false) + if session + select { |cookie| cookie.session? || cookie.expired? } + else + select(&:expired?) + end.each { |cookie| + add(cookie.expire) + } + # subclasses can optionally remove over-the-limit cookies. + self + end +end diff --git a/lib/http/cookie_jar/hash_store.rb b/lib/http/cookie_jar/hash_store.rb new file mode 100644 index 0000000..b7c082c --- /dev/null +++ b/lib/http/cookie_jar/hash_store.rb @@ -0,0 +1,94 @@ +require 'http/cookie_jar' + +class Array + def sort_by!(&block) + replace(sort_by(&block)) + end unless method_defined?(:sort_by!) +end + +class HTTP::CookieJar + class HashStore < AbstractStore + def default_options + {} + end + + def initialize(options = nil) + super + + @jar = {} + # { + # hostname => { + # path => { + # name => cookie, + # ... + # }, + # ... + # }, + # ... + # } + + @gc_index = 0 + end + + def initialize_copy(other) + @jar = Marshal.load(Marshal.dump(other.instance_variable_get(:@jar))) + end + + def add(cookie) + path_cookies = ((@jar[cookie.domain_name.hostname] ||= {})[cookie.path] ||= {}) + + if cookie.expired? + path_cookies.delete(cookie.name) + else + path_cookies[cookie.name] = cookie + end + + self + end + + def each(uri = nil) + if uri + uri = URI(uri) + thost = DomainName.new(uri.host) + tpath = HTTP::Cookie.normalize_path(uri.path) + @jar.each { |domain, paths| + next unless thost.cookie_domain?(domain) + paths.each { |path, hash| + next unless tpath.start_with?(path) + hash.delete_if { |name, cookie| + if cookie.expired? + true + else + cookie.accessed_at = Time.now + yield cookie + false + end + } + } + } + else + @jar.each { |domain, paths| + paths.each { |path, hash| + hash.delete_if { |name, cookie| + if cookie.expired? + true + else + yield cookie + false + end + } + } + } + end + end + + def clear + @jar.clear + self + end + + def empty? + @jar.empty? + end + end +end