rack-session-xfile 0.10.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.rdoc +164 -0
- data/Rakefile +22 -0
- data/lib/rack/session/xfile.rb +193 -0
- data/lib/rack-session-xfile.rb +1 -0
- data/spec/spec_xfile.rb +461 -0
- data/xfile.gemspec +19 -0
- metadata +81 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 64d96bd5bb35fd04da4c0c2b7cd7f697f367617f
|
4
|
+
data.tar.gz: 81f1f785803a8447c899da0441fff97dc4d81766
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 2ea0309035a48c736404d70b5669056b18735a8c578da6badb5296d058a53aa54279721ff74d485d492f9f0fd07481db6a2cb584bc159c78c53183f6c4fa76a6
|
7
|
+
data.tar.gz: ee252d4880ff8e137c9718395d391b1a639d89ba24c1c314c3cc8b3bb51f592bf64c76dd7239451f9c584b1fb138370a3ab9957e6325e46a5a18da661b4ffe0b
|
data/README.rdoc
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
= Rack::Session::XFile --- Atomic File-Based Session Management
|
2
|
+
|
3
|
+
== Design and implementation
|
4
|
+
|
5
|
+
XFile leverages mutex and file locking to guarantee atomic updates to session
|
6
|
+
data. Exclusive create locks are also used to allocate the resource file when
|
7
|
+
generating a new session id (SID). This eliminates race conditions and ensures
|
8
|
+
data consistency for multi-threaded and multi-process applications.
|
9
|
+
|
10
|
+
File-based sessions are the fastest and simplest persistent-storage option. To
|
11
|
+
maximize performance, store session files on an SSD, an in-memory filesystem, or
|
12
|
+
a disk-based filesystem with performant caching, such as ZFS. Session files are
|
13
|
+
distributed across 62 directories to minimize access time on some filesystems.
|
14
|
+
|
15
|
+
=== Goals
|
16
|
+
|
17
|
+
1. *Simple*: We already have a working filesystem; no configuration.
|
18
|
+
2. *Reliable*: We are using a time-tested filesystem and file lock mechanism.
|
19
|
+
3. *Secure*: Session data is always stored on the server under our control.
|
20
|
+
4. *Performant*: Session access is very responsive with memory cache or SSDs.
|
21
|
+
5. *Persistent*: Sessions persist regardless of application crashes or restarts.
|
22
|
+
|
23
|
+
|
24
|
+
== How session data is stored and referenced
|
25
|
+
|
26
|
+
Session data is marshaled and written to the persistent file on the server
|
27
|
+
associated with the SID. The client cookie (set by the +key+ option) is then
|
28
|
+
assigned the SID.
|
29
|
+
|
30
|
+
== Request filtering
|
31
|
+
|
32
|
+
The request filter uses Rack's +skip+ option to completely bypass session
|
33
|
+
processing for matching requests. When a request matches the filter, a
|
34
|
+
temporary, in-memory session hash is created instead. The filesystem is never
|
35
|
+
accessed.
|
36
|
+
|
37
|
+
User agents can be filtered by setting the +user_agent_filter+ option with a
|
38
|
+
regular expression.
|
39
|
+
|
40
|
+
This feature was originally implemented to avoid processing sessions for robots.
|
41
|
+
Many sources state that one-third to two-thirds of all web traffic is from bots.
|
42
|
+
However, most robots don't store or forward cookies. Consequently, processing
|
43
|
+
sessions for robots wastes resources and pollutes the session store with
|
44
|
+
needless "one-time" sessions.
|
45
|
+
|
46
|
+
By default, SIDs are validated by the request filtering system to detect SID
|
47
|
+
tampering, which could lead to directory traversal exploits. Requests with
|
48
|
+
invalid SIDs are skipped.
|
49
|
+
|
50
|
+
== Purging expired session files
|
51
|
+
|
52
|
+
The XFile session manager does not concern itself with purging expired sessions.
|
53
|
+
This task is much better suited for an out-of-band process, such as cron.
|
54
|
+
|
55
|
+
For example, run an hourly cron job that executes +find+ and deletes expired
|
56
|
+
session files. For instance, if the +expire_after+ option is set to 30 days,
|
57
|
+
then delete session files with last-modification times greater than 30 days.
|
58
|
+
|
59
|
+
Additionally, files with modification times past an _X_ amount of time but less
|
60
|
+
than expiration could be truncated to free up disk space. This effectively
|
61
|
+
returns an empty session, but will preserve session IDs previously issued to
|
62
|
+
clients. SID preservation may be critical if these session IDs are referenced by
|
63
|
+
another backend system or service, such as an analytics system.
|
64
|
+
|
65
|
+
=== Example cron job to purge sessions older than 30 days
|
66
|
+
|
67
|
+
0 * * * * find /tmp/xfile-sessions -type f -mtime +30 -exec rm -- {} +
|
68
|
+
|
69
|
+
=== Example cron job to clear sessions older than 15 days while maintaining SIDs
|
70
|
+
|
71
|
+
0 0 * * * find /tmp/xfile-sessions -type f -mtime +15 -exec cp /dev/null {} \;
|
72
|
+
|
73
|
+
*Caveat*: There is potential for a race condition anytime a process modifies
|
74
|
+
XFile sessions without utilizing file locks. However, in this case with cron,
|
75
|
+
the potential is very small considering the session files to be deleted haven't
|
76
|
+
been accessed in a relatively long time and probably won't be any time soon.
|
77
|
+
|
78
|
+
|
79
|
+
== Usage Examples
|
80
|
+
|
81
|
+
=== Basic usage with sessions stored under the application directory
|
82
|
+
|
83
|
+
require 'rack-session-xfile'
|
84
|
+
|
85
|
+
use Rack::Session::XFile,
|
86
|
+
session_dir: File.join(settings.root, 'tmp/sessions'),
|
87
|
+
expire_after: 60*60*24*30,
|
88
|
+
secure: true,
|
89
|
+
key: 'sid'
|
90
|
+
|
91
|
+
By default, sessions are stored in the directory named _xfile-sessions_ under
|
92
|
+
the operating system's temporary file path.
|
93
|
+
|
94
|
+
=== Bypass session processing for robots
|
95
|
+
|
96
|
+
require 'rack-session-xfile'
|
97
|
+
|
98
|
+
use Rack::Session::XFile,
|
99
|
+
user_agent_filter: /(bot|crawler|spider)/i
|
100
|
+
|
101
|
+
A more extensive filter could include: wget|archive|search|seo|libwww|index.
|
102
|
+
|
103
|
+
== Install, test & contribute
|
104
|
+
|
105
|
+
* Install as a system-wide gem.
|
106
|
+
|
107
|
+
$ sudo gem install rack-session-xfile
|
108
|
+
|
109
|
+
* Install as a user-local gem.
|
110
|
+
|
111
|
+
$ gem install rack-session-xfile --user-install
|
112
|
+
|
113
|
+
* Clone the Mercurial repository.
|
114
|
+
|
115
|
+
$ hg clone https://bitbucket.org/pachl/rack-session-xfile
|
116
|
+
|
117
|
+
XFile is tested with Christian Neukirchen's awesome test framework,
|
118
|
+
{Bacon}[https://github.com/chneukirchen/bacon]. First, get bacon. Then crank up
|
119
|
+
the heat on the entire test suite. Let's get cooking!
|
120
|
+
|
121
|
+
$ sudo gem install bacon
|
122
|
+
$ cd rack-session-xfile
|
123
|
+
$ bacon -a
|
124
|
+
|
125
|
+
If you find or fix a bug, please send reports and patches to pachl@ecentryx.com
|
126
|
+
or open an issue on the bug tracker.
|
127
|
+
|
128
|
+
|
129
|
+
== Links
|
130
|
+
|
131
|
+
Homepage :: http://ecentryx.com/gems/rack-session-xfile
|
132
|
+
Ruby Gem :: https://rubygems.org/gems/rack-session-xfile
|
133
|
+
Source Code :: https://bitbucket.org/pachl/rack-session-xfile/src
|
134
|
+
Bug Tracker :: https://bitbucket.org/pachl/rack-session-xfile/issues
|
135
|
+
|
136
|
+
== Compatibility
|
137
|
+
|
138
|
+
Rack::Session::XFile was developed and tested on
|
139
|
+
OpenBSD[http://www.openbsd.org] 5.6
|
140
|
+
using Ruby[https://www.ruby-lang.org] 2.1.2
|
141
|
+
and Rack[https://github.com/rack/rack] 1.6.0.
|
142
|
+
|
143
|
+
== History
|
144
|
+
|
145
|
+
1. 2015-01-29, v0.10.0: First public release
|
146
|
+
* XFile ran in production for more than a year before public release.
|
147
|
+
|
148
|
+
== License
|
149
|
+
|
150
|
+
({ISC License}[http://opensource.org/licenses/ISC])
|
151
|
+
|
152
|
+
Copyright (c) 2015, Clint Pachl <pachl@ecentryx.com>
|
153
|
+
|
154
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
155
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
156
|
+
and this permission notice appear in all copies.
|
157
|
+
|
158
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
159
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
160
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
161
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
162
|
+
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
163
|
+
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
164
|
+
THIS SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
verbose false
|
2
|
+
|
3
|
+
require 'rubygems/package_task'
|
4
|
+
Gem::PackageTask.new(eval File.read('xfile.gemspec')) {}
|
5
|
+
|
6
|
+
require 'rdoc/task'
|
7
|
+
RDoc::Task.new do |rdoc|
|
8
|
+
rdoc.main = 'README.rdoc'
|
9
|
+
rdoc.rdoc_files.include('README.rdoc', 'lib/**/*.rb')
|
10
|
+
end
|
11
|
+
|
12
|
+
desc 'Upload RDoc files to public server'
|
13
|
+
task :uprdoc => :rdoc do
|
14
|
+
dir = '/var/www/sites/ecentryx.com/public/gems/rack-session-xfile/'
|
15
|
+
host = 'hercules'
|
16
|
+
sh "cd ./html && pax -w . | ssh #{host} 'cd #{dir} && rm -rf * && pax -r'"
|
17
|
+
end
|
18
|
+
|
19
|
+
desc 'Run all specs'
|
20
|
+
task :spec do
|
21
|
+
sh 'bacon -aq'
|
22
|
+
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
require 'rack/session/abstract/id'
|
2
|
+
require 'tmpdir'
|
3
|
+
|
4
|
+
module Rack
|
5
|
+
module Session
|
6
|
+
|
7
|
+
class XFile < Abstract::ID
|
8
|
+
|
9
|
+
VERSION = '0.10.1'
|
10
|
+
|
11
|
+
File = ::File # override Rack::File
|
12
|
+
|
13
|
+
SIDCharacters = [*(0..9), *(:a..:z), *(:A..:Z)].map!(&:to_s)
|
14
|
+
|
15
|
+
Abstract::ID::DEFAULT_OPTIONS.merge! \
|
16
|
+
session_dir: File.join(Dir.tmpdir, 'xfile-sessions'),
|
17
|
+
user_agent_filter: nil,
|
18
|
+
key: 'xid'
|
19
|
+
|
20
|
+
def initialize(app, options = {})
|
21
|
+
super
|
22
|
+
@mutex = Mutex.new
|
23
|
+
@session_dir = @default_options[:session_dir]
|
24
|
+
@ua_filter = @default_options[:user_agent_filter]
|
25
|
+
@sid_nbytes = @default_options[:sidbits] / 8
|
26
|
+
setup_directories
|
27
|
+
check_permissions
|
28
|
+
end
|
29
|
+
|
30
|
+
def set_session(env, sid, session, options)
|
31
|
+
critical_section(env) do
|
32
|
+
write_session(sid, session)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_session(env, sid)
|
37
|
+
critical_section(env) do
|
38
|
+
return [nil, {}] if filter_request(env, sid)
|
39
|
+
|
40
|
+
unless sid and session = read_session(sid)
|
41
|
+
sid, session = generate_sid, {}
|
42
|
+
end
|
43
|
+
[sid, session]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def destroy_session(env, sid, options)
|
48
|
+
critical_section(env) do
|
49
|
+
File.unlink(session_file(sid)) rescue nil
|
50
|
+
generate_sid unless options[:drop]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def session_count
|
55
|
+
Dir[File.join(@session_dir, '?', '*')].count
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
#
|
61
|
+
# Provide thread-safety for critical sections.
|
62
|
+
#--
|
63
|
+
# Some systems do not implement, or implement correctly, advisory file
|
64
|
+
# locking for threads; therefore a mutex lock must encompass file locks.
|
65
|
+
#
|
66
|
+
def critical_section(env)
|
67
|
+
@mutex.lock if env['rack.multithread']
|
68
|
+
yield
|
69
|
+
ensure
|
70
|
+
@mutex.unlock if @mutex.locked?
|
71
|
+
end
|
72
|
+
|
73
|
+
#
|
74
|
+
# Atomically generate a unique session ID and allocate the resource file.
|
75
|
+
# Returns the session ID.
|
76
|
+
# --
|
77
|
+
# NOTE
|
78
|
+
# The sid is also a filename. Only SIDCharacters are allowed in the sid.
|
79
|
+
# If +super+ is called it returns a HEX sid, which is a subset.
|
80
|
+
#
|
81
|
+
def generate_sid
|
82
|
+
sid = SecureRandom.urlsafe_base64(@sid_nbytes) rescue super
|
83
|
+
sid.tr!('_-', SIDCharacters.sample(2).join)
|
84
|
+
File.open(session_file(sid), File::WRONLY|File::CREAT|File::EXCL).close
|
85
|
+
sid
|
86
|
+
rescue Errno::EEXIST
|
87
|
+
retry
|
88
|
+
end
|
89
|
+
|
90
|
+
#
|
91
|
+
# Read session from file using a shared lock.
|
92
|
+
#
|
93
|
+
def read_session(sid)
|
94
|
+
File.open(session_file(sid)) do |f|
|
95
|
+
f.flock(File::LOCK_SH)
|
96
|
+
begin
|
97
|
+
f.size == 0 ? {} : Marshal.load(f)
|
98
|
+
rescue ArgumentError, TypeError => e
|
99
|
+
warn "#{self.class} PATH=#{session_file(sid)}: #{e.message}"
|
100
|
+
{}
|
101
|
+
end
|
102
|
+
end
|
103
|
+
rescue Errno::ENOENT
|
104
|
+
end
|
105
|
+
|
106
|
+
#
|
107
|
+
# Write session to file using an exclusive lock.
|
108
|
+
#
|
109
|
+
def write_session(sid, session)
|
110
|
+
File.open(session_file(sid), 'r+') do |f|
|
111
|
+
f.flock(File::LOCK_EX)
|
112
|
+
f.write(Marshal.dump(session))
|
113
|
+
f.flush
|
114
|
+
f.truncate(f.pos)
|
115
|
+
end
|
116
|
+
sid
|
117
|
+
rescue => e
|
118
|
+
warn "#{self.class} cannot set session: #{e.message}"
|
119
|
+
false
|
120
|
+
end
|
121
|
+
|
122
|
+
#
|
123
|
+
# Skip session processing if the request matches a filter.
|
124
|
+
#
|
125
|
+
# == User agent filter
|
126
|
+
# User agents that do not utilize sessions, such as bots, may be filtered.
|
127
|
+
#
|
128
|
+
# == SID filter
|
129
|
+
# The +sid+ is provided by untrusted clients. A tampered sid could result
|
130
|
+
# in directory traversal, pathname, or guessing exploits. Do not waste
|
131
|
+
# resources on a client that sends an invalid sid.
|
132
|
+
#
|
133
|
+
# NOTE: Whitelist all valid sid characters. It's probably not sufficient
|
134
|
+
# to blacklist only potentially dangerous chars such as '..', '/' or '\'.
|
135
|
+
#
|
136
|
+
# sid bit range: 62^10-62^50 (approx. 2^60-2^297)
|
137
|
+
#
|
138
|
+
def filter_request(env, sid)
|
139
|
+
request = nil
|
140
|
+
|
141
|
+
if @ua_filter
|
142
|
+
request = Request.new(env)
|
143
|
+
if request.user_agent =~ @ua_filter
|
144
|
+
return request.session.options[:skip] = true
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
if sid and sid !~ /^[a-zA-Z0-9]{10,50}$/
|
149
|
+
warn "#{self.class} SID=#{sid}: tampered sid detected."
|
150
|
+
request ||= Request.new(env)
|
151
|
+
request.session.options[:skip] = true
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def session_file(sid)
|
156
|
+
File.join @session_dir, sid[0], sid
|
157
|
+
end
|
158
|
+
|
159
|
+
#
|
160
|
+
# Create directory structure for session file distribution.
|
161
|
+
#
|
162
|
+
def setup_directories
|
163
|
+
Dir.mkdir(@session_dir, 0700) unless Dir.exist? @session_dir
|
164
|
+
|
165
|
+
SIDCharacters.each do |char|
|
166
|
+
dir = File.join @session_dir, char
|
167
|
+
Dir.mkdir(dir, 0700) unless Dir.exist? dir
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
#
|
172
|
+
# Ensure session directory has secure permissions, else raise exception.
|
173
|
+
#
|
174
|
+
def check_permissions
|
175
|
+
perms = File.stat(@session_dir).mode
|
176
|
+
msg = "#{self.class}: #{@session_dir} sessions directory is "
|
177
|
+
|
178
|
+
raise Errno::EACCES, msg + 'not owned' unless File.owned? @session_dir
|
179
|
+
raise Errno::EACCES, msg + 'not accessible' if perms & 0700 != 0700
|
180
|
+
raise SecurityError, msg + 'world accessible' if perms & 0007 != 0
|
181
|
+
|
182
|
+
if perms & 070 != 0
|
183
|
+
if File.grpowned? @session_dir
|
184
|
+
warn msg + 'group owned/accessible'
|
185
|
+
else
|
186
|
+
raise SecurityError, msg + 'group accessible'
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require_relative 'rack/session/xfile'
|
data/spec/spec_xfile.rb
ADDED
@@ -0,0 +1,461 @@
|
|
1
|
+
require 'rack/session/xfile'
|
2
|
+
require 'rack/mock'
|
3
|
+
require 'fileutils'
|
4
|
+
require 'tmpdir'
|
5
|
+
|
6
|
+
|
7
|
+
describe Rack::Session::XFile do
|
8
|
+
|
9
|
+
def counter number
|
10
|
+
%[{"counter"=>#{number}}]
|
11
|
+
end
|
12
|
+
|
13
|
+
def empty_session
|
14
|
+
'{}'
|
15
|
+
end
|
16
|
+
|
17
|
+
def set_sid id
|
18
|
+
if id =~ TheSession
|
19
|
+
{'HTTP_COOKIE' => id}
|
20
|
+
else
|
21
|
+
{'HTTP_COOKIE' => "#{@session_key}=#{id}"}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Create an xfile out-of-band in the current session directory.
|
26
|
+
def create_session data = nil
|
27
|
+
app = Rack::Session::XFile.new(nil)
|
28
|
+
sid = app.send(:generate_sid)
|
29
|
+
app.set_session({}, sid, (data || {sid: sid}), {})
|
30
|
+
sid
|
31
|
+
end
|
32
|
+
|
33
|
+
spec_tmp_dir = Dir.mktmpdir 'xfile-specs-'
|
34
|
+
@session_dir = Rack::Session::XFile::DEFAULT_OPTIONS[:session_dir].
|
35
|
+
replace(File.join(spec_tmp_dir, 'xfile-sessions'))
|
36
|
+
@session_key = Rack::Session::XFile::DEFAULT_OPTIONS[:key]
|
37
|
+
session_bits = Rack::Session::XFile::DEFAULT_OPTIONS[:sidbits]
|
38
|
+
session_chars = (session_bits / 8 / 3.0 * 4).ceil # urlsafe_base64 length
|
39
|
+
TheSession = /^#{@session_key}=[0-9a-zA-Z]{#{session_chars}};/
|
40
|
+
|
41
|
+
# Apps
|
42
|
+
|
43
|
+
incrementor = lambda do |env|
|
44
|
+
env['rack.session']['counter'] ||= 0
|
45
|
+
env['rack.session']['counter'] += 1
|
46
|
+
Rack::Response.new(env['rack.session'].inspect).to_a
|
47
|
+
end
|
48
|
+
|
49
|
+
noop = lambda do |env|
|
50
|
+
Rack::Response.new('noop').to_a
|
51
|
+
end
|
52
|
+
|
53
|
+
read_session_id = lambda do |env|
|
54
|
+
Rack::Response.new(env['rack.session'].id).to_a
|
55
|
+
end
|
56
|
+
|
57
|
+
read_session = lambda do |env|
|
58
|
+
env['rack.session'].send(:load_for_read!)
|
59
|
+
Rack::Response.new(env['rack.session'].inspect).to_a
|
60
|
+
end
|
61
|
+
|
62
|
+
defer_session = lambda do |env|
|
63
|
+
env['rack.session.options'][:defer] = true
|
64
|
+
incrementor.call(env)
|
65
|
+
end
|
66
|
+
|
67
|
+
drop_session = lambda do |env|
|
68
|
+
env['rack.session.options'][:drop] = true
|
69
|
+
incrementor.call(env)
|
70
|
+
end
|
71
|
+
|
72
|
+
renew_session = lambda do |env|
|
73
|
+
env['rack.session.options'][:renew] = true
|
74
|
+
incrementor.call(env)
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
before do
|
79
|
+
@warnings = warnings = []
|
80
|
+
Rack::Session::XFile.class_eval do
|
81
|
+
define_method(:warn) { |m| warnings << m }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
after do
|
86
|
+
FileUtils.rm_rf(@session_dir)
|
87
|
+
Rack::Session::XFile.class_eval { remove_method :warn }
|
88
|
+
end
|
89
|
+
|
90
|
+
it 'sets default options' do
|
91
|
+
key = 'secret'
|
92
|
+
dir = File.join(spec_tmp_dir, 'secret-sessions')
|
93
|
+
filter = /useragent/
|
94
|
+
expire = 3600
|
95
|
+
app = Rack::Session::XFile.new(incrementor,
|
96
|
+
key: key,
|
97
|
+
session_dir: dir,
|
98
|
+
user_agent_filter: filter,
|
99
|
+
expire_after: expire)
|
100
|
+
app.key.should.equal key
|
101
|
+
app.default_options[:key].should.be.nil # deleted in superclass
|
102
|
+
app.default_options[:session_dir].should.equal dir
|
103
|
+
app.default_options[:user_agent_filter].should.equal filter
|
104
|
+
app.default_options[:expire_after].should.equal expire
|
105
|
+
cookie = Rack::MockRequest.new(app).get('/')['Set-Cookie']
|
106
|
+
cookie.should.match /^#{key}=/
|
107
|
+
FileUtils.rm_rf(dir)
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'initializes session directory structure' do
|
111
|
+
app = Rack::Session::XFile.new(noop)
|
112
|
+
dir = app.default_options[:session_dir]
|
113
|
+
Dir[File.join(dir, '**', '*')].count.should == # greedy match
|
114
|
+
Rack::Session::XFile::SIDCharacters.count
|
115
|
+
app.session_count.should.be.zero
|
116
|
+
end
|
117
|
+
|
118
|
+
it 'sets secure permissions when creating session directory' do
|
119
|
+
# NOTE unable to test when no user/group ownership
|
120
|
+
Rack::Session::XFile.new(noop)
|
121
|
+
(File.stat(@session_dir).mode & 077).should.be.zero
|
122
|
+
File.owned?(@session_dir).should.be.true
|
123
|
+
end
|
124
|
+
|
125
|
+
it 'raises exception if session directory is world accessible' do
|
126
|
+
Dir.mkdir(@session_dir, 0707)
|
127
|
+
should.raise(SecurityError) { Rack::Session::XFile.new(noop) }.
|
128
|
+
message.should.include 'world accessible'
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'raises exception if session directory is not user accessible' do
|
132
|
+
Dir.mkdir @session_dir
|
133
|
+
File.chmod 0000, @session_dir
|
134
|
+
should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
|
135
|
+
File.chmod 0100, @session_dir
|
136
|
+
should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
|
137
|
+
File.chmod 0200, @session_dir
|
138
|
+
should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
|
139
|
+
File.chmod 0400, @session_dir
|
140
|
+
should.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
|
141
|
+
File.chmod 0700, @session_dir
|
142
|
+
should.not.raise(Errno::EACCES) { Rack::Session::XFile.new(noop) }
|
143
|
+
end
|
144
|
+
|
145
|
+
it 'warns if session directory is group accessible but group owned' do
|
146
|
+
Dir.mkdir(@session_dir, 0770)
|
147
|
+
should.not.raise { Rack::Session::XFile.new(noop) }
|
148
|
+
@warnings.count.should.equal 1
|
149
|
+
@warnings.first.should.include 'group owned/accessible'
|
150
|
+
end
|
151
|
+
|
152
|
+
it 'returns false when setting session if cannot serialize data' do
|
153
|
+
obj = Object.new
|
154
|
+
def obj.singleton() nil end
|
155
|
+
nonserializable_session = { data: obj }
|
156
|
+
|
157
|
+
sid = create_session
|
158
|
+
app = Rack::Session::XFile.new(noop)
|
159
|
+
app.set_session({}, sid, nonserializable_session, {}).should.be.false
|
160
|
+
@warnings.count.should.equal 1
|
161
|
+
@warnings.first.should.match /cannot set session.*singleton can't be dumped/
|
162
|
+
end
|
163
|
+
|
164
|
+
it 'returns false when setting session if sid is nonexistent' do
|
165
|
+
sid = 'nonexistent'
|
166
|
+
app = Rack::Session::XFile.new(noop)
|
167
|
+
app.set_session({}, sid, {}, {}).should.be.false
|
168
|
+
@warnings.count.should.equal 1
|
169
|
+
@warnings.first.should.match /cannot set session.*No such file/
|
170
|
+
end
|
171
|
+
|
172
|
+
it 'creates new xfile session' do
|
173
|
+
app = Rack::Session::XFile.new(incrementor)
|
174
|
+
res = Rack::MockRequest.new(app).get('/')
|
175
|
+
res['Set-Cookie'].should.match TheSession
|
176
|
+
res.body.should.equal counter(1)
|
177
|
+
end
|
178
|
+
|
179
|
+
it 'creates one xfile with six stateful requests' do
|
180
|
+
app = Rack::Session::XFile.new(incrementor)
|
181
|
+
req = Rack::MockRequest.new(app)
|
182
|
+
|
183
|
+
app.session_count.should.be.zero
|
184
|
+
cookie = req.get('/')['Set-Cookie']
|
185
|
+
app.session_count.should.equal 1
|
186
|
+
6.times { req.get('/', set_sid(cookie)) }
|
187
|
+
app.session_count.should.equal 1
|
188
|
+
end
|
189
|
+
|
190
|
+
it 'creates six xfiles with six stateless requests' do
|
191
|
+
app = Rack::Session::XFile.new(incrementor)
|
192
|
+
req = Rack::MockRequest.new(app)
|
193
|
+
|
194
|
+
app.session_count.should.be.zero
|
195
|
+
6.times { req.get('/') }
|
196
|
+
app.session_count.should.equal 6
|
197
|
+
end
|
198
|
+
|
199
|
+
it 'loads session data from existing xfile' do
|
200
|
+
app = Rack::Session::XFile.new(read_session)
|
201
|
+
sid = create_session(data = {'preloaded' => true}) # rack stringifies keys
|
202
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(sid))
|
203
|
+
res.body.should.equal data.inspect
|
204
|
+
app.session_count.should.equal 1
|
205
|
+
end
|
206
|
+
|
207
|
+
it 'merges session data from middleware in front' do
|
208
|
+
app = Rack::Session::XFile.new(incrementor)
|
209
|
+
res = Rack::MockRequest.new(app).get('/', 'rack.session' => {foo: 'bar'})
|
210
|
+
res.body.should.match /counter/
|
211
|
+
res.body.should.match /foo/
|
212
|
+
end
|
213
|
+
|
214
|
+
it 'creates new session when xfile is nonexistent' do
|
215
|
+
app = Rack::Session::XFile.new(read_session)
|
216
|
+
res = Rack::MockRequest.new(app).get('/', set_sid('nonexistent'))
|
217
|
+
res.body.should.equal empty_session
|
218
|
+
res['Set-Cookie'].should.match TheSession
|
219
|
+
end
|
220
|
+
|
221
|
+
it 'creates new session when xfile is empty' do
|
222
|
+
app = Rack::Session::XFile.new(read_session)
|
223
|
+
sid = create_session
|
224
|
+
xfile = app.send(:session_file, sid)
|
225
|
+
File.truncate(xfile, 0)
|
226
|
+
File.size(xfile).should.be.zero
|
227
|
+
|
228
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(sid))
|
229
|
+
res.body.should.equal empty_session
|
230
|
+
res['Set-Cookie'].should.be.nil # because sid already exists
|
231
|
+
app.session_count.should.equal 1
|
232
|
+
File.size(xfile).should.not.be.zero
|
233
|
+
end
|
234
|
+
|
235
|
+
it 'creates new session when xfile is corrupt' do
|
236
|
+
app = Rack::Session::XFile.new(read_session)
|
237
|
+
sid = create_session
|
238
|
+
session_file = app.send(:session_file, sid)
|
239
|
+
session_data = {'key' => 0}
|
240
|
+
|
241
|
+
# return an empty session if marshaled session data is corrupt in any way
|
242
|
+
critical_indices = [0, 3, 6] # positions in marhaled +session_data+
|
243
|
+
critical_indices.each do |i| # that are suceptible to corruption
|
244
|
+
corrupt_data = Marshal.dump(session_data).insert(i, 'FUBAR')
|
245
|
+
File.write(session_file, corrupt_data)
|
246
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(sid))
|
247
|
+
res.body.should.equal empty_session
|
248
|
+
res['Set-Cookie'].should.be.nil
|
249
|
+
end
|
250
|
+
|
251
|
+
@warnings.count.should.equal critical_indices.count
|
252
|
+
@warnings[0].should.include 'incompatible marshal file format'
|
253
|
+
@warnings[1].should.include 'dump format error for symbol'
|
254
|
+
@warnings[2].should.include 'marshal data too short'
|
255
|
+
end
|
256
|
+
|
257
|
+
it 'protects against sid encoding and path traversal exploits' do
|
258
|
+
app = Rack::Session::XFile.new(read_session)
|
259
|
+
|
260
|
+
res1 = Rack::MockRequest.new(app).get('/', set_sid('%2e%2e%2f%2e%2e%2f%00'))
|
261
|
+
res1.body.should.equal empty_session
|
262
|
+
res1['Set-Cookie'].should.be.nil
|
263
|
+
app.session_count.should.be.zero
|
264
|
+
|
265
|
+
res2 = Rack::MockRequest.new(app).get('/', set_sid('../../../../etc/passwd'))
|
266
|
+
res2.body.should.equal empty_session
|
267
|
+
res2['Set-Cookie'].should.be.nil
|
268
|
+
app.session_count.should.be.zero
|
269
|
+
|
270
|
+
@warnings.size.should.equal 2
|
271
|
+
@warnings[0].should.include 'tampered sid detected'
|
272
|
+
@warnings[1].should.include 'tampered sid detected'
|
273
|
+
end
|
274
|
+
|
275
|
+
it 'returns new sid cookie with :renew option set' do
|
276
|
+
inc_cntr = Rack::Session::XFile.new(incrementor)
|
277
|
+
read_sid = Rack::Session::XFile.new(read_session_id) # doesn't inc counter
|
278
|
+
renew_sid = Rack::Session::XFile.new(renew_session) # increments counter
|
279
|
+
|
280
|
+
res = Rack::MockRequest.new(inc_cntr).get('/')
|
281
|
+
cookie = res['Set-Cookie']
|
282
|
+
res.body.should.equal counter(1)
|
283
|
+
|
284
|
+
res = Rack::MockRequest.new(read_sid).get('/', set_sid(cookie))
|
285
|
+
cookie = res['Set-Cookie'] if res['Set-Cookie']
|
286
|
+
res.body.length.should.equal session_chars
|
287
|
+
old_sid = res.body
|
288
|
+
|
289
|
+
res = Rack::MockRequest.new(renew_sid).get('/', set_sid(cookie))
|
290
|
+
cookie = res['Set-Cookie'] if res['Set-Cookie']
|
291
|
+
res.body.should.equal counter(2)
|
292
|
+
|
293
|
+
res = Rack::MockRequest.new(read_sid).get('/', set_sid(cookie))
|
294
|
+
cookie = res['Set-Cookie'] if res['Set-Cookie']
|
295
|
+
res.body.length.should.equal session_chars
|
296
|
+
new_sid = res.body
|
297
|
+
|
298
|
+
# the session id should have changed, ...
|
299
|
+
new_sid.should.not.equal old_sid
|
300
|
+
|
301
|
+
# but the session data should persist
|
302
|
+
res = Rack::MockRequest.new(inc_cntr).get('/', set_sid(cookie))
|
303
|
+
res.body.should.equal counter(3)
|
304
|
+
|
305
|
+
# and there should only be one xfile session
|
306
|
+
inc_cntr.session_count.should.equal 1
|
307
|
+
end
|
308
|
+
|
309
|
+
it 'returns no cookie and deletes xfile with :drop option set' do
|
310
|
+
app = Rack::Session::XFile.new(incrementor)
|
311
|
+
req = Rack::MockRequest.new(app)
|
312
|
+
drop = Rack::Utils::Context.new(app, drop_session)
|
313
|
+
dreq = Rack::MockRequest.new(drop)
|
314
|
+
|
315
|
+
res = req.get('/')
|
316
|
+
original_session = res['Set-Cookie'][TheSession]
|
317
|
+
res.body.should.equal counter(1)
|
318
|
+
app.session_count.should.equal 1
|
319
|
+
|
320
|
+
res = req.get('/', set_sid(original_session))
|
321
|
+
res.body.should.equal counter(2)
|
322
|
+
app.session_count.should.equal 1
|
323
|
+
|
324
|
+
res = dreq.get('/', set_sid(original_session))
|
325
|
+
res['Set-Cookie'].should.be.nil
|
326
|
+
res.body.should.equal counter(3)
|
327
|
+
app.session_count.should.be.zero
|
328
|
+
|
329
|
+
res = req.get('/', set_sid(original_session))
|
330
|
+
res['Set-Cookie'][TheSession].should.not.equal original_session
|
331
|
+
res.body.should.equal counter(1)
|
332
|
+
app.session_count.should.equal 1
|
333
|
+
end
|
334
|
+
|
335
|
+
it 'returns no cookie and updates xfile with :defer option set' do
|
336
|
+
app = Rack::Session::XFile.new(incrementor)
|
337
|
+
exp = Rack::Session::XFile.new(incrementor, expire_after:3600)
|
338
|
+
exdfr = Rack::Session::XFile.new(incrementor, expire_after:3600, defer:true)
|
339
|
+
defer = Rack::Session::XFile.new(defer_session)
|
340
|
+
|
341
|
+
res = Rack::MockRequest.new(defer).get('/')
|
342
|
+
res['Set-Cookie'].should.be.nil # normally, new sessions set cookies
|
343
|
+
res.body.should.equal counter(1) # this xfile is updated on the server,
|
344
|
+
app.session_count.should.equal 1 # but is not referenced by any client
|
345
|
+
|
346
|
+
res = Rack::MockRequest.new(app).get('/')
|
347
|
+
cookie = res['Set-Cookie']
|
348
|
+
cookie.should.not.be.nil # because new session created
|
349
|
+
res.body.should.equal counter(1)
|
350
|
+
app.session_count.should.equal 2
|
351
|
+
|
352
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(cookie))
|
353
|
+
res['Set-Cookie'].should.be.nil # because sid did not change
|
354
|
+
res.body.should.equal counter(2)
|
355
|
+
app.session_count.should.equal 2
|
356
|
+
|
357
|
+
res = Rack::MockRequest.new(exp).get('/', set_sid(cookie))
|
358
|
+
cookie = res['Set-Cookie']
|
359
|
+
cookie.should.not.be.nil # because :expire_after forced update
|
360
|
+
res.body.should.equal counter(3)
|
361
|
+
app.session_count.should.equal 2
|
362
|
+
|
363
|
+
res = Rack::MockRequest.new(exdfr).get('/', set_sid(cookie))
|
364
|
+
res['Set-Cookie'].should.be.nil # because :defer overrides :expire_after
|
365
|
+
res.body.should.equal counter(4)
|
366
|
+
app.session_count.should.equal 2
|
367
|
+
|
368
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(cookie))
|
369
|
+
res['Set-Cookie'].should.be.nil # because sid did not change,
|
370
|
+
res.body.should.equal counter(5) # but keeps state even after :defer
|
371
|
+
app.session_count.should.equal 2
|
372
|
+
end
|
373
|
+
|
374
|
+
it 'returns no cookie and updates xfile with :defer option set in context' do
|
375
|
+
app = Rack::Session::XFile.new(incrementor)
|
376
|
+
defer = Rack::Utils::Context.new(app, defer_session)
|
377
|
+
|
378
|
+
res = Rack::MockRequest.new(defer).get('/')
|
379
|
+
res['Set-Cookie'].should.be.nil
|
380
|
+
res.body.should.equal counter(1)
|
381
|
+
app.session_count.should.equal 1
|
382
|
+
end
|
383
|
+
|
384
|
+
it 'returns no cookie and skips session processing for filtered requests' do
|
385
|
+
human_user_agent = 'Mozilla/5.0 CoolSurf/100.50'
|
386
|
+
robot_user_agent = 'BigSpider v1.0'
|
387
|
+
robot_filter = /(bot|spider|crawler)/i
|
388
|
+
app = Rack::Session::XFile.new(incrementor, user_agent_filter: robot_filter)
|
389
|
+
req = Rack::MockRequest.new(app)
|
390
|
+
|
391
|
+
res = req.get('/', 'HTTP_USER_AGENT' => robot_user_agent)
|
392
|
+
res['Set-Cookie'].should.be.nil
|
393
|
+
app.session_count.should.be.zero
|
394
|
+
|
395
|
+
res = req.get('/', 'HTTP_USER_AGENT' => human_user_agent)
|
396
|
+
res['Set-Cookie'].should.not.be.nil
|
397
|
+
app.session_count.should.equal 1
|
398
|
+
end
|
399
|
+
|
400
|
+
it 'returns no cookie if sid is unchanged' do
|
401
|
+
app = Rack::Session::XFile.new(incrementor)
|
402
|
+
req = Rack::MockRequest.new(app)
|
403
|
+
|
404
|
+
cookie = req.get('/')['Set-Cookie']
|
405
|
+
cookie.should.match TheSession
|
406
|
+
|
407
|
+
res = req.get('/', set_sid(cookie))
|
408
|
+
res.body.should.equal counter(2)
|
409
|
+
res['Set-Cookie'].should.be.nil
|
410
|
+
|
411
|
+
res = req.get('/', set_sid(cookie))
|
412
|
+
res.body.should.equal counter(3)
|
413
|
+
res['Set-Cookie'].should.be.nil
|
414
|
+
end
|
415
|
+
|
416
|
+
it 'returns no cookie if session is not read or written' do
|
417
|
+
app = Rack::Session::XFile.new(noop)
|
418
|
+
res = Rack::MockRequest.new(app).get('/')
|
419
|
+
res['Set-Cookie'].should.be.nil
|
420
|
+
app.session_count.should.be.zero
|
421
|
+
end
|
422
|
+
|
423
|
+
it 'returns no cookie if previous session is not read or written' do
|
424
|
+
app = Rack::Session::XFile.new(noop)
|
425
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(create_session))
|
426
|
+
res['Set-Cookie'].should.be.nil
|
427
|
+
app.session_count.should.equal 1
|
428
|
+
end
|
429
|
+
|
430
|
+
it 'returns no cookie if session is read but not written' do
|
431
|
+
app = Rack::Session::XFile.new(read_session)
|
432
|
+
res = Rack::MockRequest.new(app).get('/')
|
433
|
+
res['Set-Cookie'].should.be.nil
|
434
|
+
app.session_count.should.be.zero
|
435
|
+
end
|
436
|
+
|
437
|
+
it 'returns no cookie if previous session is read but not written' do
|
438
|
+
app = Rack::Session::XFile.new(read_session)
|
439
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(create_session))
|
440
|
+
res['Set-Cookie'].should.be.nil
|
441
|
+
app.session_count.should.equal 1
|
442
|
+
end
|
443
|
+
|
444
|
+
it 'returns no cookie if session is not written and was not '\
|
445
|
+
'previously created, regardless of the :expire_after option' do
|
446
|
+
app = Rack::Session::XFile.new(read_session, expire_after: 3600)
|
447
|
+
res = Rack::MockRequest.new(app).get('/')
|
448
|
+
res['Set-Cookie'].should.be.nil
|
449
|
+
app.session_count.should.be.zero
|
450
|
+
end
|
451
|
+
|
452
|
+
it 'returns cookie if previous session is not read or written '\
|
453
|
+
'but :expire_after option is set' do
|
454
|
+
app = Rack::Session::XFile.new(noop, expire_after: 3600)
|
455
|
+
res = Rack::MockRequest.new(app).get('/', set_sid(create_session))
|
456
|
+
res['Set-Cookie'].should.not.be.nil
|
457
|
+
app.session_count.should.equal 1
|
458
|
+
end
|
459
|
+
|
460
|
+
FileUtils.rm_rf(spec_tmp_dir)
|
461
|
+
end
|
data/xfile.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'rack-session-xfile'
|
3
|
+
s.version = File.read('lib/rack/session/xfile.rb')[/VERSION = '(.*)'/, 1]
|
4
|
+
s.author = 'Clint Pachl'
|
5
|
+
s.email = 'pachl@ecentryx.com'
|
6
|
+
s.homepage = 'http://ecentryx.com/gems/rack-session-xfile'
|
7
|
+
s.license = 'ISC'
|
8
|
+
s.summary = 'File-based session management with eXclusive R/W locking.'
|
9
|
+
s.description = <<-EOS
|
10
|
+
A simple and reliable file-based session manager with eXclusive R/W locking.
|
11
|
+
Session data is updated atomically using mutex and file locks, making it
|
12
|
+
suitable for multi-threaded and multi-process applications.
|
13
|
+
EOS
|
14
|
+
s.files = Dir['Rakefile', 'README*', '*.gemspec', 'lib/**/*.rb', 'spec/*']
|
15
|
+
s.test_files = Dir['spec/spec_*.rb']
|
16
|
+
s.required_ruby_version = '>= 1.9.2'
|
17
|
+
s.add_runtime_dependency 'rack', '~> 1.5'
|
18
|
+
s.add_development_dependency 'bacon', '~> 1.2'
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rack-session-xfile
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.10.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Clint Pachl
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-01-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bacon
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.2'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.2'
|
41
|
+
description: |
|
42
|
+
A simple and reliable file-based session manager with eXclusive R/W locking.
|
43
|
+
Session data is updated atomically using mutex and file locks, making it
|
44
|
+
suitable for multi-threaded and multi-process applications.
|
45
|
+
email: pachl@ecentryx.com
|
46
|
+
executables: []
|
47
|
+
extensions: []
|
48
|
+
extra_rdoc_files: []
|
49
|
+
files:
|
50
|
+
- README.rdoc
|
51
|
+
- Rakefile
|
52
|
+
- lib/rack-session-xfile.rb
|
53
|
+
- lib/rack/session/xfile.rb
|
54
|
+
- spec/spec_xfile.rb
|
55
|
+
- xfile.gemspec
|
56
|
+
homepage: http://ecentryx.com/gems/rack-session-xfile
|
57
|
+
licenses:
|
58
|
+
- ISC
|
59
|
+
metadata: {}
|
60
|
+
post_install_message:
|
61
|
+
rdoc_options: []
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 1.9.2
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
requirements: []
|
75
|
+
rubyforge_project:
|
76
|
+
rubygems_version: 2.2.2
|
77
|
+
signing_key:
|
78
|
+
specification_version: 4
|
79
|
+
summary: File-based session management with eXclusive R/W locking.
|
80
|
+
test_files:
|
81
|
+
- spec/spec_xfile.rb
|