konto_check_ruby 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,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is part of konto_check_ruby, a Ruby port of the C library
4
+ # konto_check, Copyright (C) 2002-2023 Michael Plugge <konto_check@yahoo.com>.
5
+ # Ruby port Copyright (C) 2026 tickettoaster GmbH <https://tickettoaster.de>.
6
+ #
7
+ # konto_check_ruby is free software; you can redistribute it and/or modify it
8
+ # under the terms of the GNU Lesser General Public License as published by
9
+ # the Free Software Foundation; either version 2.1 of the License, or (at
10
+ # your option) any later version. It is distributed WITHOUT ANY WARRANTY; see
11
+ # the file LICENSE for details.
12
+
13
+ require "zlib"
14
+ require_relative "retvals"
15
+
16
+ module KontoCheckRuby
17
+ # Reader and writer for the LUT2 file format of konto_check
18
+ # ("BLZ Lookup Table/Format 2.0").
19
+ #
20
+ # File layout:
21
+ #
22
+ # text prolog (several lines, the first one is the signature), ending
23
+ # with a line "DATA"
24
+ # 2 bytes number of directory slots (little endian)
25
+ # slots*12 directory: per slot (type, file offset, compressed length),
26
+ # each an unsigned 32 bit little endian integer; type 0 = empty
27
+ # blocks per block a 16 byte header (type, compressed length,
28
+ # uncompressed length, adler32 of the uncompressed data)
29
+ # followed by the (zlib compressed) block data
30
+ #
31
+ # All methods of this module return [return_code, value]; return_code is
32
+ # one of the integer constants of KontoCheckRuby (OK or an error code).
33
+ module LutFile
34
+ SIGNATURE_V2 = "BLZ Lookup Table/Format 2."
35
+ SIGNATURE_V1 = "BLZ Lookup Table/Format 1."
36
+ MAX_SLOTS = 500
37
+ SLOT_CNT_MIN = 60
38
+ DEFAULT_SLOTS = 60
39
+ SET_OFFSET = 100
40
+
41
+ COMPRESSION_NAMES = {
42
+ COMPRESSION_NONE => "keine",
43
+ COMPRESSION_ZLIB => "zlib",
44
+ COMPRESSION_BZIP2 => "bzip2",
45
+ COMPRESSION_LZO => "lzo",
46
+ COMPRESSION_LZMA => "lzma"
47
+ }.freeze
48
+
49
+ # Names of the block types as used in the info block and in lut_blocks()
50
+ BLOCK_NAME1 = Hash.new(" (unbekannt)").merge(
51
+ 1 => "BLZ", 2 => "FILIALEN", 3 => "NAME", 4 => "PLZ", 5 => "ORT", 6 => "NAME_KURZ",
52
+ 7 => "PAN", 8 => "BIC", 9 => "PZ", 10 => "NR", 11 => "AENDERUNG", 12 => "LOESCHUNG",
53
+ 13 => "NACHFOLGE_BLZ", 14 => "NAME_NAME_KURZ", 15 => "INFO", 16 => "BIC_SORT",
54
+ 17 => "NAME_SORT", 18 => "NAME_KURZ_SORT", 19 => "ORT_SORT", 20 => "PLZ_SORT",
55
+ 21 => "PZ_SORT", 22 => "OWN_IBAN", 23 => "VOLLTEXT_TXT", 24 => "VOLLTEXT_IDX",
56
+ 25 => "IBAN_REGEL", 26 => "IBAN_REGEL_SORT", 27 => "BIC_H_SORT", 28 => "SCL_INFO",
57
+ 29 => "SCL_BIC", 30 => "SCL_NAME", 31 => "SCL_FLAGS", 32 => "SCL_FLAGS_ORIG"
58
+ ).freeze
59
+
60
+ BLOCK_NAME2 = Hash.new(" (unbekannt)").merge(
61
+ 0 => "leer",
62
+ 1 => "BLZ", 2 => "Anzahl Fil.", 3 => "Name", 4 => "Plz", 5 => "Ort", 6 => "Name (kurz)",
63
+ 7 => "PAN", 8 => "BIC", 9 => "Pruefziffer", 10 => "Lfd. Nr.", 11 => "Aenderung",
64
+ 12 => "Loeschung", 13 => "NachfolgeBLZ", 14 => "Name, Kurzn.", 15 => "Infoblock",
65
+ 16 => "BIC idx", 17 => "Name idx", 18 => "Kurzname idx", 19 => "Ort idx", 20 => "PLZ idx",
66
+ 21 => "PZ idx", 22 => "IBAN Blacklist", 23 => "Volltext txt", 24 => "Volltext idx",
67
+ 25 => "IBAN Regel", 26 => "IBAN Regel idx", 27 => "BIC Hauptst.idx", 28 => "SCL Infoblock",
68
+ 29 => "SCL BIC", 30 => "SCL Banknamen", 31 => "SCL Flags", 32 => "SCL Flags orig"
69
+ ).freeze
70
+
71
+ # Block types for which an index block is generated (lut_block_idx in C)
72
+ BLOCKS_WITH_INDEX = [3, 4, 5, 6, 8, 9, 14].freeze
73
+
74
+ # Human readable name of a block type (mode 1: short, 2: with set number,
75
+ # 3: the C macro name)
76
+ def self.block_name(typ, mode = 1)
77
+ base = typ > SET_OFFSET && typ < 400 ? typ - SET_OFFSET : typ
78
+ case mode
79
+ when 2
80
+ return "(Userblock)" if typ >= 400
81
+ return BLOCK_NAME2[0] if typ == 0
82
+ "#{typ > SET_OFFSET ? 2 : 1}. #{BLOCK_NAME2[base]}"
83
+ when 3
84
+ n = LUT_BLOCK_TYPES[typ]
85
+ n ? "LUT2_#{typ > SET_OFFSET ? '2_' : ''}#{n}" : ""
86
+ else
87
+ typ > SET_OFFSET ? "#{BLOCK_NAME1[base]} (2)" : BLOCK_NAME1[base]
88
+ end
89
+ end
90
+
91
+ # Adler-32 variant used by konto_check (adler32a in the C source). It
92
+ # differs from the standard zlib adler32 in that the bytes are treated as
93
+ # *signed* chars. It is kept for compatibility with existing LUT files.
94
+ def self.adler32a(data, adler = 1)
95
+ s1 = adler & 0xffff
96
+ s2 = (adler >> 16) & 0xffff
97
+ bytes = data.unpack("c*")
98
+ i = 0
99
+ n = bytes.length
100
+ while i < n
101
+ k = [n - i, 5552].min
102
+ j = 0
103
+ while j < k
104
+ s1 = (s1 + bytes[i + j]) & 0xffffffff
105
+ s2 = (s2 + s1) & 0xffffffff
106
+ j += 1
107
+ end
108
+ i += k
109
+ s1 %= 65521
110
+ s2 %= 65521
111
+ end
112
+ (s2 << 16) | s1
113
+ end
114
+
115
+ # Parsed header/directory of a LUT file
116
+ class Directory
117
+ attr_reader :prolog_lines, :compression, :slots, :entries, :data_pos
118
+
119
+ # entries: Array of [typ, offset, compressed_len]
120
+ def initialize(prolog_lines, compression, slots, entries, data_pos)
121
+ @prolog_lines = prolog_lines
122
+ @compression = compression
123
+ @slots = slots
124
+ @entries = entries
125
+ @data_pos = data_pos
126
+ end
127
+
128
+ # Slot index (0 based) of the *last* block of the given type, or nil
129
+ def slot_of(typ)
130
+ idx = nil
131
+ @entries.each_with_index { |(t, _o, _l), i| idx = i if t == typ }
132
+ idx
133
+ end
134
+
135
+ def free_slot
136
+ @entries.index { |(t, _o, _l)| t == 0 }
137
+ end
138
+
139
+ def types
140
+ @entries.map(&:first)
141
+ end
142
+ end
143
+
144
+ # Reads the prolog and the directory. Returns [code, Directory].
145
+ def self.read_directory(io)
146
+ io.rewind
147
+ first = io.gets
148
+ return [INVALID_LUT_FILE, nil] unless first
149
+ line = first.chomp
150
+ return [LUT1_FILE_USED, nil] if line[0, SIGNATURE_V1.length] == SIGNATURE_V1
151
+ return [INVALID_LUT_FILE, nil] unless line[0, SIGNATURE_V2.length] == SIGNATURE_V2
152
+ prolog = [line]
153
+ compression = 0
154
+ loop do
155
+ l = io.gets
156
+ return [INVALID_LUT_FILE, nil] if l.nil?
157
+ break if l == "DATA\n"
158
+ prolog << l.chomp
159
+ case l
160
+ when "Kompression: keine\n" then compression = COMPRESSION_NONE
161
+ when "Kompression: gzip\n" then compression = COMPRESSION_ZLIB
162
+ when "Kompression: bzip2\n" then compression = COMPRESSION_BZIP2
163
+ when "Kompression: lzo\n" then compression = COMPRESSION_LZO
164
+ when "Kompression: lzma\n" then compression = COMPRESSION_LZMA
165
+ end
166
+ end
167
+ compression = COMPRESSION_ZLIB if compression == 0
168
+ hdr = io.read(2)
169
+ return [LUT2_FILE_CORRUPTED, nil] if hdr.nil? || hdr.bytesize < 2
170
+ slots = hdr.unpack1("v")
171
+ dir_pos = io.pos
172
+ dir = io.read(slots * 12)
173
+ return [LUT2_FILE_CORRUPTED, nil] if dir.nil? || dir.bytesize != slots * 12
174
+ entries = dir.unpack("V*").each_slice(3).to_a
175
+ [OK, Directory.new(prolog, compression, slots, entries, dir_pos)]
176
+ end
177
+
178
+ # Reads one block. Either typ (the last block of that type wins) or
179
+ # slot (1 based) must be given. Returns [code, data(binary String)].
180
+ def self.read_block_io(io, dir, typ: nil, slot: nil)
181
+ if slot && slot > 0 && slot <= dir.slots
182
+ typ, read_pos, compressed_len1 = dir.entries[slot - 1]
183
+ else
184
+ read_pos = 0
185
+ compressed_len1 = 0
186
+ dir.entries.each do |t, o, l|
187
+ next unless t == typ
188
+ read_pos = o
189
+ compressed_len1 = l
190
+ end
191
+ end
192
+ return [LUT2_BLOCK_NOT_IN_FILE, nil] if read_pos.nil? || read_pos == 0
193
+ io.seek(read_pos)
194
+ header = io.read(16)
195
+ return [FILE_READ_ERROR, nil] if header.nil? || header.bytesize < 16
196
+ typ2, compressed_len, len, adler = header.unpack("V4")
197
+ return [LUT2_FILE_CORRUPTED, nil] if typ2 != typ || compressed_len != compressed_len1
198
+ raw = io.read(compressed_len)
199
+ return [FILE_READ_ERROR, nil] if raw.nil? || raw.bytesize < compressed_len
200
+ data = case dir.compression
201
+ when COMPRESSION_NONE then raw
202
+ when COMPRESSION_ZLIB
203
+ begin
204
+ Zlib::Inflate.inflate(raw)
205
+ rescue Zlib::DataError
206
+ return [LUT2_Z_DATA_ERROR, nil]
207
+ rescue Zlib::BufError
208
+ return [LUT2_Z_BUF_ERROR, nil]
209
+ rescue Zlib::MemError
210
+ return [LUT2_Z_MEM_ERROR, nil]
211
+ end
212
+ else
213
+ return [KTO_CHECK_UNSUPPORTED_COMPRESSION, nil]
214
+ end
215
+ return [LUT2_Z_DATA_ERROR, nil] if data.bytesize != len
216
+ return [LUT_CRC_ERROR, nil] if adler32a(data) != adler
217
+ [OK, data]
218
+ end
219
+
220
+ def self.read_block(filename, typ)
221
+ return [FILE_READ_ERROR, nil] unless File.file?(filename)
222
+ File.open(filename, "rb") do |io|
223
+ code, dir = read_directory(io)
224
+ return [code, nil] unless code == OK
225
+ read_block_io(io, dir, typ: typ)
226
+ end
227
+ rescue SystemCallError
228
+ [FILE_READ_ERROR, nil]
229
+ end
230
+
231
+ def self.read_slot(filename, slot)
232
+ return [FILE_READ_ERROR, nil] unless File.file?(filename)
233
+ File.open(filename, "rb") do |io|
234
+ code, dir = read_directory(io)
235
+ return [code, nil] unless code == OK
236
+ read_block_io(io, dir, slot: slot)
237
+ end
238
+ rescue SystemCallError
239
+ [FILE_READ_ERROR, nil]
240
+ end
241
+
242
+ # Reads the directory of a file. Returns [code, Directory].
243
+ def self.directory(filename)
244
+ return [FILE_READ_ERROR, nil] unless File.file?(filename)
245
+ File.open(filename, "rb") { |io| read_directory(io) }
246
+ rescue SystemCallError
247
+ [FILE_READ_ERROR, nil]
248
+ end
249
+
250
+ # Creates a new (empty) LUT file with the given prolog and number of
251
+ # directory slots. Returns a return code.
252
+ def self.create(filename, prolog, slots = DEFAULT_SLOTS)
253
+ return TOO_MANY_SLOTS if slots > MAX_SLOTS
254
+ File.open(filename, "wb") do |io|
255
+ io.write(prolog)
256
+ io.write("\nDATA\n")
257
+ io.write([slots].pack("v"))
258
+ io.write("\0" * (slots * 12))
259
+ end
260
+ OK
261
+ rescue SystemCallError
262
+ FILE_WRITE_ERROR
263
+ end
264
+
265
+ # Appends a block to an existing LUT file (opened read/write). A slot
266
+ # with the same type is reused (the old block stays in the file but is
267
+ # no longer referenced), otherwise the first free slot is taken.
268
+ def self.write_block_io(io, typ, data)
269
+ code, dir = read_directory(io)
270
+ return code unless code == OK
271
+ slot = dir.slot_of(typ) || dir.free_slot
272
+ return LUT2_NO_SLOT_FREE if slot.nil?
273
+ data = data.b
274
+ compressed = case dir.compression
275
+ when COMPRESSION_NONE then data
276
+ when COMPRESSION_ZLIB then Zlib::Deflate.deflate(data, Zlib::BEST_COMPRESSION)
277
+ else return KTO_CHECK_UNSUPPORTED_COMPRESSION
278
+ end
279
+ io.seek(0, IO::SEEK_END)
280
+ write_pos = io.pos
281
+ io.write([typ, compressed.bytesize, data.bytesize, adler32a(data)].pack("V4"))
282
+ io.write(compressed)
283
+ io.seek(dir.data_pos + slot * 12)
284
+ io.write([typ, write_pos, compressed.bytesize].pack("V3"))
285
+ io.flush
286
+ OK
287
+ end
288
+
289
+ def self.write_block(filename, typ, data)
290
+ return FILE_WRITE_ERROR unless File.file?(filename)
291
+ File.open(filename, "r+b") { |io| write_block_io(io, typ, data) }
292
+ rescue SystemCallError
293
+ FILE_WRITE_ERROR
294
+ end
295
+
296
+ # Textual dump of the directory (lut_dir_dump_str in C). Returns [code, String].
297
+ def self.dump(filename)
298
+ return [FILE_READ_ERROR, nil] unless File.file?(filename)
299
+ File.open(filename, "rb") do |io|
300
+ code, dir = read_directory(io)
301
+ return [code, nil] unless code == OK
302
+ out = +" Slot retval Typ Inhalt Laenge kompr. Verh. Adler32 Test\n"
303
+ len1 = len2 = 0
304
+ dir.entries.each_with_index do |(typ, offset, clen), i|
305
+ if typ == 0
306
+ out << format("%2d/%2d %3d %8d %-20s %8d %8d%7s 0x%08x %s\n", i + 1, dir.slots, 1, 0, " (ungenutzt)", 0, 0, "-", 0, "OK")
307
+ next
308
+ end
309
+ io.seek(offset)
310
+ header = io.read(16)
311
+ typ2, clen2, len, adler = header ? header.unpack("V4") : [0, 0, 0, 0]
312
+ retval = OK
313
+ retval = LUT2_FILE_CORRUPTED if typ2 != typ || clen2 != clen
314
+ if retval == OK
315
+ rc, data = read_block_io(io, dir, slot: i + 1)
316
+ retval = rc
317
+ retval = LUT_CRC_ERROR if rc == OK && adler32a(data) != adler
318
+ end
319
+ out << format("%2d/%2d %3d %8d %-20s %8d %8d%7.1f%% 0x%08x %s\n",
320
+ i + 1, dir.slots, retval, typ, block_name(typ, 2), len, clen,
321
+ len > 0 ? clen.fdiv(len) * 100 : 0.0, adler, retval == OK ? "OK" : "FEHLER")
322
+ len1 += len
323
+ len2 += clen
324
+ end
325
+ out << format("\nGesamtgroesse unkomprimiert: %d, Gesamtgroesse komprimiert: %d\nKompressionsrate: %1.2f%% (Kompression: %s)\nSlotdir (kurz): ",
326
+ len1, len2, len1 > 0 ? 100.0 * len2 / len1 : 0.0, COMPRESSION_NAMES[dir.compression] || "???")
327
+ out << dir.entries.map(&:first).reject(&:zero?).map(&:to_s).join(" ") << " \n"
328
+ [OK, out]
329
+ end
330
+ rescue SystemCallError
331
+ [FILE_READ_ERROR, nil]
332
+ end
333
+
334
+ # Extracts the validity dates (JJJJMMTT integers) from an info block.
335
+ # Returns [from, to] (0 if missing).
336
+ def self.parse_validity(info)
337
+ return [0, 0] unless info
338
+ first = info.lines.first.to_s
339
+ m = first.match(/(\d+)\D*-?\D*(\d+)?/)
340
+ return [0, 0] unless m
341
+ [m[1].to_i, m[2].to_i]
342
+ end
343
+
344
+ # Validity status of an info block for the given date (Integer JJJJMMTT)
345
+ def self.validity(info, current)
346
+ v1, v2 = parse_validity(info)
347
+ return LUT2_NO_VALID_DATE if v1 == 0 || v2 == 0
348
+ return LUT2_VALID if current >= v1 && current <= v2
349
+ return LUT2_NOT_YET_VALID if current < v1
350
+ LUT2_NO_LONGER_VALID
351
+ end
352
+
353
+ # Extracts the file id ("Datei-ID ...") from an info block or nil.
354
+ def self.file_id(info)
355
+ return nil unless info
356
+ lines = info.lines.map(&:chomp)
357
+ i = lines.index { |l| l.start_with?("Datei-ID (zuf") }
358
+ i && lines[i + 1]
359
+ end
360
+
361
+ # Reads both info blocks of a LUT file. Returns
362
+ # [code, info1, info2, valid1, valid2] (lut_info in C for a file name).
363
+ def self.info(filename, current_date = today_int)
364
+ return [FILE_READ_ERROR, nil, nil, 0, 0] unless File.file?(filename)
365
+ File.open(filename, "rb") do |io|
366
+ code, dir = read_directory(io)
367
+ return [code, nil, nil, code, code] unless code == OK
368
+ c1, i1 = read_block_io(io, dir, typ: LUT2_INFO)
369
+ unless c1 == OK
370
+ # no info block: return the prolog like get_lut_info2()
371
+ prolog = dir.prolog_lines.join("\n") + "\n"
372
+ return [c1, prolog, nil, c1, 0]
373
+ end
374
+ i1 = i1.force_encoding("ISO-8859-1").encode("UTF-8")
375
+ v1 = validity(i1, current_date)
376
+ c2, i2 = read_block_io(io, dir, typ: LUT2_2_INFO)
377
+ if c2 == OK
378
+ i2 = i2.force_encoding("ISO-8859-1").encode("UTF-8")
379
+ v2 = validity(i2, current_date)
380
+ if v2 == LUT2_NO_LONGER_VALID && v1 == LUT2_NO_LONGER_VALID
381
+ # both sets expired: mark the younger one as "better"
382
+ e1 = parse_validity(i1)[1]
383
+ e2 = parse_validity(i2)[1]
384
+ if e2 > e1
385
+ v2 = LUT2_NO_LONGER_VALID_BETTER
386
+ else
387
+ v1 = LUT2_NO_LONGER_VALID_BETTER
388
+ end
389
+ end
390
+ else
391
+ i2 = nil
392
+ v2 = c2
393
+ end
394
+ [OK, i1, i2, v1, v2]
395
+ end
396
+ rescue SystemCallError
397
+ [FILE_READ_ERROR, nil, nil, 0, 0]
398
+ end
399
+
400
+ def self.today_int(time = Time.now)
401
+ time.year * 10000 + time.month * 100 + time.day
402
+ end
403
+
404
+ # Prolog, system info line and user info line of the file
405
+ # (get_lut_info2 in C). Returns [code, version, prolog, info, user_info].
406
+ def self.prolog_info(filename)
407
+ code, dir = directory(filename)
408
+ if code == LUT1_FILE_USED
409
+ return [OK, 1, "", "", ""]
410
+ end
411
+ return [code, 0, nil, nil, nil] unless code == OK
412
+ lines = dir.prolog_lines
413
+ prolog = lines.join("\n") + "\n"
414
+ info = lines[1] || ""
415
+ user_info = ""
416
+ if info.end_with?("\\")
417
+ info = info.chomp("\\")
418
+ user_info = lines[2] || ""
419
+ end
420
+ [OK, 3, prolog, info, user_info]
421
+ end
422
+ end
423
+ end
424
+
425
+ module KontoCheckRuby
426
+ module LutFile
427
+ # Writes the IBAN blacklist block (LUT2_OWN_IBAN) from a text file with
428
+ # lines "<BLZ>=0" into an existing LUT file (lut_keine_iban_berechnung in
429
+ # C). A line "2718281=0" marks the list as coming from the IBAN service
430
+ # portal; only then the block is evaluated by the IBAN functions.
431
+ def self.write_iban_blacklist(blacklist_file, lutfile, set = 0)
432
+ return FILE_READ_ERROR unless File.file?(blacklist_file)
433
+ blzs = []
434
+ File.foreach(blacklist_file) do |line|
435
+ next unless line =~ /\A(\d{7,8})=0/
436
+ blzs << Regexp.last_match(1).to_i
437
+ end
438
+ blzs.sort!
439
+ data = [blzs.size].pack("V") + blzs.pack("V*")
440
+ offset = set == 2 ? SET_OFFSET : 0
441
+ File.open(lutfile, "r+b") do |io|
442
+ code = write_block_io(io, LUT2_OWN_IBAN + offset, data)
443
+ return code unless code == OK
444
+ c, dir = read_directory(io)
445
+ return c unless c == OK
446
+ c, info = read_block_io(io, dir, typ: LUT2_INFO + offset)
447
+ if c == OK && info =~ /^Enthaltene Felder:.*$/ && !info.include?(", OWN_IBAN")
448
+ info = info.sub(/^(Enthaltene Felder:[^\n]*)/) { "#{Regexp.last_match(1)}, OWN_IBAN" }
449
+ write_block_io(io, LUT2_INFO + offset, info)
450
+ end
451
+ end
452
+ OK
453
+ rescue SystemCallError
454
+ FILE_WRITE_ERROR
455
+ end
456
+ end
457
+ end