png_conform 0.1.1 → 0.1.3
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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +82 -42
- data/Gemfile +2 -0
- data/README.adoc +3 -2
- data/benchmarks/README.adoc +570 -0
- data/benchmarks/config/default.yml +35 -0
- data/benchmarks/config/full.yml +32 -0
- data/benchmarks/config/quick.yml +32 -0
- data/benchmarks/direct_validation.rb +18 -0
- data/benchmarks/lib/benchmark_runner.rb +204 -0
- data/benchmarks/lib/metrics_collector.rb +193 -0
- data/benchmarks/lib/png_conform_runner.rb +68 -0
- data/benchmarks/lib/pngcheck_runner.rb +67 -0
- data/benchmarks/lib/report_generator.rb +301 -0
- data/benchmarks/lib/tool_runner.rb +104 -0
- data/benchmarks/profile_loading.rb +12 -0
- data/benchmarks/profile_validation.rb +18 -0
- data/benchmarks/results/.gitkeep +0 -0
- data/benchmarks/run_benchmark.rb +159 -0
- data/config/validation_profiles.yml +105 -0
- data/docs/CHUNK_TYPES.adoc +42 -0
- data/examples/README.md +282 -0
- data/lib/png_conform/analyzers/comparison_analyzer.rb +41 -7
- data/lib/png_conform/analyzers/metrics_analyzer.rb +6 -9
- data/lib/png_conform/analyzers/optimization_analyzer.rb +30 -24
- data/lib/png_conform/analyzers/resolution_analyzer.rb +31 -32
- data/lib/png_conform/cli.rb +12 -0
- data/lib/png_conform/commands/check_command.rb +118 -52
- data/lib/png_conform/configuration.rb +147 -0
- data/lib/png_conform/container.rb +113 -0
- data/lib/png_conform/models/decoded_chunk_data.rb +33 -0
- data/lib/png_conform/models/validation_result.rb +30 -4
- data/lib/png_conform/pipelines/pipeline_result.rb +39 -0
- data/lib/png_conform/pipelines/stages/analysis_stage.rb +35 -0
- data/lib/png_conform/pipelines/stages/base_stage.rb +23 -0
- data/lib/png_conform/pipelines/stages/chunk_validation_stage.rb +74 -0
- data/lib/png_conform/pipelines/stages/sequence_validation_stage.rb +77 -0
- data/lib/png_conform/pipelines/stages/signature_validation_stage.rb +41 -0
- data/lib/png_conform/pipelines/validation_pipeline.rb +90 -0
- data/lib/png_conform/readers/full_load_reader.rb +13 -4
- data/lib/png_conform/readers/streaming_reader.rb +27 -2
- data/lib/png_conform/reporters/color_reporter.rb +17 -14
- data/lib/png_conform/reporters/reporter_factory.rb +18 -11
- data/lib/png_conform/reporters/visual_elements.rb +22 -16
- data/lib/png_conform/services/analysis_manager.rb +120 -0
- data/lib/png_conform/services/chunk_processor.rb +195 -0
- data/lib/png_conform/services/file_signature.rb +226 -0
- data/lib/png_conform/services/file_strategy.rb +78 -0
- data/lib/png_conform/services/lru_cache.rb +170 -0
- data/lib/png_conform/services/parallel_validator.rb +118 -0
- data/lib/png_conform/services/profile_manager.rb +41 -12
- data/lib/png_conform/services/result_builder.rb +299 -0
- data/lib/png_conform/services/validation_cache.rb +210 -0
- data/lib/png_conform/services/validation_orchestrator.rb +188 -0
- data/lib/png_conform/services/validation_service.rb +82 -321
- data/lib/png_conform/services/validator_pool.rb +142 -0
- data/lib/png_conform/utils/colorizer.rb +149 -0
- data/lib/png_conform/validators/ancillary/idot_validator.rb +102 -0
- data/lib/png_conform/validators/chunk_registry.rb +143 -128
- data/lib/png_conform/validators/streaming_idat_validator.rb +123 -0
- data/lib/png_conform/version.rb +1 -1
- data/lib/png_conform.rb +7 -46
- data/png_conform.gemspec +1 -0
- metadata +55 -2
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../models/validation_result"
|
|
4
|
+
require_relative "../models/file_analysis"
|
|
5
|
+
require_relative "../models/image_info"
|
|
6
|
+
require_relative "../models/compression_info"
|
|
7
|
+
|
|
8
|
+
module PngConform
|
|
9
|
+
module Services
|
|
10
|
+
# Builds validation results and file analysis
|
|
11
|
+
#
|
|
12
|
+
# The ResultBuilder handles:
|
|
13
|
+
# - Building ValidationResult from validation context
|
|
14
|
+
# - Extracting ImageInfo from IHDR chunk
|
|
15
|
+
# - Extracting CompressionInfo (lazy calculation)
|
|
16
|
+
# - Building complete FileAnalysis model
|
|
17
|
+
#
|
|
18
|
+
# This class extracts result building logic from ValidationService
|
|
19
|
+
# following Single Responsibility Principle.
|
|
20
|
+
#
|
|
21
|
+
class ResultBuilder
|
|
22
|
+
# Initialize result builder
|
|
23
|
+
#
|
|
24
|
+
# @param reader [Object] File reader (StreamingReader or FullLoadReader)
|
|
25
|
+
# @param filepath [String] File path for reporting
|
|
26
|
+
# @param context [ValidationContext] Validation context with results
|
|
27
|
+
# @param chunks [Array] Array of BinData chunks
|
|
28
|
+
# @param options [Hash] CLI options for conditional calculations
|
|
29
|
+
def initialize(reader, filepath, context, chunks, options = {})
|
|
30
|
+
@reader = reader
|
|
31
|
+
@filepath = filepath
|
|
32
|
+
@context = context
|
|
33
|
+
@chunks = chunks
|
|
34
|
+
@options = options
|
|
35
|
+
@idat_data_cache = nil # Cache for streaming IDAT data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Build complete FileAnalysis with all data
|
|
39
|
+
#
|
|
40
|
+
# @return [FileAnalysis] Complete analysis model
|
|
41
|
+
def build
|
|
42
|
+
# First build the ValidationResult
|
|
43
|
+
validation_result = build_validation_result
|
|
44
|
+
|
|
45
|
+
# Extract image info from IHDR
|
|
46
|
+
image_info = extract_image_info(validation_result)
|
|
47
|
+
|
|
48
|
+
# Build complete FileAnalysis
|
|
49
|
+
Models::FileAnalysis.new.tap do |analysis|
|
|
50
|
+
analysis.file_path = @filepath || "unknown"
|
|
51
|
+
analysis.file_size = validation_result.file_size
|
|
52
|
+
analysis.file_type = validation_result.file_type
|
|
53
|
+
analysis.validation_result = validation_result
|
|
54
|
+
analysis.image_info = image_info
|
|
55
|
+
analysis.compression_info = extract_compression_info(validation_result)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Build ValidationResult from validation context and chunks
|
|
62
|
+
#
|
|
63
|
+
# @return [ValidationResult] Validation results with chunks and errors
|
|
64
|
+
def build_validation_result
|
|
65
|
+
Models::ValidationResult.new.tap do |result|
|
|
66
|
+
# Set file metadata
|
|
67
|
+
result.filename = @filepath || "unknown"
|
|
68
|
+
result.file_type = determine_file_type
|
|
69
|
+
result.file_size = calculate_file_size
|
|
70
|
+
|
|
71
|
+
# Add all chunks with CRC validation
|
|
72
|
+
add_chunks_with_crc(result)
|
|
73
|
+
|
|
74
|
+
# Calculate compression ratio for PNG files (lazy calculation)
|
|
75
|
+
result.compression_ratio = if result.file_type == "PNG" &&
|
|
76
|
+
need_compression_ratio?
|
|
77
|
+
calculate_compression_ratio(result.chunks)
|
|
78
|
+
else
|
|
79
|
+
0.0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Add all errors, warnings, and info from context
|
|
83
|
+
add_context_messages(result)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add chunks with CRC validation to result
|
|
88
|
+
#
|
|
89
|
+
# Caches IDAT data during initial pass for streaming compression calculation.
|
|
90
|
+
# Uses cached CRC validation from reader if available to avoid recalculation.
|
|
91
|
+
#
|
|
92
|
+
# @param result [ValidationResult] Result to add chunks to
|
|
93
|
+
# @return [void]
|
|
94
|
+
def add_chunks_with_crc(result)
|
|
95
|
+
crc_error_count = 0
|
|
96
|
+
|
|
97
|
+
@chunks.each do |bindata_chunk|
|
|
98
|
+
chunk = Models::Chunk.from_bindata(bindata_chunk,
|
|
99
|
+
bindata_chunk.abs_offset)
|
|
100
|
+
|
|
101
|
+
# Cache IDAT data for streaming compression calculation
|
|
102
|
+
if bindata_chunk.chunk_type.to_s == "IDAT"
|
|
103
|
+
@idat_data_cache ||= ""
|
|
104
|
+
@idat_data_cache += bindata_chunk.data.to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validate CRC using cached result if available
|
|
108
|
+
expected_crc = bindata_chunk.crc
|
|
109
|
+
chunk.crc_expected = format_hex(expected_crc)
|
|
110
|
+
|
|
111
|
+
# Check if reader already validated CRC (cached in @_crc_valid)
|
|
112
|
+
if bindata_chunk.instance_variable_defined?(:@_crc_valid)
|
|
113
|
+
# Use cached validation result
|
|
114
|
+
chunk.valid_crc = bindata_chunk.instance_variable_get(:@_crc_valid)
|
|
115
|
+
# Don't set crc_actual since we didn't recalculate it
|
|
116
|
+
else
|
|
117
|
+
# Calculate CRC if not cached
|
|
118
|
+
actual_crc = calculate_crc(bindata_chunk)
|
|
119
|
+
chunk.crc_actual = format_hex(actual_crc)
|
|
120
|
+
chunk.valid_crc = (expected_crc == actual_crc)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
crc_error_count += 1 unless chunk.valid_crc
|
|
124
|
+
|
|
125
|
+
result.add_chunk(chunk)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
result.crc_errors_count = crc_error_count
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Add all messages from validation context to result
|
|
132
|
+
#
|
|
133
|
+
# @param result [ValidationResult] Result to add messages to
|
|
134
|
+
# @return [void]
|
|
135
|
+
def add_context_messages(result)
|
|
136
|
+
# Add errors from context
|
|
137
|
+
@context.all_errors.each do |e|
|
|
138
|
+
result.error(e[:message])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Add warnings from context
|
|
142
|
+
@context.all_warnings.each do |w|
|
|
143
|
+
result.warning(w[:message])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Add info from context
|
|
147
|
+
@context.all_info.each do |i|
|
|
148
|
+
result.info(i[:message])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Calculate file size from reader or chunks
|
|
153
|
+
#
|
|
154
|
+
# Performance optimization: Use reader.file_size if available,
|
|
155
|
+
# otherwise calculate from chunks (O(n) operation).
|
|
156
|
+
#
|
|
157
|
+
# @return [Integer] File size in bytes
|
|
158
|
+
def calculate_file_size
|
|
159
|
+
if @reader.respond_to?(:file_size)
|
|
160
|
+
@reader.file_size
|
|
161
|
+
else
|
|
162
|
+
# 8 bytes signature + sum of chunk sizes
|
|
163
|
+
# (8 byte header + data + 4 byte CRC per chunk)
|
|
164
|
+
8 + @chunks.sum { |c| 12 + c.length }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Determine file type based on chunks
|
|
169
|
+
#
|
|
170
|
+
# @return [String] File type (PNG, MNG, JNG, or UNKNOWN)
|
|
171
|
+
def determine_file_type
|
|
172
|
+
return Models::ValidationResult::FILE_TYPE_MNG if @context.seen?("MHDR")
|
|
173
|
+
return Models::ValidationResult::FILE_TYPE_JNG if @context.seen?("JHDR")
|
|
174
|
+
return Models::ValidationResult::FILE_TYPE_PNG if @context.seen?("IHDR")
|
|
175
|
+
|
|
176
|
+
Models::ValidationResult::FILE_TYPE_UNKNOWN
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Calculate CRC32 for a chunk
|
|
180
|
+
#
|
|
181
|
+
# @param chunk [Object] BinData chunk
|
|
182
|
+
# @return [Integer] CRC32 value
|
|
183
|
+
def calculate_crc(chunk)
|
|
184
|
+
require "zlib"
|
|
185
|
+
# CRC is calculated over chunk type + chunk data
|
|
186
|
+
Zlib.crc32(chunk.chunk_type.to_s + chunk.data.to_s)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Format integer as hex string
|
|
190
|
+
#
|
|
191
|
+
# @param value [Integer] Value to format
|
|
192
|
+
# @return [String] Hex string (e.g., "0x12345678")
|
|
193
|
+
def format_hex(value)
|
|
194
|
+
format("0x%08x", value)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Calculate compression ratio for PNG
|
|
198
|
+
#
|
|
199
|
+
# Streaming optimization: Uses cached IDAT data from initial chunk read
|
|
200
|
+
# to avoid re-selecting and concatenating chunks.
|
|
201
|
+
#
|
|
202
|
+
# Lazy calculation - only performed when needed.
|
|
203
|
+
#
|
|
204
|
+
# @param chunks [Array<Chunk>] All chunks
|
|
205
|
+
# @return [Float] Compression ratio as percentage, 0.0 if cannot calculate
|
|
206
|
+
def calculate_compression_ratio(chunks)
|
|
207
|
+
# Use cached IDAT data from streaming read
|
|
208
|
+
idat_chunks = chunks.select { |c| c.type == "IDAT" }
|
|
209
|
+
return 0.0 if idat_chunks.empty?
|
|
210
|
+
|
|
211
|
+
compressed_size = idat_chunks.sum(&:length)
|
|
212
|
+
return 0.0 if compressed_size.zero?
|
|
213
|
+
|
|
214
|
+
# Try to decompress to get original size
|
|
215
|
+
begin
|
|
216
|
+
require "zlib"
|
|
217
|
+
|
|
218
|
+
# Use cached IDAT data from initial read (streaming optimization)
|
|
219
|
+
compressed_data = @idat_data_cache || ""
|
|
220
|
+
|
|
221
|
+
decompressed = Zlib::Inflate.inflate(compressed_data)
|
|
222
|
+
original_size = decompressed.bytesize
|
|
223
|
+
|
|
224
|
+
return 0.0 if original_size.zero?
|
|
225
|
+
|
|
226
|
+
# Calculate percentage: (compressed/original - 1) * 100
|
|
227
|
+
# Negative means compression, positive means expansion
|
|
228
|
+
((compressed_size.to_f / original_size - 1) * 100).round(1)
|
|
229
|
+
rescue StandardError
|
|
230
|
+
# If decompression fails, we can't calculate ratio
|
|
231
|
+
0.0
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Extract ImageInfo from IHDR chunk
|
|
236
|
+
#
|
|
237
|
+
# @param result [ValidationResult] Validation result with chunks
|
|
238
|
+
# @return [ImageInfo, nil] Image info or nil if IHDR not found
|
|
239
|
+
def extract_image_info(result)
|
|
240
|
+
ihdr = result.ihdr_chunk
|
|
241
|
+
return nil unless ihdr&.data && ihdr.data.bytesize >= 13
|
|
242
|
+
|
|
243
|
+
width = ihdr.data.bytes[0..3].pack("C*").unpack1("N")
|
|
244
|
+
height = ihdr.data.bytes[4..7].pack("C*").unpack1("N")
|
|
245
|
+
bit_depth = ihdr.data.bytes[8]
|
|
246
|
+
color_type = ihdr.data.bytes[9]
|
|
247
|
+
interlace = ihdr.data.bytes[12]
|
|
248
|
+
|
|
249
|
+
Models::ImageInfo.new.tap do |info|
|
|
250
|
+
info.width = width
|
|
251
|
+
info.height = height
|
|
252
|
+
info.bit_depth = bit_depth
|
|
253
|
+
info.color_type = color_type_name(color_type)
|
|
254
|
+
info.interlaced = interlace == 1
|
|
255
|
+
info.animated = false # Could check for APNG chunks
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Extract CompressionInfo
|
|
260
|
+
#
|
|
261
|
+
# @param result [ValidationResult] Validation result
|
|
262
|
+
# @return [CompressionInfo, nil] Compression info or nil
|
|
263
|
+
def extract_compression_info(result)
|
|
264
|
+
return nil unless result.compression_ratio
|
|
265
|
+
|
|
266
|
+
Models::CompressionInfo.new.tap do |info|
|
|
267
|
+
info.compression_ratio = result.compression_ratio
|
|
268
|
+
info.compressed_size = result.chunks.select do |c|
|
|
269
|
+
c.type == "IDAT"
|
|
270
|
+
end.sum(&:length)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Helper to convert color type code to name
|
|
275
|
+
#
|
|
276
|
+
# @param code [Integer] Color type code
|
|
277
|
+
# @return [String] Color type name
|
|
278
|
+
def color_type_name(code)
|
|
279
|
+
case code
|
|
280
|
+
when 0 then "grayscale"
|
|
281
|
+
when 2 then "truecolor"
|
|
282
|
+
when 3 then "palette"
|
|
283
|
+
when 4 then "grayscale+alpha"
|
|
284
|
+
when 6 then "truecolor+alpha"
|
|
285
|
+
else "unknown"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Check if compression ratio calculation is needed
|
|
290
|
+
#
|
|
291
|
+
# @return [Boolean] True if compression ratio should be calculated
|
|
292
|
+
def need_compression_ratio?
|
|
293
|
+
return true if ["yaml", "json"].include?(@options[:format])
|
|
294
|
+
|
|
295
|
+
!@options[:quiet]
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lru_cache"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module PngConform
|
|
7
|
+
module Services
|
|
8
|
+
# Validation cache for storing and reusing validation results
|
|
9
|
+
#
|
|
10
|
+
# Provides multi-level caching for validation operations to avoid
|
|
11
|
+
# redundant I/O and computation. Supports LRU eviction for memory efficiency.
|
|
12
|
+
#
|
|
13
|
+
class ValidationCache
|
|
14
|
+
attr_reader :file_cache, :max_files, :enabled
|
|
15
|
+
|
|
16
|
+
# Default configuration
|
|
17
|
+
DEFAULT_MAX_FILES = 100
|
|
18
|
+
DEFAULT_ENABLED = true
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Get global singleton instance
|
|
22
|
+
#
|
|
23
|
+
# @return [ValidationCache] Global cache instance
|
|
24
|
+
def instance
|
|
25
|
+
@instance ||= new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Reset global cache
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
def reset!
|
|
32
|
+
@instance = new
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Initialize validation cache
|
|
37
|
+
#
|
|
38
|
+
# @param options [Hash] Cache configuration options
|
|
39
|
+
def initialize(options = {})
|
|
40
|
+
@max_files = options.fetch(:max_files, DEFAULT_MAX_FILES)
|
|
41
|
+
@file_cache = LRUCache.new(@max_files)
|
|
42
|
+
@enabled = options.fetch(:enabled, DEFAULT_ENABLED)
|
|
43
|
+
@stats = {
|
|
44
|
+
hits: 0,
|
|
45
|
+
misses: 0,
|
|
46
|
+
evictions: 0,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fetch cached validation result for a file
|
|
51
|
+
#
|
|
52
|
+
# @param file_path [String] Path to PNG file
|
|
53
|
+
# @param options [Hash] Validation options (affects cache key)
|
|
54
|
+
# @return [FileAnalysis, nil] Cached result or nil if not cached
|
|
55
|
+
def fetch(file_path, options = {})
|
|
56
|
+
return nil unless @enabled
|
|
57
|
+
|
|
58
|
+
cache_key = generate_cache_key(file_path, options)
|
|
59
|
+
entry = @file_cache[cache_key]
|
|
60
|
+
|
|
61
|
+
if entry
|
|
62
|
+
@stats[:hits] += 1
|
|
63
|
+
# Return shallow copy - FileAnalysis objects are read-only after creation
|
|
64
|
+
# This is significantly faster than Marshal deep copy
|
|
65
|
+
shallow_copy(entry[:result])
|
|
66
|
+
else
|
|
67
|
+
@stats[:misses] += 1
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Store validation result in cache
|
|
73
|
+
#
|
|
74
|
+
# @param file_path [String] Path to PNG file
|
|
75
|
+
# @param options [Hash] Validation options (affects cache key)
|
|
76
|
+
# @param result [FileAnalysis] Validation result to cache
|
|
77
|
+
# @return [void]
|
|
78
|
+
def store(file_path, options, result)
|
|
79
|
+
return unless @enabled
|
|
80
|
+
|
|
81
|
+
cache_key = generate_cache_key(file_path, options)
|
|
82
|
+
entry = {
|
|
83
|
+
result: result, # Store reference - objects are read-only
|
|
84
|
+
timestamp: Time.now,
|
|
85
|
+
file_size: result.file_size,
|
|
86
|
+
validation_time: result.validation_result&.validation_time,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@file_cache[cache_key] = entry
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if file validation is cached
|
|
93
|
+
#
|
|
94
|
+
# @param file_path [String] Path to PNG file
|
|
95
|
+
# @param options [Hash] Validation options
|
|
96
|
+
# @return [Boolean] True if result is cached
|
|
97
|
+
def cached?(file_path, options = {})
|
|
98
|
+
return false unless @enabled
|
|
99
|
+
|
|
100
|
+
cache_key = generate_cache_key(file_path, options)
|
|
101
|
+
@file_cache.key?(cache_key)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Invalidate cache entry for a file
|
|
105
|
+
#
|
|
106
|
+
# @param file_path [String] Path to PNG file
|
|
107
|
+
# @param options [Hash] Validation options (optional)
|
|
108
|
+
# @return [Boolean] True if entry was found and removed
|
|
109
|
+
def invalidate(file_path, options = {})
|
|
110
|
+
return false unless @enabled
|
|
111
|
+
|
|
112
|
+
cache_key = generate_cache_key(file_path, options)
|
|
113
|
+
deleted = @file_cache.delete(cache_key)
|
|
114
|
+
|
|
115
|
+
if deleted
|
|
116
|
+
@stats[:evictions] += 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
!!deleted
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Clear all cached entries
|
|
123
|
+
#
|
|
124
|
+
# @return [void]
|
|
125
|
+
def clear
|
|
126
|
+
@file_cache.clear
|
|
127
|
+
reset_stats
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get cache statistics
|
|
131
|
+
#
|
|
132
|
+
# @return [Hash] Cache performance statistics
|
|
133
|
+
def stats
|
|
134
|
+
total_requests = @stats[:hits] + @stats[:misses]
|
|
135
|
+
hit_rate = if total_requests.positive?
|
|
136
|
+
(@stats[:hits].to_f / total_requests * 100).round(1)
|
|
137
|
+
else
|
|
138
|
+
0.0
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
enabled: @enabled,
|
|
143
|
+
max_files: @max_files,
|
|
144
|
+
current_files: @file_cache.current_size,
|
|
145
|
+
hits: @stats[:hits],
|
|
146
|
+
misses: @stats[:misses],
|
|
147
|
+
evictions: @stats[:evictions],
|
|
148
|
+
hit_rate: hit_rate,
|
|
149
|
+
cache_stats: @file_cache.stats,
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Reset statistics counters
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
def reset_stats
|
|
157
|
+
@stats = {
|
|
158
|
+
hits: 0,
|
|
159
|
+
misses: 0,
|
|
160
|
+
evictions: 0,
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Get cache key for file validation
|
|
165
|
+
#
|
|
166
|
+
# The cache key includes file path, mtime, and relevant options
|
|
167
|
+
# to ensure cache validity when files change or options differ.
|
|
168
|
+
#
|
|
169
|
+
# @param file_path [String] Path to PNG file
|
|
170
|
+
# @param options [Hash] Validation options
|
|
171
|
+
# @return [String] Cache key
|
|
172
|
+
def generate_cache_key(file_path, options = {})
|
|
173
|
+
# Include relevant options in cache key
|
|
174
|
+
relevant_opts = options.slice(:profile, :batch_enabled, :fail_fast)
|
|
175
|
+
|
|
176
|
+
# Get file modification time for cache invalidation
|
|
177
|
+
mtime = begin
|
|
178
|
+
File.mtime(file_path).to_i
|
|
179
|
+
rescue StandardError
|
|
180
|
+
0
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Create deterministic cache key
|
|
184
|
+
key_parts = [
|
|
185
|
+
file_path,
|
|
186
|
+
mtime,
|
|
187
|
+
relevant_opts.sort.to_h,
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
Digest::SHA256.hexdigest(key_parts.flatten.join(":"))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Create shallow copy of result object
|
|
196
|
+
#
|
|
197
|
+
# Uses .dup for fast shallow copying. FileAnalysis objects are
|
|
198
|
+
# effectively read-only after creation, so deep copying is unnecessary.
|
|
199
|
+
#
|
|
200
|
+
# @param obj [Object] Object to copy
|
|
201
|
+
# @return [Object] Shallow copy of object
|
|
202
|
+
def shallow_copy(obj)
|
|
203
|
+
obj.dup
|
|
204
|
+
rescue TypeError
|
|
205
|
+
# For objects that can't be dup'd, return as-is
|
|
206
|
+
obj
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "chunk_processor"
|
|
4
|
+
require_relative "result_builder"
|
|
5
|
+
require_relative "analysis_manager"
|
|
6
|
+
|
|
7
|
+
module PngConform
|
|
8
|
+
module Services
|
|
9
|
+
# Main validation orchestration service
|
|
10
|
+
#
|
|
11
|
+
# This service coordinates the validation of PNG files by:
|
|
12
|
+
# 1. Validating the PNG signature
|
|
13
|
+
# 2. Processing chunks through ChunkProcessor
|
|
14
|
+
# 3. Validating chunk sequence requirements
|
|
15
|
+
# 4. Building the complete FileAnalysis result
|
|
16
|
+
#
|
|
17
|
+
# The orchestrator follows a pipeline architecture:
|
|
18
|
+
# File → Signature → Chunks → Sequence → Analysis → Result
|
|
19
|
+
#
|
|
20
|
+
class ValidationOrchestrator
|
|
21
|
+
attr_reader :reader, :filepath, :options, :context, :chunks
|
|
22
|
+
|
|
23
|
+
# Convenience method to validate a file by path
|
|
24
|
+
#
|
|
25
|
+
# @param filepath [String] Path to PNG file
|
|
26
|
+
# @param options [Hash] Optional CLI options
|
|
27
|
+
# @return [FileAnalysis] Complete file analysis
|
|
28
|
+
def self.validate_file(filepath, options = {})
|
|
29
|
+
require_relative "../readers/full_load_reader"
|
|
30
|
+
reader = Readers::FullLoadReader.new(filepath)
|
|
31
|
+
new(reader, filepath, options).validate
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Initialize validation orchestrator
|
|
35
|
+
#
|
|
36
|
+
# @param reader [Object] File reader (StreamingReader or FullLoadReader)
|
|
37
|
+
# @param filepath [String, nil] Optional file path (for reporting)
|
|
38
|
+
# @param options [Hash] CLI options for controlling behavior
|
|
39
|
+
def initialize(reader, filepath = nil, options = {})
|
|
40
|
+
@reader = reader
|
|
41
|
+
@filepath = filepath
|
|
42
|
+
@options = options
|
|
43
|
+
@context = Validators::ValidationContext.new
|
|
44
|
+
@chunks = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Validate the PNG file
|
|
48
|
+
#
|
|
49
|
+
# This is the main entry point for validation. It orchestrates
|
|
50
|
+
# all validation stages in the correct order.
|
|
51
|
+
#
|
|
52
|
+
# @return [FileAnalysis] Complete file analysis with all data
|
|
53
|
+
def validate
|
|
54
|
+
validate_signature
|
|
55
|
+
process_chunks
|
|
56
|
+
validate_sequence
|
|
57
|
+
build_result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get all errors from the validation context
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<Hash>] Array of error hashes
|
|
63
|
+
def errors
|
|
64
|
+
@context.all_errors
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get all warnings from the validation context
|
|
68
|
+
#
|
|
69
|
+
# @return [Array<Hash>] Array of warning hashes
|
|
70
|
+
def warnings
|
|
71
|
+
@context.all_warnings
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get all info messages from the validation context
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<Hash>] Array of info hashes
|
|
77
|
+
def info_messages
|
|
78
|
+
@context.all_info
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Validate PNG signature
|
|
84
|
+
#
|
|
85
|
+
# Checks that the file starts with the PNG signature:
|
|
86
|
+
# 137 80 78 71 13 10 26 10
|
|
87
|
+
#
|
|
88
|
+
# @return [void]
|
|
89
|
+
def validate_signature
|
|
90
|
+
sig = reader.signature
|
|
91
|
+
expected = [137, 80, 78, 71, 13, 10, 26, 10].pack("C*")
|
|
92
|
+
|
|
93
|
+
return if sig == expected
|
|
94
|
+
|
|
95
|
+
@context.add_error(
|
|
96
|
+
chunk_type: "SIGNATURE",
|
|
97
|
+
message: "Invalid PNG signature",
|
|
98
|
+
severity: :error,
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Process all chunks using ChunkProcessor
|
|
103
|
+
#
|
|
104
|
+
# Delegates chunk validation to the ChunkProcessor which handles:
|
|
105
|
+
# - Iterating through chunks
|
|
106
|
+
# - Creating validators via ChunkRegistry
|
|
107
|
+
# - Collecting validation results
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
110
|
+
def process_chunks
|
|
111
|
+
processor = ChunkProcessor.new(@reader, @context, @options)
|
|
112
|
+
processor.process do |chunk|
|
|
113
|
+
@chunks << chunk
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Validate chunk sequence requirements
|
|
118
|
+
#
|
|
119
|
+
# Checks high-level sequencing rules:
|
|
120
|
+
# - IHDR must be first chunk
|
|
121
|
+
# - IEND must be last chunk
|
|
122
|
+
# - At least one IDAT chunk required
|
|
123
|
+
#
|
|
124
|
+
# @return [void]
|
|
125
|
+
def validate_sequence
|
|
126
|
+
validate_ihdr_first
|
|
127
|
+
validate_iend_last
|
|
128
|
+
validate_idat_present
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check that IHDR is the first chunk
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
def validate_ihdr_first
|
|
135
|
+
return if @context.seen?("IHDR")
|
|
136
|
+
|
|
137
|
+
@context.add_error(
|
|
138
|
+
chunk_type: "IHDR",
|
|
139
|
+
message: "Missing IHDR chunk (must be first)",
|
|
140
|
+
severity: :error,
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check that IEND is the last chunk
|
|
145
|
+
#
|
|
146
|
+
# @return [void]
|
|
147
|
+
def validate_iend_last
|
|
148
|
+
return if @context.seen?("IEND")
|
|
149
|
+
|
|
150
|
+
@context.add_error(
|
|
151
|
+
chunk_type: "IEND",
|
|
152
|
+
message: "Missing IEND chunk (must be last)",
|
|
153
|
+
severity: :error,
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Check that at least one IDAT chunk exists
|
|
158
|
+
#
|
|
159
|
+
# @return [void]
|
|
160
|
+
def validate_idat_present
|
|
161
|
+
return if @context.seen?("IDAT")
|
|
162
|
+
|
|
163
|
+
@context.add_error(
|
|
164
|
+
chunk_type: "IDAT",
|
|
165
|
+
message: "Missing IDAT chunk (at least one required)",
|
|
166
|
+
severity: :error,
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Build complete FileAnalysis with validation results and analyzer data
|
|
171
|
+
#
|
|
172
|
+
# Coordinates ResultBuilder and AnalysisManager to produce the final result
|
|
173
|
+
#
|
|
174
|
+
# @return [FileAnalysis] Complete analysis model
|
|
175
|
+
def build_result
|
|
176
|
+
result_builder = ResultBuilder.new(@reader, @filepath, @context,
|
|
177
|
+
@chunks, @options)
|
|
178
|
+
file_analysis = result_builder.build
|
|
179
|
+
|
|
180
|
+
# Run analyzers through AnalysisManager
|
|
181
|
+
analysis_manager = AnalysisManager.new(@options)
|
|
182
|
+
analysis_manager.enrich(file_analysis)
|
|
183
|
+
|
|
184
|
+
file_analysis
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|