reshuffle code around
[coquelicot.git] / lib / coquelicot.rb
1 require 'base64'
2 require 'lockfile'
3 require 'openssl'
4 require 'yaml'
5
6 module Coquelicot
7   class BadKey < StandardError; end
8
9   class StoredFile
10     BUFFER_LEN = 4096
11
12     attr_reader :path, :meta, :expire_at
13
14     def self.open(path, pass = nil)
15       StoredFile.new(path, pass)
16     end
17
18     def each
19       # output content
20       yield @initial_content
21       @initial_content = nil
22       until (buf = @file.read(BUFFER_LEN)).nil?
23         yield @cipher.update(buf)
24       end
25       yield @cipher.final
26       @cipher.reset
27       @cipher = nil
28     end
29
30     def created_at
31       Time.at(@meta['Created-at'])
32     end
33
34     def expired?
35       @expire_at < Time.now
36     end
37
38     def one_time_only?
39       @meta['One-time-only'] && @meta['One-time-only'] == 'true'
40     end
41
42     def exclusively(&block)
43       old_path = @path
44       begin
45         new_path = "#{old_path}.#{Coquelicot.gen_random_base32(16)}"
46       end while File.exists? new_path
47       File.rename(old_path, new_path)
48       @path = new_path
49       File.open(old_path, 'w').close
50       begin
51         yield
52       ensure
53         File.rename(new_path, old_path)
54       end
55     end
56
57     def self.create(src, pass, meta)
58       salt = gen_salt
59       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
60                      "Salt" => Base64.encode64(salt).strip,
61                      "Expire-at" => meta.delete('Expire-at'),
62                    }
63       yield YAML.dump(clear_meta) + YAML_START
64
65       cipher = get_cipher(pass, salt, :encrypt)
66       yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
67                           YAML_START)
68       src.rewind
69       while not (buf = src.read(BUFFER_LEN)).nil?
70         yield cipher.update(buf)
71       end
72       yield cipher.final
73     end
74
75     def empty!
76       # zero the content before truncating
77       File.open(@path, 'r+') do |f|
78         f.seek 0, IO::SEEK_END
79         length = f.tell
80         f.rewind
81         while length > 0 do
82           write_len = [StoredFile::BUFFER_LEN, length].min
83           length -= f.write("\0" * write_len)
84         end
85         f.fsync
86       end
87       File.truncate(@path, 0)
88     end
89
90   private
91
92     YAML_START = "--- \n"
93     CIPHER = 'AES-256-CBC'
94     SALT_LEN = 8
95     COQUELICOT_VERSION = "1.0"
96
97     def self.get_cipher(pass, salt, method)
98       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
99       cipher = OpenSSL::Cipher.new CIPHER
100       cipher.method(method).call
101       cipher.key = hmac[0..31]
102       cipher.iv = hmac[32..-1]
103       cipher
104     end
105
106     def self.gen_salt
107       OpenSSL::Random::random_bytes(SALT_LEN)
108     end
109
110     def initialize(path, pass)
111       @path = path
112       @file = File.open(@path)
113       if @file.lstat.size == 0 then
114         @expire_at = Time.now - 1
115         return
116       end
117
118       if YAML_START != (buf = @file.read(YAML_START.length)) then
119         raise "unknown file, read #{buf.inspect}"
120       end
121       parse_clear_meta
122       return if pass.nil?
123       init_decrypt_cipher pass
124       parse_meta
125     end
126
127     def parse_clear_meta
128       meta = ''
129       until YAML_START == (line = @file.readline) do
130         meta += line
131       end
132       @meta = YAML.load(meta)
133       if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
134         raise "unknown file"
135       end
136       @expire_at = Time.at(@meta['Expire-at'])
137     end
138
139     def init_decrypt_cipher(pass)
140       salt = Base64.decode64(@meta["Salt"])
141       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
142     end
143
144     def parse_meta
145       yaml = ''
146       buf = @file.read(BUFFER_LEN)
147       content = @cipher.update(buf)
148       raise BadKey unless content.start_with? YAML_START
149       yaml << YAML_START
150       block = content.split(YAML_START, 3)
151       yaml << block[1]
152       if block.length == 3 then
153         @initial_content = block[2]
154         @meta.merge! YAML.load(yaml)
155         return
156       end
157
158       until (buf = @file.read(BUFFER_LEN)).nil? do
159         block = @cipher.update(buf).split(YAML_START, 3)
160         yaml << block[0]
161         break if block.length == 2
162       end
163       @initial_content = block[1]
164       @meta.merge! YAML.load(yaml)
165     end
166
167     def close
168       @cipher.reset unless @cipher.nil?
169       @file.close
170     end
171   end
172
173   class Depot
174     LOCKFILE_OPTIONS = { :timeout => 60,
175                          :max_age => 8,
176                          :refresh => 2,
177                          :debug   => false }
178
179     attr_reader :path
180
181     def initialize(path)
182       @path = path
183     end
184
185     def add_file(src, pass, options)
186       dst = nil
187       lockfile.lock do
188         dst = gen_random_file_name
189         File.open(full_path(dst), 'w').close
190       end
191       begin
192         File.open(full_path(dst), 'w') do |dest|
193           StoredFile.create(src, pass, options) { |data| dest.write data }
194         end
195       rescue
196         File.unlink full_path(dst)
197         raise
198       end
199       link = gen_random_file_name
200       add_link(link, dst)
201       link
202     end
203
204     def get_file(link, pass=nil)
205       name = read_link(link)
206       return nil if name.nil?
207       StoredFile::open(full_path(name), pass)
208     end
209
210     def file_exists?(link)
211       name = read_link(link)
212       return !name.nil?
213     end
214
215     def gc!
216       files.each do |name|
217         path = full_path(name)
218         if File.lstat(path).size > 0
219           file = StoredFile::open path
220           file.empty! if file.expired?
221         elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
222           remove_from_links { |l| l.strip.end_with? " #{name}" }
223           File.unlink path
224         end
225       end
226     end
227
228   private
229
230     def lockfile
231       Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
232     end
233
234     def links_path
235       "#{@path}/.links"
236     end
237
238     def add_link(src, dst)
239       lockfile.lock do
240         File.open(links_path, 'a') do |f|
241           f.write("#{src} #{dst}\n")
242         end
243       end
244     end
245
246     def remove_from_links(&block)
247       lockfile.lock do
248         links = []
249         File.open(links_path, 'r+') do |f|
250           f.readlines.each do |l|
251             links << l unless yield l
252           end
253           f.rewind
254           f.truncate(0)
255           f.write links.join
256         end
257       end
258     end
259
260     def remove_link(src)
261       remove_from_links { |l| l.start_with? "#{src} " }
262     end
263
264     def read_link(src)
265       dst = nil
266       lockfile.lock do
267         File.open(links_path) do |f|
268           begin
269             line = f.readline rescue break
270             if line.start_with? "#{src} " then
271               dst = line.split[1]
272               break
273             end
274           end until line.empty?
275         end
276       end
277       dst
278     end
279
280     def files
281       lockfile.lock do
282         File.open(links_path) do |f|
283           f.readlines.collect { |l| l.split[1] }
284         end
285       end
286     end
287
288     def gen_random_file_name
289       begin
290         name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
291       end while File.exists?(full_path(name))
292       name
293     end
294
295     def full_path(name)
296       raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
297       "#{@path}/#{name}"
298     end
299   end
300
301   DEFAULT_SETTINGS = { :default_expire => 60,
302                        :gone_period => 10080,
303                        :filename_length => 20,
304                        :random_pass_length => 16,
305                     }
306
307   # Like RFC 4648 (Base32)
308   FILENAME_CHARS = %w(a b c d e f g h i j k l m n o p q r s t u v w x y z 2 3 4 5 6 7)
309
310   class << self
311
312     def setup(settings)
313       @settings = DEFAULT_SETTINGS.merge(settings)
314       @settings.each_key do |k|
315         @settings.class.send(:define_method, k) { self[k] }
316       end
317       @depot = nil
318       @settings
319     end
320
321     def settings
322       @settings ||= setup({})
323     end
324
325     def depot
326       @depot ||= Depot.new(settings.depot_path)
327     end
328
329     def gen_random_base32(length)
330       name = ''
331       OpenSSL::Random::random_bytes(length).each_byte do |i|
332         name << FILENAME_CHARS[i % FILENAME_CHARS.length]
333       end
334       name
335     end
336     def gen_random_pass
337       gen_random_base32(settings.random_pass_length)
338     end
339     def remap_base32_extra_characters(str)
340       map = {}
341       FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
342       map.merge!({ '1' => 'l', '0' => 'o' })
343       result = ''
344       str.each_char { |c| result << map[c] }
345       result
346     end
347   end
348 end