mast 1.0.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.
@@ -0,0 +1,158 @@
1
+ # Metaclass extensions for core File class.
2
+ #
3
+ class File #:nodoc:
4
+
5
+ # Is a file a gzip file?
6
+ #
7
+ def self.gzip?(file)
8
+ open(file,'rb') { |f|
9
+ return false unless f.getc == 0x1f
10
+ return false unless f.getc == 0x8b
11
+ }
12
+ true
13
+ end
14
+
15
+ # Reads in a file, removes blank lines and remarks
16
+ # (lines starting with '#') and then returns
17
+ # an array of all the remaining lines.
18
+ #
19
+ # CREDIT: Trans
20
+ def self.read_list(filepath, chomp_string='')
21
+ farr = nil
22
+ farr = read(filepath).split("\n")
23
+ farr.collect! { |line|
24
+ l = line.strip.chomp(chomp_string)
25
+ (l.empty? or l[0,1] == '#') ? nil : l
26
+ }
27
+ farr.compact
28
+ end
29
+
30
+ # Return the path shared.
31
+ def self.sharedpath(file1, file2)
32
+ afile1 = file1.split(/\/\\/)
33
+ afile2 = file2.split(/\/\\/)
34
+ overlap = []
35
+ i = 0; e1, e2 = afile1[i], afile2[i]
36
+ while e1 && e2 && e1 == e2
37
+ overlap << e1
38
+ i += 1; e1, e2 = afile1[i], afile2[i]
39
+ end
40
+ return overlap.empty? ? false : overlap
41
+ end
42
+
43
+ # Is path1 a parent directory of path2.
44
+ def self.parent?(file1, file2)
45
+ return false if File.identical?(file1, file2)
46
+ afile1 = file1.split(/(\/|\\)/)
47
+ afile2 = file2.split(/(\/|\\)/)
48
+ overlap = []
49
+ i = 0; e1, e2 = afile1[i], afile2[i]
50
+ while e1 && e2 && e1 == e2
51
+ overlap << e1
52
+ i += 1; e1, e2 = afile1[i], afile2[i]
53
+ end
54
+ return (overlap == afile1)
55
+ end
56
+
57
+ # Reduce a list of files so there is no overlapping
58
+ # directory entries. This is useful when recursively
59
+ # descending a directory listing, so as to avoid and
60
+ # repeat entries.
61
+ #
62
+ # TODO: Maybe globbing should occur in here too?
63
+ #
64
+ def self.reduce(*list)
65
+ # TODO: list = list.map{ |f| File.cleanpath(f) }
66
+ newlist = list.dup
67
+ list.each do |file1|
68
+ list.each do |file2|
69
+ if parent?(file1, file2)
70
+ newlist.delete(file2)
71
+ end
72
+ end
73
+ end
74
+ newlist
75
+ end
76
+
77
+ end
78
+
79
+ # Metaclass extensions for core Dir class.
80
+ #
81
+ class Dir #:nodoc:
82
+
83
+ # Like +glob+ but can take multiple patterns.
84
+ #
85
+ # Dir.multiglob( '*.rb', '*.py' )
86
+ #
87
+ # Rather then constants for options multiglob accepts a trailing options
88
+ # hash of symbol keys.
89
+ #
90
+ # :noescape File::FNM_NOESCAPE
91
+ # :casefold File::FNM_CASEFOLD
92
+ # :pathname File::FNM_PATHNAME
93
+ # :dotmatch File::FNM_DOTMATCH
94
+ # :strict File::FNM_PATHNAME && File::FNM_DOTMATCH
95
+ #
96
+ # It also has an option for recurse.
97
+ #
98
+ # :recurse Recurively include contents of directories.
99
+ #
100
+ # For example
101
+ #
102
+ # Dir.multiglob( '*', :recurse => true )
103
+ #
104
+ # would have the same result as
105
+ #
106
+ # Dir.multiglob('**/*')
107
+ #
108
+ def self.multiglob(*patterns)
109
+ options = (Hash === patterns.last ? patterns.pop : {})
110
+
111
+ if options.delete(:recurse)
112
+ #patterns += patterns.collect{ |f| File.join(f, '**', '**') }
113
+ multiglob_r(*patterns)
114
+ end
115
+
116
+ bitflags = 0
117
+ bitflags |= File::FNM_NOESCAPE if options[:noescape]
118
+ bitflags |= File::FNM_CASEFOLD if options[:casefold]
119
+ bitflags |= File::FNM_PATHNAME if options[:pathname] or options[:strict]
120
+ bitflags |= File::FNM_DOTMATCH if options[:dotmatch] or options[:strict]
121
+
122
+ patterns = [patterns].flatten.compact
123
+
124
+ if options[:recurse]
125
+ patterns += patterns.collect{ |f| File.join(f, '**', '**') }
126
+ end
127
+
128
+ files = []
129
+ files += patterns.collect{ |pattern| Dir.glob(pattern, bitflags) }.flatten.uniq
130
+
131
+ return files
132
+ end
133
+
134
+ # The same as +multiglob+, but recusively includes directories.
135
+ #
136
+ # Dir.multiglob_r( 'folder' )
137
+ #
138
+ # is equivalent to
139
+ #
140
+ # Dir.multiglob( 'folder', :recurse=>true )
141
+ #
142
+ # The effect of which is
143
+ #
144
+ # Dir.multiglob( 'folder', 'folder/**/**' )
145
+ #
146
+ def self.multiglob_r(*patterns)
147
+ options = (Hash === patterns.last ? patterns.pop : {})
148
+ matches = multiglob(*patterns)
149
+ directories = matches.select{ |m| File.directory?(m) }
150
+ matches += directories.collect{ |d| multiglob_r(File.join(d, '**'), options) }.flatten
151
+ matches.uniq
152
+ #options = (Hash === patterns.last ? patterns.pop : {})
153
+ #options[:recurse] = true
154
+ #patterns << options
155
+ #multiglob(*patterns)
156
+ end
157
+
158
+ end
@@ -0,0 +1,588 @@
1
+ module Mast
2
+ require 'fileutils'
3
+ require 'getoptlong'
4
+ require 'shellwords'
5
+ require 'mast/core_ext'
6
+
7
+ # Manifest stores a list of package files, and optionally checksums.
8
+ #
9
+ # The class can be used to create and compare package manifests and digests.
10
+ #
11
+ # TODO: Integrate file signing and general manifest better (?)
12
+ #
13
+ # TODO: Digester is in sign.rb too. Dry-up?
14
+ #
15
+ # TODO: The #diff method is shelling out; this needs to be internalized.
16
+ #
17
+ # TODO: Consider adding @include options rather then scanning entire directory.
18
+ # But this can't be done unless we can write a routine that can look at @include
19
+ # and reduce it to non-overlapping matches. Eg. [doc, doc/rdoc] should reduce
20
+ # to just [doc]. Otherwise we will get duplicate entries, b/c the #output
21
+ # method is written for speed and low memory footprint. This might mean @include
22
+ # can't use file globs.
23
+ #
24
+ class Manifest
25
+
26
+ # Manifest file overwrite error.
27
+ #
28
+ OverwriteError = Class.new(Exception)
29
+
30
+ # No Manifest File Error.
31
+ #
32
+ NoManifestError = Class.new(LoadError)
33
+
34
+ # By default mast will exclude any pathname matching
35
+ # 'CVS', '_darcs', '.git*' or '.config'.
36
+ DEFAULT_EXCLUDE = %w{CVS _darcs .git* .config} # InstalledFiles
37
+
38
+ # By default, mast will ignore any file with a name matching
39
+ # '.svn' or '*~', ie. ending with a tilde.
40
+ DEFAULT_IGNORE = %w{*~ .svn}
41
+
42
+ #
43
+ DEFAULT_FILE = '{manifest,digest}{,.txt,.list}'
44
+
45
+ # Possible file name (was for Fileable?).
46
+ #def self.filename
47
+ # DEFAULT_FILE
48
+ #end
49
+
50
+ def self.open(file=nil, options={})
51
+ unless file
52
+ file = Dir.glob(filename, File::FNM_CASEFOLD).first
53
+ raise LoadError, "Manifest file is required." unless file
54
+ end
55
+ options[:file] = file
56
+ new(options)
57
+ end
58
+
59
+ # Directory of manifest.
60
+ attr_accessor :directory
61
+
62
+ # File used to store manifest/digest file.
63
+ attr_accessor :file
64
+
65
+ # Encryption type
66
+ attr_accessor :digest
67
+
68
+ # Do not exclude standard exclusions.
69
+ attr_accessor :all
70
+
71
+ # What files to include. Defaults to ['*'].
72
+ # Note that Mast automatically recurses through
73
+ # directory entries, so using '**/*' would simply
74
+ # be a waste of of processing cycles.
75
+ attr_accessor :include
76
+
77
+ # What files to exclude.
78
+ attr_accessor :exclude
79
+
80
+ # Special files to ignore.
81
+ attr_accessor :ignore
82
+
83
+ # Layout of digest -- 'csf' or 'sfv'. Default is 'csf'.
84
+ attr_accessor :format
85
+
86
+ # Files and checksums listed in file.
87
+ #attr_reader :list
88
+
89
+ #
90
+ alias_method :all?, :all
91
+
92
+ # New Manifest object.
93
+ #
94
+ def initialize(options={})
95
+ @include = ['*']
96
+ @exclude = []
97
+ @ignore = []
98
+ @format = 'csf'
99
+ @all = false
100
+ @digest = nil
101
+ @directory = Dir.pwd
102
+
103
+ change_options(options)
104
+
105
+ #if @file
106
+ # read(@file)
107
+ #else
108
+ #if file = Dir.glob(self.class.filename)[0]
109
+ # @file = file
110
+ #else
111
+ # @file = DEFAULT_FILE
112
+ #end
113
+ #end
114
+ end
115
+
116
+ # Set options.
117
+ def change_options(opts)
118
+ opts.each do |k,v|
119
+ k = k.to_s.downcase
120
+ send("#{k}=",v||send(k))
121
+ end
122
+ #@file = options[:file] || @file
123
+ #@digest = options[:digest] || @digest
124
+ #@all = options[:all] || @all
125
+ #@exclude = options[:exclude] || options[:ignore] || @exclude
126
+ #@exclude = [@exclude].flatten.compact
127
+ end
128
+
129
+ def include=(inc)
130
+ @include = [inc].flatten.uniq
131
+ end
132
+
133
+ #
134
+ def file
135
+ @file ||= Dir[File.join(directory, DEFAULT_FILE)].first || 'MANIFEST'
136
+ end
137
+
138
+ #
139
+ def read?
140
+ @read
141
+ end
142
+
143
+ # Create a digest/manifest file. This saves the list of files
144
+ # and optionally their checksum.
145
+ #def create(options=nil)
146
+ # change_options(options) if options
147
+ # #@file ||= DEFAULT_FILE
148
+ # raise OverwriteError if FileTest.file?(file)
149
+ # save #(false)
150
+ #end
151
+
152
+ # Generate manifest.
153
+ def generate(out=$stdout)
154
+ out << topline_string
155
+ output(out)
156
+ end
157
+
158
+ # Update file.
159
+ def update
160
+ raise NoManifestError unless file and FileTest.file?(file)
161
+ parse_topline
162
+ save
163
+ end
164
+
165
+ # Save as file.
166
+ def save
167
+ File.open(file, 'w') do |file|
168
+ file << topline_string
169
+ output(file)
170
+ end
171
+ return file
172
+ end
173
+
174
+ # Diff file against actual files.
175
+ #
176
+ # TODO: Do not shell out for diff.
177
+ #
178
+ def diff
179
+ raise NoManifestError unless file and FileTest.file?(file)
180
+ parse_topline # parse_file unless read?
181
+ manifest = create_temporary_manifest
182
+ begin
183
+ result = `diff -du #{file} #{manifest.file}`
184
+ ensure
185
+ FileUtils.rm(manifest.file)
186
+ end
187
+ # pass = result.empty?
188
+ return result
189
+ end
190
+
191
+ # Files listed in the manifest file, but not found in file system.
192
+ #
193
+ def whatsold
194
+ parse_file unless read?
195
+ filelist - list
196
+ end
197
+
198
+ # Files found in file system, but not listed in the manifest file.
199
+ def whatsnew
200
+ parse_file unless read?
201
+ list - (filelist + [filename])
202
+ end
203
+
204
+ #
205
+ def verify
206
+ parse_file unless read?
207
+ chart == filechart
208
+ end
209
+
210
+ # Clean non-manifest files.
211
+ def clean
212
+ cfiles, cdirs = cleanlist.partition{ |f| !File.directory?(f) }
213
+ FileUtils.rm(cfiles)
214
+ FileUtils.rmdir(cdirs)
215
+ end
216
+
217
+ # List of current files.
218
+ def list
219
+ @list ||= chart.keys.sort
220
+ end
221
+
222
+ # Chart of current files (name => checksum).
223
+ def chart
224
+ @chart ||= parse_directory
225
+ end
226
+
227
+ # List of files as given in MANIFEST file.
228
+ def filelist
229
+ @filelist ||= filechart.keys.sort
230
+ end
231
+
232
+ # Chart of files as given in MANIFEST file (name => checksum).
233
+ def filechart
234
+ @filechart ||= parse_file
235
+ end
236
+
237
+ #
238
+ def cleanlist
239
+ list = []
240
+ Dir.chdir(directory) do
241
+ keep = Dir.glob('*').select{|f| File.directory?(f)} # keep top-level directories?
242
+ keep << filename # keep manifest file
243
+ list = Dir.glob('**/*') - (filelist + keep)
244
+ end
245
+ list
246
+ end
247
+
248
+ #
249
+ def showlist
250
+ parse_topline unless read?
251
+ list
252
+ end
253
+
254
+ # # Clobber non-manifest files.
255
+ # #
256
+ # def clobber
257
+ # clobber_files.each{ |f| rm_r(f) if File.exist?(f) }
258
+ # end
259
+ #
260
+ # #--
261
+ # # TODO Should clobber work off the manifest file itself?
262
+ # #++
263
+ # def clobber_files
264
+ # keep = filelist # + [info.manifest]
265
+ # Dir.glob('**/*') - keep
266
+ # end
267
+
268
+ # File's basename.
269
+ def filename
270
+ File.basename(file)
271
+ end
272
+
273
+ private
274
+
275
+ #
276
+ def output(out=$stdout)
277
+ Dir.chdir(directory) do
278
+ exclusions # seed exclusions
279
+ #rec_output('*', out)
280
+ inclusions.each do |inc|
281
+ rec_output(inc, out)
282
+ end
283
+ end
284
+ end
285
+
286
+ # Generate listing on the fly.
287
+ def rec_output(match, out=$stdout)
288
+ out.flush unless Array === out
289
+ #match = (location == dir ? '*' : File.join(dir,'*'))
290
+ files = Dir.glob(match, File::FNM_DOTMATCH) - exclusions
291
+ # TODO: Is there a more efficient way to reject ignored files?
292
+ #files = files.select{ |f| !ignores.any?{ |i| File.fnmatch(i, File.basename(f)) } }
293
+ files = files.reject{ |f| ignores.any?{ |i| File.fnmatch(i, File.basename(f)) } }
294
+ files = files.sort
295
+ files.each do |file|
296
+ sum = checksum(file,digest)
297
+ sum = sum + ' ' if sum
298
+ out << "#{sum}#{file}"
299
+ out << "\n" unless Array === out
300
+ if File.directory?(file)
301
+ rec_output(File.join(file,'*'), out)
302
+ end
303
+ end
304
+ #return out
305
+ end
306
+
307
+ #
308
+ def parse_directory
309
+ h = {}
310
+ files.each do |f|
311
+ h[f] = checksum(f)
312
+ end
313
+ h
314
+ end
315
+
316
+ # List of files.
317
+ #
318
+ def files #(update=false)
319
+ @files ||= (
320
+ r = []
321
+ output(r)
322
+ r
323
+ )
324
+ #files = []
325
+ #Dir.chdir(directory) do
326
+ # files += Dir.multiglob_r('**/*')
327
+ # files -= Dir.multiglob_r(exclusions)
328
+ #end
329
+ #return files
330
+ end
331
+
332
+ # Compute exclusions.
333
+ def inclusions
334
+ @_inclusions ||= (
335
+ e = [include].flatten
336
+ #e += DEFAULT_EXCLUDE unless all?
337
+ #e += [filename, filename.chomp('~')] if file
338
+ e = e.map{ |x| Dir.glob(x) }.flatten.uniq
339
+ e = File.reduce(*e)
340
+ e
341
+ )
342
+ end
343
+
344
+ # Compute exclusions.
345
+ def exclusions
346
+ @_exclusions ||= (
347
+ e = [exclude].flatten
348
+ e += DEFAULT_EXCLUDE unless all?
349
+ e += [filename, filename.chomp('~')] if file
350
+ e.map{ |x| Dir.glob(x) }.flatten.uniq
351
+ )
352
+ end
353
+
354
+ # Compute ignores.
355
+ def ignores
356
+ @_ignores ||= (
357
+ i = [ignore].flatten
358
+ i += [ '.', '..' ]
359
+ i += DEFAULT_IGNORE unless all?
360
+ i
361
+ )
362
+ end
363
+
364
+ public
365
+
366
+ # List of files in file system, but omit folders.
367
+ def list_without_folders
368
+ list.select{ |f| !File.directory?(f) }
369
+ end
370
+
371
+ # Produce textual listing less the manifest file.
372
+ #
373
+ def listing
374
+ str = ''
375
+ output(str)
376
+ str
377
+ end
378
+
379
+ #
380
+ def to_s
381
+ topline_string + listing
382
+ end
383
+
384
+ private
385
+
386
+ # Create temporary manifest (for comparison).
387
+
388
+ def create_temporary_manifest
389
+ temp_manifest = Manifest.new(
390
+ :file => file+"~",
391
+ :digest => digest,
392
+ :include => include,
393
+ :exclude => exclude,
394
+ :ignore => ignore,
395
+ :all => all
396
+ )
397
+ temp_manifest.save
398
+ #File.open(tempfile, 'w+') do |f|
399
+ # f << to_s(true)
400
+ #end
401
+ return temp_manifest
402
+ end
403
+
404
+ # Produce hexdigest/cheksum for a file.
405
+ # Default digest type is sha1.
406
+
407
+ def checksum(file, digest=nil)
408
+ return nil unless digest
409
+ if FileTest.directory?(file)
410
+ @null_string ||= digester(digest).hexdigest("")
411
+ else
412
+ digester(digest).hexdigest(File.read(file))
413
+ end
414
+ end
415
+
416
+ # Return a digest class for given +type+.
417
+ # Supported digests are:
418
+ #
419
+ # * md5
420
+ # * sha1
421
+ # * sha128 (same as sha1)
422
+ # * sha256
423
+ # * sha512
424
+ #
425
+ # Default digest type is sha256.
426
+
427
+ def digester(type=nil)
428
+ require 'openssl'
429
+ case type.to_s.downcase
430
+ when 'md5'
431
+ require 'digest/md5'
432
+ ::Digest::MD5
433
+ when 'sha128', 'sha1'
434
+ require 'digest/sha1' #need?
435
+ OpenSSL::Digest::SHA1
436
+ when 'sha256'
437
+ require 'digest/sha1' #need?
438
+ OpenSSL::Digest::SHA256
439
+ when 'sha512'
440
+ require 'digest/sha1' #need?
441
+ OpenSSL::Digest::SHA512
442
+ else
443
+ raise "unsupported digest #{type}"
444
+ end
445
+ end
446
+
447
+ # Read manifest file.
448
+
449
+ def parse_file
450
+ raise ManifestMissing unless file
451
+
452
+ parse_topline
453
+
454
+ #@file = file
455
+ #@location = File.dirname(File.expand_path(file))
456
+
457
+ chart = {}
458
+ flist = File.read_list(file)
459
+ flist.each do |line|
460
+ left, right = line.split(/\s+/)
461
+ if right
462
+ checksum = left
463
+ filename = right
464
+ chart[filename] = checksum
465
+ else
466
+ filename = left
467
+ chart[filename] = nil
468
+ end
469
+ end
470
+
471
+ @read = true
472
+ @filechart = chart
473
+ end
474
+
475
+ # Get topline of Manifest file, parse and cache.
476
+ #def topline
477
+ # @topline ||= topline_parse
478
+ #end
479
+
480
+ # Parse topline.
481
+ #
482
+ def parse_topline
483
+ if line = read_topline
484
+ argv = Shellwords.shellwords(line)
485
+ ARGV.replace(argv)
486
+ opts = GetoptLong.new(
487
+ [ '-g', '--digest' , GetoptLong::REQUIRED_ARGUMENT ],
488
+ [ '-x', '--exclude', GetoptLong::REQUIRED_ARGUMENT ],
489
+ [ '-i', '--ignore' , GetoptLong::REQUIRED_ARGUMENT ],
490
+ [ '-a', '--all' , GetoptLong::NO_ARGUMENT ]
491
+ )
492
+ a, d, x, i = false, nil, [], []
493
+ opts.each do |opt, arg|
494
+ case opt
495
+ when '-g': d = arg.downcase
496
+ when '-a': a = true
497
+ when '-x': x << arg
498
+ when '-i': i << arg
499
+ end
500
+ end
501
+
502
+ @all = a
503
+ @digest = d
504
+ @exclude = x
505
+ @ignore = i
506
+ @include = ARGV.empty? ? nil : ARGV.dup
507
+ end
508
+ end
509
+
510
+ # Read topline of MANIFEST file.
511
+ #
512
+ def read_topline
513
+ r = nil
514
+ #if file = locate(filename)
515
+ File.open(file) do |f|
516
+ s = f.readline
517
+ if s =~ /^#!mast\s*(.*?)\n/
518
+ r = $1
519
+ end
520
+ end
521
+ return r
522
+ #end
523
+ end
524
+
525
+ # Create topline of MANIFEST file.
526
+ #
527
+ def topline_string(update=false)
528
+ #if update
529
+ # a = all #|| topline.all
530
+ # d = digest #|| topline.digest
531
+ # x = exclude #+ topline.exclude
532
+ #else
533
+ # a, d, x = all, digest, exclude
534
+ #end
535
+ top = []
536
+ top << "-a" if all?
537
+ top << "-g #{digest.to_s.downcase}" if digest
538
+ exclude.each do |e|
539
+ top << "-x #{e}"
540
+ end
541
+ ignore.each do |e|
542
+ top << "-i #{e}"
543
+ end
544
+ include.each do |e|
545
+ top << e
546
+ end
547
+ return "#!mast #{top.join(' ')}\n" # FIXME: use proper executable
548
+ end
549
+
550
+ end
551
+
552
+ end
553
+
554
+
555
+ =begin
556
+ #
557
+ def manifest_file
558
+ apply_naming_policy(@file || DEFAULT_FILE, 'txt')
559
+ end
560
+
561
+ private
562
+
563
+ # Apply naming policy.
564
+ #
565
+ def apply_naming_policy(name, ext)
566
+ return name unless policy
567
+ policies = naming_policy.split(' ')
568
+ policies.each do |polic|
569
+ case polic
570
+ when 'downcase'
571
+ name = name.downcase
572
+ when 'upcase'
573
+ name = name.upcase
574
+ when 'capitalize'
575
+ name = name.capitalize
576
+ when 'extension'
577
+ name = name + ".#{ext}"
578
+ when 'plain'
579
+ name = name.chomp(File.extname(name))
580
+ else
581
+ name
582
+ end
583
+ end
584
+ return name
585
+ end
586
+ =end
587
+
588
+ #end # module Ratchets