Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions PROFILE_STATE_LEAKAGE_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Profile State Leakage Fix

## Problem Summary

The svg_conform gem was experiencing state leakage when switching between validation profiles. When validating the same SVG content with different profiles in sequence, the second validation would return incorrect results (e.g., 0 errors instead of the expected count).

### Example of the Bug

```ruby
profile = SvgConform::Profiles.get("metanorma")
validator = SvgConform::Validator.new(mode: :sax)
result = validator.validate(svg, profile: profile)
warn "metanorma: #{result.errors.count}" # => 0

profile = SvgConform::Profiles.get("svg_1_2_rfc")
validator = SvgConform::Validator.new(mode: :sax)
result = validator.validate(svg, profile: profile)
warn "svg_1_2_rfc: #{result.errors.count}" # => 0 (INCORRECT - should be 18)
```

## Root Cause

The issue stemmed from **two separate but related problems**:

### Problem 1: Incorrect Classification Cache Key

The `SaxValidationHandler` uses a class-level cache to store requirement classifications (immediate vs deferred) by profile. However, it was using `@profile.class.name` as the cache key, which evaluates to `"SvgConform::Profile"` for ALL profiles since they're all instances of the same `Profile` class.

This meant:
- When validating with "metanorma" profile first, it cached the classification with fewer deferred requirements
- When validating with "svg_1_2_rfc" profile next, it reused the SAME cached classification
- The svg_1_2_rfc validation would skip requirements that should have been deferred, causing incorrect results

### Problem 2: Incomplete State Reset in Requirements

Additionally, some requirements had incomplete `reset_state` methods:

1. **InvalidIdReferencesRequirement**: Only reset `@other_refs` but not `@collected_ids` or `@use_element_refs`
2. **NoExternalCssRequirement**: Did not implement `reset_state` at all, despite maintaining `@collected_style_elements`

## Solution

Fixed both issues:

### Fix 1: Correct Classification Cache Key

Changed `lib/svg_conform/sax_validation_handler.rb` to use the profile name instead of class name as the cache key:

```ruby
# Before (INCORRECT):
profile_key = @profile.class.name # Always "SvgConform::Profile"

# After (CORRECT):
profile_key = @profile.name || @profile.object_id.to_s # e.g., "metanorma", "svg_1_2_rfc"
```

This ensures each profile has its own cached classification of requirements.

### Fix 2: Complete State Reset in Requirements

Fixed the `reset_state` method in all affected requirement classes to properly reset ALL stateful instance variables:

### Files Modified

1. **lib/svg_conform/sax_validation_handler.rb**
- Fixed classification cache key to use `@profile.name` instead of `@profile.class.name`
- This was the PRIMARY fix that resolved the state leakage issue

2. **lib/svg_conform/requirements/invalid_id_references_requirement.rb**
- Added complete `reset_state` method that resets all three state variables:
- `@collected_ids`
- `@use_element_refs`
- `@other_refs`

3. **lib/svg_conform/requirements/no_external_css_requirement.rb**
- Added `reset_state` method to reset:
- `@collected_style_elements`

4. **spec/svg_conform_spec.rb**
- Added comprehensive test suite for profile switching without state leakage
- Tests cover:
- Switching between different profiles
- Multiple sequential validations with the same profile
- Interleaved profile validations
- Real-world SVG that violates one profile but not another

## Verification

The fix has been verified with:

1. **New Tests**: Added 3 new test cases specifically for profile switching
2. **Full Test Suite**: All 306 existing tests pass
3. **Profile Switching**: Validates that results are consistent regardless of the order of profile usage

### Test Results

```
✓ maintains consistent validation results when switching between profiles
✓ properly resets requirement state between validations
✓ handles interleaved profile validations correctly

Finished in 7.53 seconds (files took 0.3596 seconds to load)
306 examples, 0 failures, 2 pending
```

## Workarounds (Before Fix)

If you cannot immediately upgrade to the fixed version, you can work around this issue by:

1. **Clear the profile cache** between validations:
```ruby
SvgConform::Profiles.clear_cache!
```

