rack-session-xfile 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|