Class: Omnizip::Parity::Par2Verifier

Inherits:
Object
  • Object
show all
Defined in:
lib/omnizip/parity/par2_verifier.rb

Overview

PAR2 archive verifier

Verifies file integrity using PAR2 recovery files and checks if damaged files can be repaired.

Uses PacketRegistry and packet models for clean object-oriented architecture.

Examples:

Verify files

verifier = Par2Verifier.new('backup.par2')
result = verifier.verify
if result.repairable?
  puts "#{result.damaged_blocks.size} blocks damaged, can repair"
end

Defined Under Namespace

Classes: VerificationResult

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(par2_file) ⇒ Par2Verifier

Initialize verifier

Parameters:

  • par2_file (String)

    Path to .par2 index file

Raises:

  • (ArgumentError)

    if file doesn't exist



71
72
73
74
75
76
77
78
79
80
# File 'lib/omnizip/parity/par2_verifier.rb', line 71

def initialize(par2_file)
  raise ArgumentError, "PAR2 file not found: #{par2_file}" unless
    File.exist?(par2_file)

  @par2_file = par2_file
  @metadata = {}
  @file_list = []
  @recovery_blocks = []
  @block_hashes = {} # Store IFSC block hashes for verification
end

Instance Attribute Details

#file_listArray<Hash> (readonly)

Returns Parsed file list (one entry per managed file).

Returns:

  • (Array<Hash>)

    Parsed file list (one entry per managed file)



62
63
64
# File 'lib/omnizip/parity/par2_verifier.rb', line 62

def file_list
  @file_list
end

#metadataHash (readonly)

Returns Parsed PAR2 metadata.

Returns:

  • (Hash)

    Parsed PAR2 metadata



59
60
61
# File 'lib/omnizip/parity/par2_verifier.rb', line 59

def 
  @metadata
end

#par2_fileString (readonly)

Returns Path to PAR2 index file.

Returns:

  • (String)

    Path to PAR2 index file



56
57
58
# File 'lib/omnizip/parity/par2_verifier.rb', line 56

def par2_file
  @par2_file
end

#recovery_blocksArray (readonly)

Returns Available recovery blocks.

Returns:

  • (Array)

    Available recovery blocks



65
66
67
# File 'lib/omnizip/parity/par2_verifier.rb', line 65

def recovery_blocks
  @recovery_blocks
end

Instance Method Details

#avg_blocks_per_fileInteger

Calculate average blocks per file

Returns:

  • (Integer)

    Average blocks per file



333
334
335
336
337
338
339
# File 'lib/omnizip/parity/par2_verifier.rb', line 333

def avg_blocks_per_file
  return 0 if @file_list.empty?

  total_size = @file_list.sum { |f| f[:size] }
  total_blocks = (total_size.to_f / @metadata[:block_size]).ceil
  (total_blocks.to_f / @file_list.size).ceil
end

#calculate_total_blocksInteger

Calculate total blocks

Returns:

  • (Integer)

    Total number of blocks



344
345
346
347
348
# File 'lib/omnizip/parity/par2_verifier.rb', line 344

def calculate_total_blocks
  @file_list.sum do |file_info|
    (file_info[:size].to_f / @metadata[:block_size]).ceil
  end
end

#find_file_path(filename) ⇒ String?

Find file path for filename

Parameters:

  • filename (String)

    Filename from PAR2

Returns:

  • (String, nil)

    Full path or nil if not found



247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/omnizip/parity/par2_verifier.rb', line 247

def find_file_path(filename)
  # Look in same directory as PAR2 file
  dir = File.dirname(@par2_file)
  candidate = File.join(dir, filename)

  return candidate if File.exist?(candidate)

  # Look in current directory
  return filename if File.exist?(filename)

  nil
end

#identify_damaged_blocks(io, file_info) ⇒ Array<Integer>

Identify which blocks are damaged

Parameters:

  • io (IO)

    File IO

  • file_info (Hash)

    File information

Returns:

  • (Array<Integer>)

    Damaged block indices



306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/omnizip/parity/par2_verifier.rb', line 306

def identify_damaged_blocks(io, file_info)
  damaged = []
  block_idx = 0
  expected_hashes = @block_hashes[file_info[:file_id]] || []

  io.rewind
  while (data = io.read(@metadata[:block_size]))
    # Pad last block
    if data.bytesize < @metadata[:block_size]
      data += "\x00" * (@metadata[:block_size] - data.bytesize)
    end

    # Check block hash against stored IFSC hash
    block_hash = Digest::MD5.digest(data)
    if block_idx < expected_hashes.size && block_hash != expected_hashes[block_idx]
      damaged << block_idx
    end

    block_idx += 1
  end

  damaged
end

#load_recovery_volume(volume_file) ⇒ Object

Load recovery blocks from single volume file

Parameters:

  • volume_file (String)

    Path to volume file



231
232
233
234
235
236
237
238
239
240
241
# File 'lib/omnizip/parity/par2_verifier.rb', line 231

def load_recovery_volume(volume_file)
  File.open(volume_file, "rb") do |io|
    while !io.eof?
      packet = Models::PacketRegistry.read_packet(io)
      break unless packet

      # Only process recovery packets from volume files
      process_packet_model(packet) if packet.is_a?(Models::RecoverySlicePacket)
    end
  end
end

#load_recovery_volumesObject

Load recovery blocks from volume files



215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/omnizip/parity/par2_verifier.rb', line 215

def load_recovery_volumes
  base_name = File.basename(@par2_file, ".par2")
  dir_name = File.dirname(@par2_file)

  # Find all volume files
  pattern = File.join(dir_name, "#{base_name}.vol*.par2")
  volume_files = Dir.glob(pattern)

  volume_files.each do |volume_file|
    load_recovery_volume(volume_file)
  end
