cookiejar 0.2.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,300 @@
1
+ require 'cookiejar/cookie'
2
+
3
+ module CookieJar
4
+ # A cookie store for client side usage.
5
+ # - Enforces cookie validity rules
6
+ # - Returns just the cookies valid for a given URI
7
+ # - Handles expiration of cookies
8
+ # - Allows for persistence of cookie data (with or without session)
9
+ #
10
+ #--
11
+ #
12
+ # Internal format:
13
+ #
14
+ # Internally, the data structure is a set of nested hashes.
15
+ # Domain Level:
16
+ # At the domain level, the hashes are of individual domains,
17
+ # down-cased and without any leading period. For instance, imagine cookies
18
+ # for .foo.com, .bar.com, and .auth.bar.com:
19
+ #
20
+ # {
21
+ # "foo.com" : (host data),
22
+ # "bar.com" : (host data),
23
+ # "auth.bar.com" : (host data)
24
+ # }
25
+ #
26
+ # Lookups are done both for the matching entry, and for an entry without
27
+ # the first segment up to the dot, ie. for /^\.?[^\.]+\.(.*)$/.
28
+ # A lookup of auth.bar.com would match both bar.com and
29
+ # auth.bar.com, but not entries for com or www.auth.bar.com.
30
+ #
31
+ # Host Level:
32
+ # Entries are in an hash, with keys of the path and values of a hash of
33
+ # cookie names to cookie object
34
+ #
35
+ # {
36
+ # "/" : {"session" : (Cookie object), "cart_id" : (Cookie object)}
37
+ # "/protected" : {"authentication" : (Cookie Object)}
38
+ # }
39
+ #
40
+ # Paths are given a straight prefix string comparison to match.
41
+ # Further filters <secure, http only, ports> are not represented in this
42
+ # heirarchy.
43
+ #
44
+ # Cookies returned are ordered solely by specificity (length) of the
45
+ # path.
46
+ class Jar
47
+ # Create a new empty Jar
48
+ def initialize
49
+ @domains = {}
50
+ end
51
+
52
+ # Given a request URI and a literal Set-Cookie header value, attempt to
53
+ # add the cookie to the cookie store.
54
+ #
55
+ # @param [String, URI] request_uri the resource returning the header
56
+ # @param [String] cookie_header_value the contents of the Set-Cookie
57
+ # @return [Cookie] which was created and stored
58
+ # @raise [InvalidCookieError] if the cookie header did not validate
59
+ def set_cookie request_uri, cookie_header_value
60
+ cookie = Cookie.from_set_cookie request_uri, cookie_header_value
61
+ add_cookie cookie
62
+ end
63
+
64
+ # Given a request URI and a literal Set-Cookie2 header value, attempt to
65
+ # add the cookie to the cookie store.
66
+ #
67
+ # @param [String, URI] request_uri the resource returning the header
68
+ # @param [String] cookie_header_value the contents of the Set-Cookie2
69
+ # @return [Cookie] which was created and stored
70
+ # @raise [InvalidCookieError] if the cookie header did not validate
71
+ def set_cookie2 request_uri, cookie_header_value
72
+ cookie = Cookie.from_set_cookie2 request_uri, cookie_header_value
73
+ add_cookie cookie
74
+ end
75
+
76
+ # Given a request URI and some HTTP headers, attempt to add the cookie(s)
77
+ # (from Set-Cookie or Set-Cookie2 headers) to the cookie store. If a
78
+ # cookie is defined (by equivalent name, domain, and path) via Set-Cookie
79
+ # and Set-Cookie2, the Set-Cookie version is ignored.
80
+ #
81
+ # @param [String, URI] request_uri the resource returning the header
82
+ # @param [Hash<String,[String,Array<String>]>] http_headers a Hash
83
+ # which may have a key of "Set-Cookie" or "Set-Cookie2", and values of
84
+ # either strings or arrays of strings
85
+ # @return [Array<Cookie>,nil] the cookies created, or nil if none found.
86
+ # @raise [InvalidCookieError] if one of the cookie headers contained
87
+ # invalid formatting or data
88
+ def set_cookies_from_headers request_uri, http_headers
89
+ cookies = gather_header_values http_headers['Set-Cookie'] do |value|
90
+ Cookie.from_set_cookie request_uri, value
91
+ end
92
+
93
+ cookies += gather_header_values(http_headers['Set-Cookie2']) do |value|
94
+ Cookie.from_set_cookie2 request_uri, value
95
+ end
96
+
97
+ # build the list of cookies, using a Jar. Since Set-Cookie2 values
98
+ # come second, they will replace the Set-Cookie versions.
99
+ jar = Jar.new
100
+ cookies.each do |cookie|
101
+ jar.add_cookie cookie
102
+ end
103
+ cookies = jar.to_a
104
+
105
+ # now add them all to our own store.
106
+ cookies.each do |cookie|
107
+ add_cookie cookie
108
+ end
109
+ cookies
110
+ end
111
+
112
+ # Add a pre-existing cookie object to the jar.
113
+ #
114
+ # @param [Cookie] cookie a pre-existing cookie object
115
+ # @return [Cookie] the cookie added to the store
116
+ def add_cookie cookie
117
+ domain_paths = find_or_add_domain_for_cookie cookie
118
+ add_cookie_to_path domain_paths, cookie
119
+ cookie
120
+ end
121
+
122
+ # Return an array of all cookie objects in the jar
123
+ #
124
+ # @return [Array<Cookie>] all cookies. Includes any expired cookies
125
+ # which have not yet been removed with expire_cookies
126
+ def to_a
127
+ result = []
128
+ @domains.values.each do |paths|
129
+ paths.values.each do |cookies|
130
+ cookies.values.inject result, :<<
131
+ end
132
+ end
133
+ result
134
+ end
135
+
136
+ # Return a JSON 'object' for the various data values. Allows for
137
+ # persistence of the cookie information
138
+ #
139
+ # @param [Array] a options controlling output JSON text
140
+ # (usually a State and a depth)
141
+ # @return [String] JSON representation of object data
142
+ def to_json *a
143
+ {
144
+ 'json_class' => self.class.name,
145
+ 'cookies' => (to_a.to_json *a)
146
+ }.to_json *a
147
+ end
148
+
149
+ # Create a new Jar from a JSON-backed hash
150
+ #
151
+ # @param o [Hash] the expanded JSON object
152
+ # @return [CookieJar] a new CookieJar instance
153
+ def self.json_create o
154
+ if o.is_a? Hash
155
+ o = o['cookies']
156
+ end
157
+ cookies = o.inject [] do |result, cookie_json|
158
+ result << (Cookie.json_create cookie_json)
159
+ end
160
+ self.from_a cookies
161
+ end
162
+
163
+ # Create a new Jar from an array of Cookie objects. Expired cookies
164
+ # will still be added to the archive, and conflicting cookies will
165
+ # be overwritten by the last cookie in the array.
166
+ #
167
+ # @param [Array<Cookie>] cookies array of cookie objects
168
+ # @return [CookieJar] a new CookieJar instance
169
+ def self.from_a cookies
170
+ jar = new
171
+ cookies.each do |cookie|
172
+ jar.add_cookie cookie
173
+ end
174
+ jar
175
+ end
176
+
177
+ # Look through the jar for any cookies which have passed their expiration
178
+ # date, or session cookies from a previous session
179
+ #
180
+ # @param session [Boolean] whether session cookies should be expired,
181
+ # or just cookies past their expiration date.
182
+ def expire_cookies session = false
183
+ @domains.delete_if do |domain, paths|
184
+ paths.delete_if do |path, cookies|
185
+ cookies.delete_if do |cookie_name, cookie|
186
+ cookie.expired? || (session && cookie.session?)
187
+ end
188
+ cookies.empty?
189
+ end
190
+ paths.empty?
191
+ end
192
+ end
193
+
194
+ # Given a request URI, return a sorted list of Cookie objects. Cookies
195
+ # will be in order per RFC 2965 - sorted by longest path length, but
196
+ # otherwise unordered.
197
+ #
198
+ # @param [String, URI] request_uri the address the HTTP request will be
199
+ # sent to
200
+ # @param [Hash] opts options controlling returned cookies
201
+ # @option opts [Boolean] :script (false) Cookies marked HTTP-only will be ignored
202
+ # if true
203
+ # @return [Array<Cookie>] cookies which should be sent in the HTTP request
204
+ def get_cookies request_uri, opts = { }
205
+ uri = to_uri request_uri
206
+ hosts = Cookie.compute_search_domains uri
207
+
208
+ results = []
209
+ hosts.each do |host|
210
+ domain = find_domain host
211
+ domain.each do |path, cookies|
212
+ if uri.path.start_with? path
213
+ results += cookies.values.select do |cookie|
214
+ cookie.should_send? uri, opts[:script]
215
+ end
216
+ end
217
+ end
218
+ end
219
+ #Sort by path length, longest first
220
+ results.sort do |lhs, rhs|
221
+ rhs.path.length <=> lhs.path.length
222
+ end
223
+ end
224
+
225
+ # Given a request URI, return a string Cookie header.Cookies will be in
226
+ # order per RFC 2965 - sorted by longest path length, but otherwise
227
+ # unordered.
228
+ #
229
+ # @param [String, URI] request_uri the address the HTTP request will be
230
+ # sent to
231
+ # @param [Hash] opts options controlling returned cookies
232
+ # @option opts [Boolean] :script (false) Cookies marked HTTP-only will be ignored
233
+ # if true
234
+ # @return String value of the Cookie header which should be sent on the
235
+ # HTTP request
236
+ def get_cookie_header request_uri, opts = { }
237
+ cookies = get_cookies request_uri, opts
238
+ version = 0
239
+ ver = [[],[]]
240
+ cookies.each do |cookie|
241
+ ver[cookie.version] << cookie
242
+ end
243
+ if (ver[1].empty?)
244
+ # can do a netscape-style cookie header, relish the opportunity
245
+ cookies.map do |cookie|
246
+ cookie.to_s
247
+ end.join ";"
248
+ else
249
+ # build a RFC 2965-style cookie header. Split the cookies into
250
+ # version 0 and 1 groups so that we can reuse the '$Version' header
251
+ result = ''
252
+ unless ver[0].empty?
253
+ result << '$Version=0;'
254
+ result << ver[0].map do |cookie|
255
+ (cookie.to_s 1,false)
256
+ end.join(';')
257
+ # separate version 0 and 1 with a comma
258
+ result << ','
259
+ end
260
+ result << '$Version=1;'
261
+ ver[1].map do |cookie|
262
+ result << (cookie.to_s 1,false)
263
+ end
264
+ result
265
+ end
266
+ end
267
+
268
+ protected
269
+
270
+ def gather_header_values http_header_value, &block
271
+ result = []
272
+ http_header_value
273
+ if http_header_value.is_a? Array
274
+ http_header_value.each do |value|
275
+ result << block.call(value)
276
+ end
277
+ elsif http_header_value.is_a? String
278
+ result << block.call(http_header_value)
279
+ end
280
+ result
281
+ end
282
+
283
+ def to_uri request_uri
284
+ (request_uri.is_a? URI)? request_uri : (URI.parse request_uri)
285
+ end
286
+
287
+ def find_domain host
288
+ @domains[host] || {}
289
+ end
290
+
291
+ def find_or_add_domain_for_cookie cookie
292
+ @domains[cookie.domain] ||= {}
293
+ end
294
+
295
+ def add_cookie_to_path paths, cookie
296
+ path_entry = (paths[cookie.path] ||= {})
297
+ path_entry[cookie.name] = cookie
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,176 @@
1
+ require 'cookiejar'
2
+ require 'rubygems'
3
+
4
+ include CookieJar
5
+
6
+ FOO_URL = 'http://localhost/foo'
7
+ AMMO_URL = 'http://localhost/ammo'
8
+ NETSCAPE_SPEC_SET_COOKIE_HEADERS =
9
+ [['CUSTOMER=WILE_E_COYOTE; path=/; expires=Wednesday, 09-Nov-99 23:12:40 GMT',
10
+ FOO_URL],
11
+ ['PART_NUMBER=ROCKET_LAUNCHER_0001; path=/',
12
+ FOO_URL],
13
+ ['SHIPPING=FEDEX; path=/foo',
14
+ FOO_URL],
15
+ ['PART_NUMBER=ROCKET_LAUNCHER_0001; path=/',
16
+ FOO_URL],
17
+ ['PART_NUMBER=RIDING_ROCKET_0023; path=/ammo',
18
+ AMMO_URL]]
19
+
20
+ describe Cookie do
21
+ describe "#from_set_cookie" do
22
+ it "should handle cookies from the netscape spec" do
23
+ NETSCAPE_SPEC_SET_COOKIE_HEADERS.each do |value|
24
+ header, url = *value
25
+ cookie = Cookie.from_set_cookie url, header
26
+ end
27
+ end
28
+ it "should give back the input names and values" do
29
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'foo=bar'
30
+ cookie.name.should == 'foo'
31
+ cookie.value.should == 'bar'
32
+ end
33
+ it "should normalize domain names" do
34
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'foo=Bar;domain=LoCaLHoSt.local'
35
+ cookie.domain.should == '.localhost.local'
36
+ end
37
+ it "should accept non-normalized .local" do
38
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'foo=bar;domain=.local'
39
+ cookie.domain.should == '.local'
40
+ end
41
+ it "should accept secure cookies" do
42
+ cookie = Cookie.from_set_cookie 'https://www.google.com/a/blah', 'GALX=RgmSftjnbPM;Path=/a/;Secure'
43
+ cookie.name.should == 'GALX'
44
+ cookie.secure.should be_true
45
+ end
46
+ end
47
+ describe "#from_set_cookie2" do
48
+ it "should give back the input names and values" do
49
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=bar;Version=1'
50
+ cookie.name.should == 'foo'
51
+ cookie.value.should == 'bar'
52
+ end
53
+ it "should normalize domain names" do
54
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=Bar;domain=LoCaLHoSt.local;Version=1'
55
+ cookie.domain.should == '.localhost.local'
56
+ end
57
+ it "should accept non-normalized .local" do
58
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=bar;domain=.local;Version=1'
59
+ cookie.domain.should == '.local'
60
+ end
61
+ it "should accept secure cookies" do
62
+ cookie = Cookie.from_set_cookie2 'https://www.google.com/a/blah', 'GALX=RgmSftjnbPM;Path="/a/";Secure;Version=1'
63
+ cookie.name.should == 'GALX'
64
+ cookie.path.should == '/a/'
65
+ cookie.secure.should be_true
66
+ end
67
+ it "should fail on unquoted paths" do
68
+ lambda do
69
+ Cookie.from_set_cookie2 'https://www.google.com/a/blah',
70
+ 'GALX=RgmSftjnbPM;Path=/a/;Secure;Version=1'
71
+ end.should raise_error InvalidCookieError
72
+ end
73
+ it "should accept quoted values" do
74
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo="bar";Version=1'
75
+ cookie.name.should == 'foo'
76
+ cookie.value.should == '"bar"'
77
+ end
78
+ it "should accept poorly chosen names" do
79
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'Version=mine;Version=1'
80
+ cookie.name.should == 'Version'
81
+ cookie.value.should == 'mine'
82
+ end
83
+ it "should accept quoted parameter values" do
84
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=bar;Version="1"'
85
+ end
86
+ it "should honor the discard and max-age parameters" do
87
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;max-age=100;discard;Version=1'
88
+ cookie.should be_session
89
+ cookie.should_not be_expired
90
+
91
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;max-age=100;Version=1'
92
+ cookie.should_not be_session
93
+
94
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1'
95
+ cookie.should be_session
96
+ end
97
+ it "should handle quotable quotes" do
98
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f="\"";Version=1'
99
+ cookie.value.should eql '"\""'
100
+ end
101
+ it "should handle quotable apostrophes" do
102
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f="\;";Version=1'
103
+ cookie.value.should eql '"\;"'
104
+ end
105
+ end
106
+ describe '#decoded_value' do
107
+ it "should leave normal values alone" do
108
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1'
109
+ cookie.decoded_value.should eql 'b'
110
+ end
111
+ it "should attempt to unencode quoted values" do
112
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f="\"b";Version=1'
113
+ cookie.value.should eql '"\"b"'
114
+ cookie.decoded_value.should eql '"b'
115
+ end
116
+ end
117
+ describe '#to_s' do
118
+ it "should handle a simple cookie" do
119
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'f=b'
120
+ cookie.to_s.should == 'f=b'
121
+ cookie.to_s(1).should == '$Version=0;f=b;$Path="/"'
122
+ end
123
+ it "should report an explicit domain" do
124
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1;Domain=.local'
125
+ cookie.to_s(1).should == '$Version=1;f=b;$Path="/";$Domain=.local'
126
+ end
127
+ it "should return specified ports" do
128
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1;Port="80,443"'
129
+ cookie.to_s(1).should == '$Version=1;f=b;$Path="/";$Port="80,443"'
130
+ end
131
+ it "should handle specified paths" do
132
+ cookie = Cookie.from_set_cookie 'http://localhost/bar/', 'f=b;path=/bar/'
133
+ cookie.to_s.should == 'f=b'
134
+ cookie.to_s(1).should == '$Version=0;f=b;$Path="/bar/"'
135
+ end
136
+ it "should omit $Version header when asked" do
137
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'f=b'
138
+ cookie.to_s(1,false).should == 'f=b;$Path="/"'
139
+ end
140
+ end
141
+ describe '#should_send?' do
142
+ it "should not send if ports do not match" do
143
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1;Port="80"'
144
+ cookie.should_send?("http://localhost/", false).should be_true
145
+ cookie.should_send?("https://localhost/", false).should be_false
146
+ end
147
+ end
148
+ begin
149
+ require 'json'
150
+ describe ".to_json" do
151
+ it "should serialize a cookie to JSON" do
152
+ c = Cookie.from_set_cookie 'https://localhost/', 'foo=bar;secure;expires=Fri, September 11 2009 18:10:00 -0700'
153
+ json = c.to_json
154
+ json.should be_a String
155
+ end
156
+ end
157
+ describe ".json_create" do
158
+ it "should deserialize JSON to a cookie" do
159
+ json = "{\"name\":\"foo\",\"value\":\"bar\",\"domain\":\"localhost.local\",\"path\":\"\\/\",\"created_at\":\"2009-09-11 12:51:03 -0600\",\"expiry\":\"2009-09-11 19:10:00 -0600\",\"secure\":true}"
160
+ hash = JSON.parse json
161
+ c = Cookie.json_create hash
162
+ CookieValidation.validate_cookie 'https://localhost/', c
163
+ end
164
+ it "should automatically deserialize to a cookie" do
165
+ json = "{\"json_class\":\"CookieJar::Cookie\",\"name\":\"foo\",\"value\":\"bar\",\"domain\":\"localhost.local\",\"path\":\"\\/\",\"created_at\":\"2009-09-11 12:51:03 -0600\",\"expiry\":\"2009-09-11 19:10:00 -0600\",\"secure\":true}"
166
+ c = JSON.parse json
167
+ c.should be_a Cookie
168
+ CookieValidation.validate_cookie 'https://localhost/', c
169
+ end
170
+ end
171
+ rescue LoadError
172
+ it "does not appear the JSON library is installed" do
173
+ raise "please install the JSON library"
174
+ end
175
+ end
176
+ end