properly half-close both sides of the HTTP connection
[coquelicot.git] / spec / coquelicot / stored_file_spec.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2010-2012 potager.org <jardiniers@potager.org>
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 require 'spec_helper'
18 require 'tmpdir'
19 require 'yaml'
20 require 'timecop'
21 require 'base64'
22
23 module Coquelicot
24   describe StoredFile do
25     shared_context 'create new StoredFile' do
26       around do |example|
27         Dir.mktmpdir('coquelicot') do |tmpdir|
28           @tmpdir = tmpdir
29           example.run
30         end
31       end
32
33       def create_stored_file(extra_meta = {})
34         @stored_file_path ||= File.expand_path('stored_file', @tmpdir)
35         @pass = 'secret'
36         @src = __FILE__
37         @src_length = File.stat(@src).size
38         meta = { 'Expire-at' => 0 }
39         meta.merge!(extra_meta)
40         content = File.read(@src)
41         StoredFile.create(@stored_file_path, @pass, meta) do
42           buf, content = content, nil
43           buf
44         end
45       end
46     end
47
48     describe '.get_cipher' do
49       context 'when given an unknown method' do
50         it 'should raise an error' do
51           expect {
52             StoredFile.get_cipher('secret', 'salt', :whatever)
53           }.to raise_error(NameError)
54         end
55       end
56       [ :encrypt, :decrypt ].each do |method|
57         let(:key_len)  { 32 } # this is AES-256-CBC
58         let(:iv_len)   { 16 } # this is AES-256-CBC
59         let(:hmac_len) { key_len + iv_len }
60         let(:hmac) { (1..hmac_len).to_a.collect { |c| c.chr }.join }
61         context "when given #{method} as method" do
62           it 'should use PKCS5.pbkdf2_hmac_sha1' do
63             OpenSSL::PKCS5.should_receive(:pbkdf2_hmac_sha1).
64               with('secret', 'salt', 2000, hmac_len).
65               and_return(hmac)
66             StoredFile.get_cipher('secret', 'salt', method)
67           end
68           it 'should set the key to lower part of the HMAC' do
69             OpenSSL::PKCS5.stub(:pbkdf2_hmac_sha1).
70               and_return(hmac)
71             cipher = OpenSSL::Cipher.new 'AES-256-CBC'
72             cipher.should_receive(:key=).with(hmac[0..key_len-1])
73             OpenSSL::Cipher.stub(:new).and_return(cipher)
74             StoredFile.get_cipher('secret', 'salt', method)
75           end
76           it 'should set the IV to the higher part of the HMAC' do
77             OpenSSL::PKCS5.stub(:pbkdf2_hmac_sha1).
78               and_return(hmac)
79             cipher = OpenSSL::Cipher.new 'AES-256-CBC'
80             cipher.should_receive(:iv=).with(hmac[key_len..-1])
81             OpenSSL::Cipher.stub(:new).and_return(cipher)
82             StoredFile.get_cipher('secret', 'salt', method)
83           end
84           it 'should return an OpenSSL::Cipher' do
85             cipher = StoredFile.get_cipher('secret', 'salt', method)
86             cipher.should be_a(OpenSSL::Cipher)
87           end
88         end
89       end
90     end
91
92     describe '.gen_salt' do
93       it 'should return a string of proper length' do
94         StoredFile.gen_salt.length == StoredFile::SALT_LEN
95       end
96       it 'should call OpenSSL::Random every time' do
97         OpenSSL::Random.should_receive(:random_bytes).
98             and_return(1, 2)
99         StoredFile.gen_salt == 1
100         StoredFile.gen_salt == 2
101       end
102     end
103
104     describe '.open' do
105       context 'when the given file does not exist' do
106         it 'should raise an error' do
107           expect {
108             StoredFile.open('/nonexistent')
109           }.to raise_error(Errno::ENOENT)
110         end
111       end
112       context 'when the file is not a StoredFile' do
113         it 'should raise an error' do
114           expect {
115             StoredFile.open(__FILE__)
116           }.to raise_error
117           # XXX: make this an ArgumentError
118         end
119       end
120       context 'when giving no pass' do
121         for_all_file_versions do
122           subject { StoredFile.open(stored_file_path) }
123           it 'should read clear metadata' do
124             subject.meta['Coquelicot'] == reference['Coquelicot']
125           end
126           # XXX: maybe we want a way to know that we can't uncrypt the rest
127         end
128       end
129       context 'when giving a wrong pass' do
130         for_all_file_versions do
131           it 'should raise an error' do
132             expect {
133               StoredFile.open(stored_file_path, 'whatever')
134             }.to raise_error(BadKey)
135           end
136         end
137       end
138       context 'when giving the right pass' do
139         for_all_file_versions do
140           subject { StoredFile.open(stored_file_path, 'secret') }
141           it 'should read the metadata' do
142             subject.meta['Length'] == reference['Length']
143           end
144         end
145       end
146     end
147
148     describe '.create' do
149       include_context 'create new StoredFile'
150       context 'when the metadata file already exists' do
151         it 'should raise an error' do
152           @stored_file_path = File.expand_path('stored_file', @tmpdir)
153           FileUtils.touch @stored_file_path
154           expect {
155             create_stored_file
156           }.to raise_error(Errno::EEXIST)
157         end
158       end
159       context 'when the content file already exists' do
160         it 'should raise an error' do
161           @stored_file_path = File.expand_path('stored_file', @tmpdir)
162           FileUtils.touch "#{@stored_file_path}.content"
163           expect {
164             create_stored_file
165           }.to raise_error(Errno::EEXIST)
166         end
167       end
168       context 'in metadata file, clear part' do
169         let(:test_salt) { "\0" * StoredFile::SALT_LEN }
170         let(:expire_at) { Time.now + 60 }
171         before(:each) do
172           StoredFile.stub(:gen_salt).and_return(test_salt)
173           create_stored_file('Expire-at' => expire_at)
174         end
175         let(:clear_meta) { YAML.load_file(@stored_file_path) }
176         it 'should write Coquelicot file version' do
177           clear_meta['Coquelicot'].should == '2.0'
178         end
179         it 'should generate a random Salt' do
180           salt = Base64.decode64(clear_meta['Salt'])
181           salt.should == test_salt
182         end
183         it 'should record expiration time' do
184           clear_meta['Expire-at'].should == expire_at
185         end
186       end
187       shared_context 'in encrypted part' do |path_regex|
188         before(:each) do
189           class NullCipher
190             attr_reader :content
191             def initialize; reset; end
192             def reset; @buf, @content = '', nil; end
193             def update(str); @buf << str ; str; end
194             def final; @content = @buf; ''; end
195           end
196           cipher = NullCipher.new
197           StoredFile.stub(:get_cipher).and_return(cipher)
198           @content = StringIO.new
199           open = File.method(:open)
200           File.should_receive(:open).at_least(1).times do |path, *args, &block|
201             if path =~ path_regex
202               ret = block.call(@content)
203               @cipher = cipher.dup
204               ret
205             else
206               open.call(path, *args, &block)
207             end
208           end
209         end
210       end
211       context 'in metadata file, encrypted part' do
212         include_context 'in encrypted part', /stored_file$/
213         it 'should contain metadata as YAML block' do
214           create_stored_file
215           @cipher.content.split(/^--- \n/, 3).length.should == 2
216           YAML.load(@cipher.content).should be_a(Hash)
217         end
218         context 'in encrypted metadata' do
219           before(:each) do
220             create_stored_file
221             @meta = YAML.load(@cipher.content)
222           end
223           it 'should contain Length' do
224             @meta['Length'].should == @src_length
225           end
226           it 'should Created-at' do
227             @meta.should include('Created-at')
228           end
229         end
230       end
231       context 'in encrypted content' do
232         include_context 'in encrypted part', /stored_file\.content$/
233         before(:each) do
234           create_stored_file
235         end
236         it 'should contain the file content' do
237           @cipher.content.should == File.read(@src)
238         end
239         it 'should have the whole file for encrypted content' do
240           @content.string == File.read(@src)
241         end
242       end
243       context 'when the given block raise an error' do
244         it 'should not leave files' do
245           expect {
246             path = File.expand_path('stored_file', @tmpdir)
247             begin
248               StoredFile.create(path, 'secret', {}) do
249                 raise StandardError.new
250               end
251             rescue StandardError
252               # that was expected!
253             end
254           }.to_not change { Dir.entries(@tmpdir) }
255         end
256       end
257     end
258
259     describe '#created_at' do
260       context 'with a new file' do
261         include_context 'create new StoredFile'
262         it 'should return the creation time' do
263           Timecop.freeze(2012, 1, 1) do
264             create_stored_file
265             stored_file = StoredFile.open(@stored_file_path, @pass)
266             stored_file.created_at.should == Time.local(2012, 1, 1)
267           end
268         end
269       end
270       for_all_file_versions do
271         it 'should return the creation time' do
272           stored_file.created_at.should == Time.at(reference['Created-at'])
273         end
274       end
275     end
276
277     describe '#expire_at' do
278       context 'with a new file' do
279         include_context 'create new StoredFile'
280         it 'should return the date of expiration' do
281           create_stored_file('Expire-at' => Time.local(2012, 1, 1))
282           stored_file = StoredFile.open(@stored_file_path, @pass)
283           stored_file.expire_at.should == Time.local(2012, 1, 1)
284         end
285       end
286       for_all_file_versions do
287         specify { stored_file.expire_at.should == Time.at(reference['Expire-at']) }
288       end
289     end
290
291     describe '#expired?' do
292       include_context 'create new StoredFile'
293       context 'when expiration time is in the past' do
294         it 'should return true' do
295           Timecop.freeze do
296             create_stored_file('Expire-at' => Time.now - 60)
297             stored_file = StoredFile.open(@stored_file_path, @pass)
298             stored_file.should be_expired
299           end
300         end
301       end
302       context 'when expiration time is in the future' do
303         it 'should return false' do
304           Timecop.freeze do
305             create_stored_file('Expire-at' => Time.now + 60)
306             stored_file = StoredFile.open(@stored_file_path, @pass)
307             stored_file.should_not be_expired
308           end
309         end
310       end
311     end
312
313     describe '#one_time_only?' do
314       include_context 'create new StoredFile'
315       context 'when file is labelled as "one time only"' do
316         it 'should be true' do
317           create_stored_file('One-time-only' => true)
318           stored_file = StoredFile.open(@stored_file_path, @pass)
319           stored_file.should be_one_time_only
320         end
321       end
322       context 'when file is not labelled as "one time only"' do
323         it 'should be false' do
324           create_stored_file
325           stored_file = StoredFile.open(@stored_file_path, @pass)
326           stored_file.should_not be_one_time_only
327         end
328       end
329     end
330
331     describe '#empty!' do
332       for_all_file_versions do
333         include_context 'create new StoredFile'
334         before(:each) do
335           FileUtils.cp Dir.glob("#{stored_file_path}*"), @tmpdir
336           @stored_file_path = File.expand_path('stored_file', @tmpdir)
337           @stored_file = StoredFile.open(@stored_file_path, @pass)
338         end
339         it 'should overwrite file contents with \0' do
340           Dir.glob("#{@stored_file_path}*").each do |path|
341             File.should_receive(:open) do |*args, &block|
342               length = File.stat(path).size
343               file = StringIO.new(File.read(path))
344               block.call(file)
345               file.string.should == "\0" * length
346             end
347           end
348           @stored_file.empty!
349         end
350         it 'should truncate files' do
351           @stored_file.empty!
352           Dir.glob("#{@stored_file_path}*").each do |path|
353             File.stat(path).size.should == 0
354           end
355         end
356       end
357     end
358
359     describe '#lockfile' do
360       for_all_file_versions do
361         let(:stored_file) { StoredFile.open(stored_file_path, 'secret') }
362         it 'should return a Lockfile' do
363           stored_file.lockfile.should be_a(Lockfile)
364         end
365         it 'should create a Lockfile using the path followed by ".lock"' do
366           Lockfile.should_receive(:new) do |path, options|
367             path.should == "#{stored_file_path}.lock"
368           end
369           stored_file.lockfile
370         end
371       end
372     end
373
374     describe '#each' do
375       context 'when the right pass has been given' do
376         for_all_file_versions do
377           it 'should output the whole content with several yields' do
378             buf = ''
379             stored_file.each do |data|
380               buf << data
381             end
382             buf.should == reference['Content']
383           end
384         end
385       end
386       context 'when no password has been given' do
387         for_all_file_versions do
388           let(:stored_file) { StoredFile.open(stored_file_path) }
389           it 'should raise BadKey' do
390             expect {
391               stored_file.each
392             }.to raise_error(BadKey)
393           end
394         end
395       end
396     end
397
398     describe '#close' do
399       for_all_file_versions do
400         it 'should reset the cipher' do
401           salt = Base64::decode64(YAML.load_file(stored_file_path)['Salt'])
402           cipher = StoredFile.get_cipher('secret', salt, :decrypt)
403           StoredFile.stub(:get_cipher).and_return(cipher)
404
405           stored_file = StoredFile.open(stored_file_path, 'secret')
406           cipher.should_receive(:reset)
407           stored_file.close
408         end
409       end
410       context 'when file is "one-time only"' do
411         include_context 'create new StoredFile'
412         before(:each) do
413           create_stored_file('One-time-only' => true)
414           @stored_file = StoredFile.open(@stored_file_path, @pass)
415           # XXX: that is not a nice assumption (at all)
416           @stored_file.lockfile.lock
417         end
418         context 'when the file has not been fully sent' do
419           it 'should leave the content untouched' do
420             begin
421               @stored_file.each { |data| raise StandardError }
422             rescue
423               # do nothing
424             end
425             @stored_file.close
426             another = StoredFile.open(@stored_file_path, @pass)
427             buf = ''
428             another.each { |data| buf << data }
429             buf.should == File.read(@src)
430           end
431         end
432         context 'when the file has been fully sent' do
433           before(:each) do
434             # read entirely
435             @stored_file.each { |data| nil }
436           end
437           it 'should empty the file' do
438             @stored_file.should_receive(:empty!)
439             @stored_file.close
440           end
441         end
442       end
443     end
444   end
445 end