png_conform 0.1.2 → 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 +116 -6
- data/Gemfile +1 -1
- data/config/validation_profiles.yml +105 -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 -53
- data/lib/png_conform/configuration.rb +147 -0
- data/lib/png_conform/container.rb +113 -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/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 +53 -337
- data/lib/png_conform/services/validator_pool.rb +142 -0
- data/lib/png_conform/utils/colorizer.rb +149 -0
- data/lib/png_conform/validators/chunk_registry.rb +12 -0
- data/lib/png_conform/validators/streaming_idat_validator.rb +123 -0
- data/lib/png_conform/version.rb +1 -1
- data/png_conform.gemspec +1 -0
- metadata +38 -2
|
@@ -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
|