rom-distillery 0.1

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,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
+