sgfa 0.1.0

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.
data/lib/sgfa/error.rb ADDED
@@ -0,0 +1,95 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Errors
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ # This ; prevents YARD from using the header as docs for Sgfa module
13
+ ;
14
+
15
+ module Sgfa
16
+
17
+
18
+ ##########################################################################
19
+ # Errors generated by the Sgfa system
20
+ module Error
21
+
22
+
23
+ ##########################################
24
+ # Most limit checks
25
+ #
26
+ # @param str [String] String to check
27
+ # @param min [Integer] Minimum length
28
+ # @param max [Integer] max Maximum length
29
+ # @param inv [Regexp, String] Invalid characters
30
+ # @param desc [String] desc Description of string
31
+ # @raise [Error::Limits] if limits are violated
32
+ def self.limits(str, min, max, inv, desc)
33
+
34
+ # string
35
+ if !str || !str.is_a?(String)
36
+ raise Error::Limits, desc + ' is not a string.'
37
+ end
38
+
39
+ # size
40
+ size = str.size
41
+ if str.size > max
42
+ raise Error::Limits, '%s length %d is too large, limit %d' %
43
+ [desc, size, max]
44
+ end
45
+ if str.size < min
46
+ raise Error::Limits, '%s length %d is too small, limit %d' %
47
+ [desc, size, min]
48
+ end
49
+
50
+ # invalid characters
51
+ idx = str.index(inv)
52
+ if idx
53
+ raise Error::Limits,
54
+ '%s contains invalid character \'%s\'' % [desc, str[idx]]
55
+ end
56
+ end # def self.limits()
57
+
58
+
59
+ ###########################################################
60
+ # Sanity check failed
61
+ class Sanity < StandardError; end
62
+
63
+
64
+ ###########################################################
65
+ # Outside of limits
66
+ class Limits < StandardError; end
67
+
68
+
69
+ ###########################################################
70
+ # Corrupt file
71
+ class Corrupt < StandardError; end
72
+
73
+
74
+ ###########################################################
75
+ # Outside of limits
76
+ class Limits < StandardError; end
77
+
78
+
79
+ ###########################################################
80
+ # Conflict of some type
81
+ class Conflict < StandardError; end
82
+
83
+
84
+ ###########################################################
85
+ # Does not exist
86
+ class NonExistent < StandardError; end
87
+
88
+
89
+ ###########################################################
90
+ # Permission check failed
91
+ class Permission < StandardError; end
92
+
93
+ end # module Error
94
+
95
+ end # module Sgfa
@@ -0,0 +1,445 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # History item
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require 'digest/sha2'
13
+
14
+ require_relative 'error'
15
+
16
+ module Sgfa
17
+
18
+
19
+ #####################################################################
20
+ # The history item provides a cryptographic chain of changes made to a
21
+ # {Jacket}.
22
+ #
23
+ # The History has attributes:
24
+ # * hash - SHA256 hash of the canonical encoding
25
+ # * canonical - the canonical encoded string
26
+ # * jacket - The {Jacket} hash ID the history belongs to
27
+ # * previous - Previous history number
28
+ # * history - History number
29
+ # * entry_max - Number of entries present in the Jacket
30
+ # * time - Date and time of the change
31
+ # * user - User name who made the change
32
+ # * entries - List of entries changed \[entry_num, revision_num, hash\]
33
+ # * attachments - List of attachments made \[entry_num, attach_num, hash\]
34
+ #
35
+ class History
36
+
37
+ #########################################################
38
+ # @!group Limits checks
39
+
40
+ # Max chars in user name
41
+ LimUserMax = 64
42
+
43
+ # Invalid character in user name
44
+ LimUserInv = /[[:cntrl:]]/
45
+
46
+ #####################################
47
+ # Limit check, user name
48
+ def self.limits_user(str)
49
+ Error.limits(str, 1, LimUserMax, LimUserInv, 'User name')
50
+ end # def self.limits_user()
51
+
52
+
53
+ #########################################################
54
+ # @!group Read attributes
55
+
56
+
57
+ #####################################
58
+ # Get history item hash
59
+ #
60
+ # @return [String] The hash of the history
61
+ # @raise (see #canonical)
62
+ def hash
63
+ if !@hash
64
+ @hash = Digest::SHA256.new.update(canonical).hexdigest
65
+ end
66
+ return @hash.dup
67
+ end # def hash
68
+
69
+
70
+ #####################################
71
+ # Generate canonical encoded string
72
+ #
73
+ # @return [String] Canonical output
74
+ # @raise [Error::Sanity] if the history is not complete
75
+ def canonical
76
+ if !@canon
77
+ raise Error::Sanity, 'History not complete' if !@history
78
+
79
+ txt = "jckt %s\n" % @jacket
80
+ txt << "hist %d\n" % @history
81
+ txt << "emax %d\n" % @entry_max
82
+ txt << "time %s\n" % time_str
83
+ txt << "prev %s\n" % @previous
84
+ txt << "user %s\n" % @user
85
+ @entries.each{|ary| txt << "entr %d %d %s\n" % ary }
86
+ @attach.each{|ary| txt << "atch %d %d %s\n" % ary }
87
+ @canon = txt
88
+ end
89
+ return @canon.dup
90
+ end # def canonical
91
+
92
+
93
+ #####################################
94
+ # Get jacket
95
+ #
96
+ # @return [String, Boolean] The jacket hash ID or false if not set
97
+ def jacket
98
+ if @jacket
99
+ return @jacket.dup
100
+ else
101
+ return false
102
+ end
103
+ end # def jacket
104
+
105
+
106
+ #####################################
107
+ # Get previous history hash
108
+ #
109
+ # @return [String, Boolean] The hash of the previous history item,
110
+ # or false if not set
111
+ def previous
112
+ if @previous
113
+ return @previous.dup
114
+ else
115
+ return false
116
+ end
117
+ end # def previous
118
+
119
+
120
+ #####################################
121
+ # Get history number
122
+ #
123
+ # @return [Integer, Boolean] History number, or false if not set
124
+ def history
125
+ return @history
126
+ end # def history
127
+
128
+
129
+ #####################################
130
+ # Get maximum entry
131
+ #
132
+ # @return [Integer, Boolean] Maximum entry in a Jacket, or false if not set
133
+ def entry_max
134
+ return @entry_max
135
+ end # def entry_max
136
+
137
+
138
+ # Regex to parse time string
139
+ TimeStrReg = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
140
+ private_constant :TimeStrReg
141
+
142
+
143
+ #####################################
144
+ # Get time
145
+ #
146
+ # @return [Time, Boolean] The time, or false if not set
147
+ def time
148
+ if !@time
149
+ return false if !@time_str
150
+ ma = TimeStrReg.match(@time_str)
151
+ ary = ma[1,6].map{|str| str.to_i}
152
+ @time = Time.utc(*ary)
153
+ end
154
+ return @time.dup
155
+ end # def time
156
+
157
+
158
+ #####################################
159
+ # Get time string
160
+ #
161
+ # @return [String, Boolean] Encoded time string of the history, or false
162
+ # if time not set
163
+ def time_str
164
+ if !@time_str
165
+ return false if !@time
166
+ @time_str = @time.strftime('%F %T')
167
+ end
168
+ return @time_str.dup
169
+ end # def time_str
170
+
171
+
172
+ #####################################
173
+ # Get user
174
+ #
175
+ # @return [String, Boolean] The user string, or false if not set
176
+ def user
177
+ if @user
178
+ return @user.dup
179
+ else
180
+ return false
181
+ end
182
+ end # def user
183
+
184
+
185
+ #####################################
186
+ # Get entries
187
+ #
188
+ # @return [Array] of Entry information \[entry_num, revision_num, hash\]
189
+ def entries
190
+ return @entries.map{|enum, rnum, hash| [enum, rnum, hash.dup] }
191
+ end # def entries
192
+
193
+
194
+ #####################################
195
+ # Get attachments
196
+ #
197
+ # @return [Array] of Attachment information \[entry_num, attach_num, hash\]
198
+ def attachments
199
+ return @attach.map{|enum, anum, hash| [enum, anum, hash.dup] }
200
+ end # def attachments
201
+
202
+
203
+
204
+ #########################################################
205
+ # @!group General interface
206
+
207
+ #####################################
208
+ # Create a new History item
209
+ #
210
+ # @param (see #jacket=)
211
+ # @raise (see #jacket=)
212
+ def initialize(jck=nil)
213
+ reset
214
+ if jck
215
+ self.jacket = jck
216
+ end
217
+ end # def initialize()
218
+
219
+
220
+ #####################################
221
+ # Reset to blank History item
222
+ #
223
+ # @return [History] self
224
+ def reset
225
+ @hash = nil
226
+ @canon = nil
227
+ @jacket = nil
228
+ @history = nil
229
+ @entry_max = 0
230
+ @time = nil
231
+ @time_str = nil
232
+ @previous = nil
233
+ @user = nil
234
+ @entries = []
235
+ @attach = []
236
+
237
+ @change_entry = nil
238
+ @change_tag = nil
239
+ @change_attach = nil
240
+ end # def reset
241
+
242
+
243
+ #####################################
244
+ # Set History using canonical encoding
245
+ #
246
+ # @param str [String] Canonical encoded History
247
+ # @raise [Error::Corrupt] if encoding does not follow canonical rules
248
+ def canonical=(str)
249
+ @hash = nil
250
+ @canon = str.dup
251
+ lines = str.lines
252
+
253
+ ma = /^jckt ([0-9a-f]{64})$/.match lines.shift
254
+ raise(Error::Corrupt, 'Canonical history jacket error') if !ma
255
+ @jacket = ma[1]
256
+
257
+ ma = /^hist (\d+)$/.match lines.shift
258
+ raise(Error::Corrupt, 'Canonical history history error') if !ma
259
+ @history = ma[1].to_i
260
+
261
+ ma = /^emax (\d+)$/.match lines.shift
262
+ raise(Error::Corrupt, 'Canonical history entry_max error') if !ma
263
+ @entry_max = ma[1].to_i
264
+
265
+ ma = /^time (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})$/.match lines.shift
266
+ raise(Error::Corrupt, 'Canonical history time error') if !ma
267
+ @time_str = ma[1]
268
+
269
+ ma = /^prev ([0-9a-f]{64})$/.match lines.shift
270
+ raise(Error::Corrupt, 'Canonical history previous error') if !ma
271
+ @previous = ma[1]
272
+
273
+ ma = /^user (.+)$/.match lines.shift
274
+ raise(Error::Corrupt, 'Canonical history user error') if !ma
275
+ @user = ma[1]
276
+
277
+ li = lines.shift
278
+ @entries = []
279
+ while(li && ma = /^entr (\d+) (\d+) (.+)$/.match(li) )
280
+ @entries.push [ma[1].to_i, ma[2].to_i, ma[3]]
281
+ li = lines.shift
282
+ end
283
+
284
+ @attach = []
285
+ while(li && ma = /^atch (\d+) (\d+) ([0-9a-f]{64})$/.match(li) )
286
+ @attach.push [ma[1].to_i, ma[2].to_i, ma[3]]
287
+ li = lines.shift
288
+ end
289
+
290
+ if li
291
+ raise Error::Corrupt, 'Canonical history error'
292
+ end
293
+
294
+ rescue
295
+ reset
296
+ raise
297
+ end # def canonical=()
298
+
299
+
300
+ #####################################
301
+ # Set jacket
302
+ #
303
+ # @param [String] jck The jacket hash ID
304
+ # @raise [Error::Limits] if jck is not a valid hash
305
+ # @raise [Error::Sanity] if changing an already set jacket hash ID
306
+ def jacket=(jck)
307
+ ma = /^([0-9a-f]{64})$/.match jck
308
+ raise(Error::Limits, 'Jacket hash not valid') if !ma
309
+ if @jacket
310
+ raise(Error::Sanity, 'Jacket already set') if @jacket != jck
311
+ else
312
+ @jacket = jck.dup
313
+ end
314
+ end # def jacket=()
315
+
316
+
317
+ # Tag added to every entry
318
+ TagAll = '_all'
319
+ private_constant :TagAll
320
+
321
+
322
+ #####################################
323
+ # Generate next history
324
+ #
325
+ # @param [String] user User making the change
326
+ # @param [Array] ents Entries to update
327
+ # @param [Time] tme Time to use for the history
328
+ # @return [Array] the next history item, and the changes hash
329
+ # @raise (see #hash)
330
+ # @raise (see #process)
331
+ def next(user, ents, tme=nil)
332
+ raise Error::Sanity, 'History not complete' if !@history
333
+ nxt = History.new(@jacket)
334
+ cng = nxt.process(@history + 1, hash, @entry_max, user, ents, tme)
335
+ return [nxt, cng]
336
+ end # def next
337
+
338
+
339
+ #####################################
340
+ # Process entries
341
+ #
342
+ # @note Entries without a jacket set are automatically set to the correct
343
+ # value
344
+ # @note If time is not supplied, current time is used
345
+ #
346
+ # The changes returned consist of:
347
+ # * :entry - Array of entries provided in ents
348
+ # * :tag - Hash of tag => Hash of Entry => time_str or nil
349
+ # * :attach - Attached files \[entry_num, attach_num, file\]
350
+ #
351
+ # @param [Integer] hnum History number
352
+ # @param [String] prev Hash of previous item
353
+ # @param [Integer] emax Maximum previous entry number
354
+ # @param [String] user User making the change
355
+ # @param [Array] ents Entries to update
356
+ # @param [Time] tme Time to use for the history
357
+ # @return [Hash] Record of changes made
358
+ # @raise [Error::Sanity] if entry does not belong to the same jacket
359
+ # @raise (see Entry#update)
360
+ def process(hnum, prev, emax, user, ents, tme=nil)
361
+
362
+ cng = {}
363
+ add = []
364
+
365
+ # initial values
366
+ @hash = nil
367
+ @canon = nil
368
+ @previous = prev
369
+ @history = hnum
370
+ @entry_max = emax
371
+ if tme
372
+ @time = tme.utc
373
+ else
374
+ @time = Time.now.utc
375
+ end
376
+ @user = user.dup
377
+ @entries = []
378
+ @attach = []
379
+
380
+ # process the entries
381
+ ents.each do |entry|
382
+ # set/check jacket
383
+ if !entry.jacket
384
+ entry.jacket = @jacket
385
+ elsif entry.jacket != @jacket
386
+ raise Error::Sanity, 'Entry belongs to different jacket'
387
+ end
388
+
389
+ # set entry for new entries
390
+ if !entry.entry
391
+ @entry_max += 1
392
+ entry.entry = @entry_max
393
+ end
394
+
395
+ # update the entry
396
+ ecng = entry.update(@history)
397
+ enum = entry.entry
398
+ ts = entry.time_str
399
+
400
+ # time changed, all tags update
401
+ if ecng[:time]
402
+ cng[TagAll] = {} if !cng[TagAll]
403
+ cng[TagAll][enum] = ts
404
+ entry.tags.each do |tag|
405
+ cng[tag] = {} if !cng[tag]
406
+ cng[tag][enum] = ts
407
+ end
408
+
409
+ # just new tags
410
+ else
411
+ ecng[:tags_add].each do |tag|
412
+ cng[tag] = {} if !cng[tag]
413
+ cng[tag][enum] = ts
414
+ end
415
+ end
416
+
417
+ # deleted tags
418
+ ecng[:tags_del].each do |tag|
419
+ cng[tag] = {} if !cng[tag]
420
+ cng[tag][enum] = nil
421
+ end
422
+
423
+ # record entry and attachments
424
+ @entries.push [enum, entry.revision, entry.hash]
425
+ ecng[:files].each do |anum, ary|
426
+ file, hash = ary
427
+ @attach.push [enum, anum, hash]
428
+ add.push [enum, anum, file]
429
+ end
430
+
431
+ end
432
+
433
+ ret = {
434
+ :entry => ents,
435
+ :tag => cng,
436
+ :attach => add,
437
+ }
438
+
439
+ return ret
440
+ end # def process()
441
+
442
+
443
+ end # class History
444
+
445
+ end # module Sgfa