2. **Create new validator instances** (though this won't help if profiles are cached)

## Impact

This fix ensures that:
- ✅ Validation results are consistent regardless of profile switching order
- ✅ No state leaks between different profiles
- ✅ Multiple validations with the same profile produce identical results
- ✅ The validator can be safely reused across multiple validations
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ def needs_deferred_validation?
true
end

def reset_state
@collected_ids = Set.new
@use_element_refs = []
@other_refs = []
end

def collect_sax_data(element, _context)
# Initialize collections on first call
@collected_ids ||= Set.new
Expand Down
4 changes: 4 additions & 0 deletions lib/svg_conform/requirements/no_external_css_requirement.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def needs_deferred_validation?
check_style_elements # Only deferred if checking style elements
end

def reset_state
@collected_style_elements = []
end

def collect_sax_data(element, _context)
# Collect style elements for deferred validation (text content needs to be complete)
if check_style_elements && element.name == "style"
Expand Down
3 changes: 2 additions & 1 deletion lib/svg_conform/sax_validation_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ def create_sax_context

# Classify requirements based on validation needs (with caching)
def classify_requirements_with_cache
profile_key = @profile.class.name
# Use profile name instead of class name since all profiles are instances of Profile class
profile_key = @profile.name || @profile.object_id.to_s
profile_requirements = @profile.requirements

# Check cache first (thread-safe)
Expand Down
141 changes: 141 additions & 0 deletions spec/svg_conform_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,145 @@
expect(css_profile).not_to be_nil
expect(css_profile.requirements).not_to be_empty
end

describe "profile switching without state leakage" do
let(:svg_with_invalid_ref) do
<<~SVG
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 100 100">
<defs>
<rect id="valid-rect" width="10" height="10"/>
</defs>
<use href="#valid-rect" x="10" y="10"/>
<use href="#invalid-rect" x="20" y="20"/>
</svg>
SVG
end

it "maintains consistent validation results when switching between profiles" do
validator = SvgConform::Validator.new(mode: :sax)

# First validation with metanorma profile
profile_meta = SvgConform::Profiles.get("metanorma")
result_meta1 = validator.validate(svg_with_invalid_ref, profile: profile_meta)
first_meta_count = result_meta1.errors.count

# Validation with svg_1_2_rfc profile
profile_rfc = SvgConform::Profiles.get("svg_1_2_rfc")
result_rfc1 = validator.validate(svg_with_invalid_ref, profile: profile_rfc)
first_rfc_count = result_rfc1.errors.count

# Second validation with metanorma profile (should match first)
result_meta2 = validator.validate(svg_with_invalid_ref, profile: profile_meta)
second_meta_count = result_meta2.errors.count

# Second validation with svg_1_2_rfc profile (should match first)
result_rfc2 = validator.validate(svg_with_invalid_ref, profile: profile_rfc)
second_rfc_count = result_rfc2.errors.count

# Verify consistency - no state leakage between profiles
expect(first_meta_count).to eq(second_meta_count),
"metanorma profile should return consistent results (#{first_meta_count} vs #{second_meta_count})"

expect(first_rfc_count).to eq(second_rfc_count),
"svg_1_2_rfc profile should return consistent results (#{first_rfc_count} vs #{second_rfc_count})"
end

it "properly resets requirement state between validations" do
# This test specifically verifies that requirement state is reset
# by validating the same content multiple times
validator = SvgConform::Validator.new(mode: :sax)
profile = SvgConform::Profiles.get("svg_1_2_rfc")

results = []
3.times do
result = validator.validate(svg_with_invalid_ref, profile: profile)
results << result.errors.count
end

# All runs should produce the same error count
expect(results.uniq.size).to eq(1),
"All validation runs should produce identical results: #{results.inspect}"
end

it "handles interleaved profile validations correctly" do
validator = SvgConform::Validator.new(mode: :sax)

profile_meta = SvgConform::Profiles.get("metanorma")
profile_rfc = SvgConform::Profiles.get("svg_1_2_rfc")

# Interleave validations
results = []
results << [:meta, validator.validate(svg_with_invalid_ref, profile: profile_meta).errors.count]
results << [:rfc, validator.validate(svg_with_invalid_ref, profile: profile_rfc).errors.count]
results << [:meta, validator.validate(svg_with_invalid_ref, profile: profile_meta).errors.count]
results << [:rfc, validator.validate(svg_with_invalid_ref, profile: profile_rfc).errors.count]
results << [:meta, validator.validate(svg_with_invalid_ref, profile: profile_meta).errors.count]

# Extract counts by profile
meta_counts = results.select { |type, _| type == :meta }.map(&:last)
rfc_counts = results.select { |type, _| type == :rfc }.map(&:last)

# Each profile should produce consistent results
expect(meta_counts.uniq.size).to eq(1),
"metanorma validations should be consistent: #{meta_counts.inspect}"
expect(rfc_counts.uniq.size).to eq(1),
"svg_1_2_rfc validations should be consistent: #{rfc_counts.inspect}"
end

it "returns different error counts for profiles with different requirements" do
# Real-world SVG with style attributes that violate svg_1_2_rfc but not metanorma
svg_with_styles = <<~SVG
<svg xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 28000 21000">
<g class="Drawing" id="Straight_Connector_42">
<g>
<g style="stroke:rgb(0,0,0);stroke-width:88;fill:none">
<path d="M 4264,13886 L 4264,17273" style="fill:none" />
</g></g></g>
<g class="Drawing" id="Straight_Connector_33">
<g>
<g style="stroke:rgb(0,0,0);stroke-width:88;fill:none">
<path d="M 20355,10711 L 20351,13886" style="fill:none" />
</g></g></g>
<g class="Drawing">
<g>
<g style="stroke:none;fill:none">
<rect height="3490" width="25184" x="1512" y="340" />
</g>
<g style="font-family:Arial embedded;font-size:1552px;font-weight:400">
<g style="stroke:none;fill:rgb(0,0,0)">
<text>
<tspan x="4733 5855 6718 7582 8446 8877 9741 10605 11036 12074 12853 13716 14580 15443 15960 16307 17171 17602 18724 19071 19935 20799 21315 22179 " y="2482">
Updated Scenario Diagram
</tspan>
</text>
</g>
</g>
</g>
</g>
</svg>
SVG

validator = SvgConform::Validator.new(mode: :sax)

# Validate with metanorma (should have 0 errors - more permissive)
profile_meta = SvgConform::Profiles.get("metanorma")
result_meta = validator.validate(svg_with_styles, profile: profile_meta)
meta_errors = result_meta.errors.count

# Validate with svg_1_2_rfc (should have errors - stricter requirements)
profile_rfc = SvgConform::Profiles.get("svg_1_2_rfc")
result_rfc = validator.validate(svg_with_styles, profile: profile_rfc)
rfc_errors = result_rfc.errors.count

# Profiles should return different results
expect(meta_errors).to eq(0), "metanorma profile should allow this SVG"
expect(rfc_errors).to be > 0, "svg_1_2_rfc profile should detect violations"

# Verify consistency when repeating
result_rfc2 = validator.validate(svg_with_styles, profile: profile_rfc)
expect(result_rfc2.errors.count).to eq(rfc_errors),
"svg_1_2_rfc should return same error count on repeat (no state leakage)"
end
end
end
Loading