end

#parse_par2_filevoid

This method returns an undefined value.

Parse the PAR2 index file and populate metadata, file_list, and recovery_blocks. Idempotent — safe to call multiple times.



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/omnizip/parity/par2_verifier.rb', line 138

def parse_par2_file
  @metadata = {}
  @file_list = []
  @recovery_blocks = []
  @block_hashes = {}

  File.open(@par2_file, "rb") do |io|
    while !io.eof?
      packet = Models::PacketRegistry.read_packet(io)
      break unless packet

      process_packet_model(packet)
    end
  end

  # CRITICAL: Sort file_list by position in Main packet file_ids
  # The Main packet defines canonical file order for Reed-Solomon matrix
  # Do NOT sort by file_id string - that breaks recovery!
  if @metadata[:file_ids]
    file_id_order = {}
    @metadata[:file_ids].each_with_index do |fid, idx|
      file_id_order[fid] = idx
    end
    @file_list.sort_by! { |f| file_id_order[f[:file_id]] || 999 }
  end

  # Load recovery blocks from volume files
  load_recovery_volumes
end

#process_file_description_packet_model(packet) ⇒ Object

Process file description packet model

Parameters:



179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/omnizip/parity/par2_verifier.rb', line 179

def process_file_description_packet_model(packet)
  # Skip packets with incomplete data
  return if packet.file_id.nil? || packet.filename.nil? || packet.filename.empty?
  return if packet.file_hash.nil? || packet.length.nil?

  @file_list << {
    file_id: packet.file_id,
    hash_full: packet.file_hash,
    hash_16k: packet.file_hash_16k,
    size: packet.length,
    filename: packet.filename,
  }
end

#process_ifsc_packet_model(packet) ⇒ Object

Process IFSC packet model

Each IFSC packet contains checksums for all blocks of a file

Parameters:



198
199
200
201
202
# File 'lib/omnizip/parity/par2_verifier.rb', line 198

def process_ifsc_packet_model(packet)
  # Store block hashes keyed by file_id
  # Each IFSC packet contains all hashes for one file
  @block_hashes[packet.file_id] = packet.block_hashes
end

#process_main_packet_model(packet) ⇒ Object

Process main packet model

Parameters:



171
172
173
174
# File 'lib/omnizip/parity/par2_verifier.rb', line 171

def process_main_packet_model(packet)
  @metadata[:block_size] = packet.block_size
  @metadata[:file_ids] = packet.file_ids.dup
end

#process_recovery_packet_model(packet) ⇒ Object

Process recovery packet model

Parameters:



207
208
209
210
211
212
# File 'lib/omnizip/parity/par2_verifier.rb', line 207

def process_recovery_packet_model(packet)
  @recovery_blocks << {
    exponent: packet.exponent,
    data: packet.recovery_data,
  }
end

#verifyVerificationResult

Verify files against PAR2 data

Returns:



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/omnizip/parity/par2_verifier.rb', line 85

def verify
  parse_par2_file

  damaged_files = []
  damaged_blocks = []
  missing_files = []

  # Track global block position as we iterate through files
  global_block_idx = 0

  # Check each file
  @file_list.each do |file_info|
    file_path = find_file_path(file_info[:filename])
    num_blocks = (file_info[:size].to_f / @metadata[:block_size]).ceil

    if file_path.nil?
      missing_files << file_info[:filename]
      global_block_idx += num_blocks
      next
    end

    # Verify file integrity
    damage = verify_file(file_path, file_info)
    if damage[:damaged]
      damaged_files << file_info[:filename]
      # Convert file-relative indices to global indices
      damage[:blocks].each do |file_relative_idx|
        damaged_blocks << (global_block_idx + file_relative_idx)
      end
    end

    global_block_idx += num_blocks
  end

  # Check if repairable
  total_damage = damaged_blocks.size + (missing_files.size * avg_blocks_per_file)
  repairable = total_damage <= @recovery_blocks.size

  VerificationResult.new(
    all_ok: damaged_files.empty? && missing_files.empty?,
    damaged_files: damaged_files,
    damaged_blocks: damaged_blocks,
    missing_files: missing_files,
    repairable: repairable,
    total_blocks: calculate_total_blocks,
    recovery_blocks: @recovery_blocks.size,
  )
end

#verify_file(file_path, file_info) ⇒ Hash

Verify single file

Parameters:

  • file_path (String)

    Path to file

  • file_info (Hash)

    Expected file information

Returns:

  • (Hash)

    Damage information



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/omnizip/parity/par2_verifier.rb', line 265

def verify_file(file_path, file_info)
  damaged_blocks = []
  damaged = false

  File.open(file_path, "rb") do |io|
    # Quick check: file size
    if io.size != file_info[:size]
      damaged = true
      # Still identify damaged blocks even with size mismatch
      damaged_blocks = identify_damaged_blocks(io, file_info)
      return { damaged: damaged, blocks: damaged_blocks,
               size_mismatch: true }
    end

    # Quick check: first 16KB hash
    first_16k = io.read(16384) || ""
    hash_16k = Digest::MD5.digest(first_16k)
    if hash_16k == file_info[:hash_16k]
      # Full check: complete file hash
      io.rewind
      hash_full = Digest::MD5.file(file_path).digest
      if hash_full != file_info[:hash_full]
        damaged = true
        # Identify damaged blocks
        damaged_blocks = identify_damaged_blocks(io, file_info)
      end
    else
      damaged = true
      # Identify damaged blocks when quick check fails
      damaged_blocks = identify_damaged_blocks(io, file_info)
    end
  end

  { damaged: damaged, blocks: damaged_blocks }
end