rom-distillery 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,585 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+
3
+ require 'digest'
4
+ require 'digest/sha1'
5
+ require 'zlib'
6
+ require 'fileutils'
7
+
8
+ require_relative 'error'
9
+ require_relative 'rom/path'
10
+
11
+ module Distillery
12
+
13
+ # ROM representation. It will typically have a name (entry) and hold
14
+ # information about it's content (size and checksums). If physical
15
+ # content is present it is referenced by it's path
16
+ #
17
+ class ROM
18
+ class HeaderLookupError < Error
19
+ end
20
+
21
+ # @!visibility private
22
+ HEADERS = [
23
+ # Nintendo : Family Computer Disk System
24
+ { :name => 'Family Computer Disk System',
25
+ :ext => 'fds',
26
+ :rules => [ [ 0, "FDS" ] ],
27
+ :offset => 16,
28
+ },
29
+ # Nintendo : NES
30
+ { :name => 'NES',
31
+ :ext => 'nes',
32
+ :rules => [ [ 0, "NES" ] ],
33
+ :offset => 16,
34
+ },
35
+ # Atari : Lynx
36
+ { :name => 'Atary Lynx',
37
+ :ext => 'lnx',
38
+ :rules => [ [ 0, "LYNX" ] ],
39
+ :offset => 40,
40
+ },
41
+ # Atari : 7800
42
+ { :name => 'Atari 7800',
43
+ :ext => 'a78',
44
+ :rules => [ [ 1, "ATARI7800" ],
45
+ [ 60, "\x00\x00\x00\x00ACTUAL CART DATA STARTS HERE" ] ],
46
+ :offset => 80,
47
+ },
48
+ ]
49
+
50
+ # @!visibility private
51
+ CHECKSUMS_DEF = {
52
+ :sha256 => [ 256, 'e3b0c44298fc1c149afbf4c8996fb924' \
53
+ '27ae41e4649b934ca495991b7852b855' ],
54
+ :sha1 => [ 160, 'da39a3ee5e6b4b0d3255bfef95601890afd80709' ],
55
+ :md5 => [ 128, 'd41d8cd98f00b204e9800998ecf8427e' ],
56
+ :crc32 => [ 32, '00000000' ],
57
+ }.freeze
58
+
59
+ # List of supported weak checksums sorted by strength order
60
+ # (a subset of {CHECKSUMS})
61
+ CHECKSUMS_WEAK = [ :crc32 ].freeze
62
+
63
+ # List of supported strong checksums sorted by strength order
64
+ # (a subset of {CHECKSUMS})
65
+
66
+ CHECKSUMS_STRONG = [ :sha256, :sha1, :md5 ].freeze
67
+
68
+ # List of all supported checksums sorted by strength order
69
+ CHECKSUMS = (CHECKSUMS_STRONG + CHECKSUMS_WEAK).freeze
70
+
71
+ # List of all DAT supported checksums sorted by strengh order
72
+ CHECKSUMS_DAT = [ :sha1, :md5, :crc32 ].freeze
73
+
74
+ # Checksum used when saving to file-system
75
+ FS_CHECKSUM = :sha1
76
+
77
+ # Get information about ROM file (size, checksum, header, ...)
78
+ #
79
+ # @param io [#read] input object responding to read
80
+ # @param bufsize [Integer] buffer size in kB
81
+ # @param headers [Array,nil,false] header definition list
82
+ #
83
+ # @return [Hash{Symbol=>Object}] ROM information
84
+ #
85
+ def self.info(io, bufsize: 32, headers: nil)
86
+ # Sanity check
87
+ if bufsize <= 0
88
+ raise ArgumentError, "bufsize argument must be > 0"
89
+ end
90
+
91
+ # Apply default
92
+ headers ||= HEADERS
93
+
94
+ # Adjust bufsize (from kB to B)
95
+ bufsize <<= 10
96
+
97
+ # Initialize info
98
+ offset = 0
99
+ size = 0
100
+ sha256 = Digest::SHA256.new
101
+ sha1 = Digest::SHA1.new
102
+ md5 = Digest::MD5.new
103
+ crc32 = 0
104
+
105
+ # Process whole data
106
+ if x = io.read(bufsize)
107
+ if headers != false
108
+ begin
109
+ if offset = self.headered?(x, headers: headers)
110
+ x = x[offset..-1]
111
+ end
112
+ rescue HeaderLookupError
113
+ end
114
+ end
115
+
116
+ loop do
117
+ size += x.length
118
+ sha256 << x
119
+ sha1 << x
120
+ md5 << x
121
+ crc32 = Zlib::crc32(x, crc32)
122
+ break unless x = io.read(bufsize)
123
+ end
124
+ end
125
+
126
+ # Return info
127
+ { :offset => offset,
128
+ :size => size,
129
+ :sha256 => sha256.digest,
130
+ :sha1 => sha1.digest,
131
+ :md5 => md5.digest,
132
+ :crc32 => crc32,
133
+ }.compact
134
+ end
135
+
136
+ # Check if an header is detected
137
+ #
138
+ # @param data [String] data sample for header detection
139
+ # @param ext [String,nil] extension name as hint
140
+ # @param headers [Array] header definition list
141
+ #
142
+ # @raise [HeaderLookupError] sample is too short
143
+ #
144
+ # @return [Integer,nil] ROM offset
145
+ #
146
+ def self.headered?(data, ext: nil, headers: HEADERS)
147
+ # Normalize
148
+ ext = ext[1..-1] if ext && (ext[0] == ?.)
149
+
150
+ size = data.size
151
+ hdr = headers.find {| rules:, ** |
152
+ rules.all? {|offset, string|
153
+ if (offset + string.size) > size
154
+ raise HeaderLookupError
155
+ end
156
+ data[offset, string.size] == string
157
+ }
158
+ }
159
+
160
+ hdr&.[](:offset)
161
+ end
162
+
163
+ # Copy file, possibly using link if requested.
164
+ #
165
+ # @param from [String] file to copy
166
+ # @param to [String] file destination
167
+ # @param length [Integer,nil] data length to be copied
168
+ # @param offset [Integer] data offset
169
+ # @param force [Boolean] remove previous file if necessary
170
+ # @param link [:hard, :sym, nil] use link instead of copy if possible
171
+ #
172
+ # @return [Boolean] status of the operation
173
+ #
174
+ def self.filecopy(from, to, length = nil, offset = 0,
175
+ force: false, link: :hard)
176
+ # Ensure sub-directories are created
177
+ FileUtils.mkpath(File.dirname(to))
178
+
179
+ # If whole file is to be copied try optimisation
180
+ if length.nil? && offset.zero?
181
+ # If we are on the same filesystem, we can use hardlink
182
+ f_stat = File.stat(from)
183
+ f_dev = [ f_stat.dev_major, f_stat.dev_minor ]
184
+ t_stat = File.stat(File.dirname(to))
185
+ t_dev = [ t_stat.dev_major, t_stat.dev_minor ]
186
+ if f_dev == t_dev
187
+ # If file already exists we will need to unlink it before
188
+ # but we will try to create hardlink before to not remove
189
+ # it unnecessarily if hardlinks are not supported
190
+ begin
191
+ File.link(from, to)
192
+ return true
193
+ rescue Errno::EEXIST
194
+ raise if !force
195
+ # File exist and we need to unlink it
196
+ # if unlink or link fails, something is wrong
197
+ begin
198
+ File.unlink(to)
199
+ File.link(from, to)
200
+ return true
201
+ rescue Errno::ENOENT
202
+ end
203
+ rescue Errno::EOPNOTSUPP
204
+ # If link are not supported fallback to copy
205
+ end
206
+ end
207
+ end
208
+
209
+ # Copy file
210
+ op = force ? File::TRUNC : File::EXCL
211
+ File.open(from, File::RDONLY) {|i|
212
+ i.seek(offset)
213
+ File.open(to, File::CREAT|File::WRONLY|op) {|o|
214
+ IO.copy_stream(i, o, length)
215
+ }
216
+ }
217
+ return true
218
+
219
+ rescue Errno::EEXIST
220
+ return false
221
+ end
222
+
223
+
224
+ # Create ROM object from file definition.
225
+ #
226
+ # If `file` is an absolute path or `root` is not specified,
227
+ # ROM will be created with basename/dirname of entry.
228
+ #
229
+ # @param file [String] path or relative path to file
230
+ # @param root [String] anchor for the relative entry path
231
+ # @param headers [Array,nil,false] header definition list
232
+ #
233
+ # @return [ROM] based on `file` content
234
+ #
235
+ def self.from_file(file, root=nil, headers: nil)
236
+ basedir, entry = if root.nil? then File.split(file)
237
+ elsif file.start_with?('/') then File.split(file)
238
+ else [ root, file ]
239
+ end
240
+ file = File.join(basedir, entry)
241
+
242
+ rominfo = File.open(file) {|io| ROM.info(io, headers: headers) }
243
+ self.new(ROM::Path::File.new(entry, basedir), **rominfo)
244
+ end
245
+
246
+
247
+ # Create ROM representation.
248
+ #
249
+ # @param path [ROM::Path] rom path
250
+ # @param size [Integer] size rom size
251
+ # @param offset [Integer,nil] rom start (if headered)
252
+ # @option cksums [String,Integer] :sha1 rom checksum using sha1
253
+ # @option cksums [String,Integer] :md5 rom checksum using md5
254
+ # @option cksums [String,Integer] :crc32 rom checksum using crc32
255
+ #
256
+ def initialize(path, logger: nil, offset: nil, size: nil, **cksums)
257
+ # Sanity check
258
+ if path.nil?
259
+ raise ArgumentError, "ROM path is required"
260
+ end
261
+
262
+ unsupported_cksums = cksums.keys - CHECKSUMS
263
+ if ! unsupported_cksums.empty?
264
+ raise ArgumentError,
265
+ "unsupported checksums <#{unsupported_cksums.join(',')}>"
266
+ end
267
+
268
+ # Ensure checksum for nul-size ROM
269
+ if size == 0
270
+ cksums = Hash[CHECKSUMS_DEF.map {|k, (_, z)| [k, z] } ]
271
+ end
272
+
273
+ # Initialize
274
+ @offset = offset
275
+ @path = path
276
+ @size = size
277
+ @cksum = Hash[CHECKSUMS_DEF.map {|k, (s, _)|
278
+ [k, case val = cksums[k]
279
+ # No checksum
280
+ when '', '-', nil
281
+ # Checksum as hexstring or binary string
282
+ when String
283
+ case val.size
284
+ when s/4 then [val].pack('H*')
285
+ when s/8 then val
286
+ else raise ArgumentError,
287
+ "wrong size #{val.size} for hash string #{k}"
288
+ end
289
+ # Checksum as integer
290
+ when Integer
291
+ raise ArgumentError if (val < 0) || (val > 2**s)
292
+ ["%0#{s/4}x" % val].pack('H*')
293
+ # Oops
294
+ else raise ArgumentError, "unsupported hash value type"
295
+ end
296
+ ]
297
+ }].compact
298
+
299
+ # Warns
300
+ warns = []
301
+ # warns << 'nul size' if @size == 0
302
+ warns << 'no checksum' if @cksum.empty?
303
+ if !warns.empty?
304
+ warn "ROM <#{self.to_s}> has #{warns.join(', ')}"
305
+ end
306
+ end
307
+
308
+
309
+ # Compare ROMs using their checksums.
310
+ #
311
+ # @param o [ROM] other rom
312
+ # @param weak [Boolean] use weak checksum if necessary
313
+ #
314
+ # @return [Boolean] if they are the same or not
315
+ # @return [nil] if it wasn't decidable due to missing checksum
316
+ #
317
+ def same?(o, weak: true)
318
+ return true if self.equal?(o)
319
+ decidable = false
320
+ (weak ? CHECKSUMS : CHECKSUMS_STRONG).each {|type|
321
+ s_cksum = self.cksum(type)
322
+ o_cksum = o.cksum(type)
323
+
324
+ if s_cksum.nil? || o_cksum.nil? then next
325
+ elsif s_cksum != o_cksum then return false
326
+ else decidable = true
327
+ end
328
+ }
329
+ decidable ? true : nil
330
+ end
331
+
332
+ # Check if ROM hold content
333
+ #
334
+ # @return [Boolean]
335
+ #
336
+ def has_content?
337
+ ! @path.storage.nil?
338
+ end
339
+
340
+ # String representation.
341
+ #
342
+ # @param prefered [:name, :entry, :checksum]
343
+ #
344
+ # @return [String]
345
+ #
346
+ def to_s(prefered = :name)
347
+ case prefered
348
+ when :checksum
349
+ if key = CHECKSUMS.find {|k| @cksum.include?(k) }
350
+ then cksum(key, :hex)
351
+ else self.name
352
+ end
353
+ when :name
354
+ self.name
355
+ when :entry
356
+ self.entry
357
+ else
358
+ self.name
359
+ end
360
+ end
361
+
362
+
363
+ # Does this ROM have an header?
364
+ #
365
+ # @return [Boolean]
366
+ #
367
+ def headered?
368
+ !@offset.nil? && (@offset > 0)
369
+ end
370
+
371
+
372
+ # Get ROM header
373
+ #
374
+ # @return [String]
375
+ #
376
+ def header
377
+ return nil if !headered?
378
+ @path.reader {|io| io.read(@offset) }
379
+ end
380
+
381
+
382
+ # Get the ROM specific checksum
383
+ #
384
+ # @param type checksum type must be one defined in CHECKSUMS
385
+ # @param fmt [:bin,:hex] checksum formating
386
+ #
387
+ # @return [String] checksum value (either binary string
388
+ # or as an hexadecimal string)
389
+ #
390
+ # @raise [ArgumentError] if `type` is not one defined in {CHECKSUMS}
391
+ # or `fmt` is not :bin or :hex
392
+ #
393
+ def cksum(type, fmt=:bin)
394
+ raise ArgumentError unless CHECKSUMS.include?(type)
395
+
396
+ if ckobj = @cksum[type]
397
+ case fmt
398
+ when :bin then ckobj
399
+ when :hex then ckobj.unpack1('H*')
400
+ else raise ArgumentError
401
+ end
402
+ end
403
+ end
404
+
405
+
406
+ # Get the ROM checksums
407
+ #
408
+ # @param fmt [:bin,:hex] checksum formating
409
+ #
410
+ # @return [Hash{Symbol=>String}] checksum
411
+ #
412
+ # @raise [ArgumentError] if `type` is not one defined in {CHECKSUMS}
413
+ # or `fmt` is not :bin or :hex
414
+ #
415
+ def cksums(fmt=:bin)
416
+ case fmt
417
+ when :bin then @cksum
418
+ when :hex then @cksum.transform_values {|v| v.unpack1('H*') }
419
+ else raise ArgumentError
420
+ end
421
+ end
422
+
423
+
424
+ # Checksum to be used for naming on filesystem
425
+ #
426
+ # @return [String] checksum hexstring
427
+ #
428
+ def fshash
429
+ cksum(FS_CHECKSUM, :hex)
430
+ end
431
+
432
+
433
+ # Is size information missing?
434
+ # @return [Boolean]
435
+ def missing_size?
436
+ @size.nil?
437
+ end
438
+
439
+
440
+ # Get ROM size in bytes.
441
+ #
442
+ # @return [Integer] ROM size in bytes
443
+ # @return [nil] ROM has no size
444
+ #
445
+ def size
446
+ @size
447
+ end
448
+
449
+
450
+ # Get ROM sha1 as hexadecimal string (if defined)
451
+ #
452
+ # @return [String,nil] hexadecimal checksum value
453
+ #
454
+ def sha1
455
+ cksum(:sha1, :hex)
456
+ end
457
+
458
+
459
+ # Get ROM md5 as hexadecimal string (if defined)
460
+ #
461
+ # @return [String,nil] hexadecimal checksum value
462
+ #
463
+ def md5
464
+ cksum(:md5, :hex)
465
+ end
466
+
467
+
468
+ # Get ROM crc32 as hexadcimal string (if defined)
469
+ #
470
+ # @return [String,nil] hexadecimal checksum value
471
+ #
472
+ def crc32
473
+ cksum(:crc32, :hex)
474
+ end
475
+
476
+
477
+ # Are some checksums missing?
478
+ #
479
+ # @param checksums [Array<Symbol>] list of checksums to consider
480
+ #
481
+ # @return [Boolean]
482
+ #
483
+ def missing_checksums?(checksums = CHECKSUMS_DAT)
484
+ @cksum.keys != checksums
485
+ end
486
+
487
+
488
+ # Get ROM name.
489
+ #
490
+ # @return [String]
491
+ #
492
+ def name
493
+ @path.basename
494
+ end
495
+
496
+
497
+ # Get ROM path.
498
+ #
499
+ # @return [String]
500
+ #
501
+ def path
502
+ @path
503
+ end
504
+
505
+
506
+ # ROM reader
507
+ #
508
+ # @yieldparam [#read] io stream for reading
509
+ #
510
+ # @return block value
511
+ #
512
+ def reader(&block)
513
+ @path.reader(&block)
514
+ end
515
+
516
+
517
+ # Copy ROM content to the filesystem, possibly using link if requested.
518
+ #
519
+ # @param to [String] file destination
520
+ # @param length [Integer,nil] data length to be copied
521
+ # @param part [:all,:header,:rom] which part of the rom file to copy
522
+ # @param link [:hard, :sym, nil] use link instead of copy if possible
523
+ #
524
+ # @return [Boolean] status of the operation
525
+ #
526
+ def copy(to, part: :all, force: false, link: :hard)
527
+ # Sanity check
528
+ unless [ :all, :rom, :header ].include?(part)
529
+ raise ArgumenetError, "unsupported part (#{part})"
530
+ end
531
+
532
+ # Copy
533
+ length, offset = case part
534
+ when :all
535
+ [ nil, 0 ]
536
+ when :rom
537
+ [ nil, @offset || 0 ]
538
+ when :header
539
+ return false if !self.headered?
540
+ [ @offset, 0 ]
541
+ end
542
+
543
+ @path.copy(to, length, offset, force: force, link: link)
544
+ end
545
+
546
+
547
+ # Delete physical content.
548
+ #
549
+ # @return [Boolean]
550
+ #
551
+ def delete!
552
+ if @path.delete!
553
+ @path == ROM::Path::Virtual.new(@path.entry)
554
+ end
555
+ end
556
+
557
+
558
+ # Rename ROM and physical content.
559
+ #
560
+ # @note Renaming could lead to silent removing if same ROM is on its way
561
+ #
562
+ # @param path [String] new ROM path
563
+ # @param force [Boolean] remove previous file if necessary
564
+ #
565
+ # @return [Boolean] status of the operation
566
+ #
567
+ # @yield Rename operation (optional)
568
+ # @yieldparam old [String] old entry name
569
+ # @yieldparam new [String] new entry name
570
+ #
571
+ def rename(path, force: false)
572
+ # Deal with renaming
573
+ ok = @path.rename(path, force: force)
574
+
575
+ if ok
576
+ @entry = entry
577
+ yield(old_entry, entry) if block_given?
578
+ end
579
+
580
+ ok
581
+ end
582
+ end
583
+
584
+ end
585
+