Skip to content

Commit c120cb0

Browse files
CopilotChipWolf
andauthored
fix(icons): reduce SVG data URI limit to prevent GitHub camo URL truncation (#106)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChipWolf <3164166+ChipWolf@users.noreply.github.com>
1 parent 5d7928e commit c120cb0

3 files changed

Lines changed: 231 additions & 4 deletions

File tree

.github/copilot-instructions.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,50 @@ BadgeSort/
4141
- **Dependencies**:
4242
- `requests`: HTTP requests to Shields.io
4343
- `simpleicons`: Access to Simple Icons database
44+
- `scour`: SVG optimization for smaller badge URLs
45+
- `librsvg2-bin`: SVG to PNG conversion (system package)
4446
- `pytest`, `pytest-cov`: Testing framework (dev dependencies)
4547
- Standard library: `argparse`, `colorsys`, `urllib`, etc.
4648
- **Container**: Docker (based on `duffn/python-poetry:3.11-slim`)
4749
- **Platform**: GitHub Actions
4850

51+
## Development Setup
52+
53+
### Building and Testing Locally
54+
55+
1. **Install Poetry** (if not already installed):
56+
```bash
57+
pip install poetry
58+
```
59+
60+
2. **Install project dependencies**:
61+
```bash
62+
cd /path/to/BadgeSort
63+
poetry install
64+
```
65+
66+
3. **Install system dependencies** (for SVG to PNG conversion):
67+
```bash
68+
sudo apt-get install librsvg2-bin # On Ubuntu/Debian
69+
# or
70+
brew install librsvg # On macOS
71+
```
72+
73+
4. **Run tests**:
74+
```bash
75+
poetry run pytest tests/ -v
76+
```
77+
78+
5. **Run tests with coverage**:
79+
```bash
80+
poetry run pytest tests/ --cov=badgesort --cov-report=term-missing
81+
```
82+
83+
6. **Run the CLI**:
84+
```bash
85+
poetry run python -m badgesort.icons -s github python docker
86+
```
87+
4988
## Coding Standards and Conventions
5089

5190
### Commit Message Format (REQUIRED)

badgesort/icons.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626
logging.basicConfig(level=logging.DEBUG)
2727
logger = logging.getLogger(__name__)
2828

29-
def svg_to_base64_data_uri(svg_content, fill_color='white', max_url_length=6000):
29+
def svg_to_base64_data_uri(svg_content, fill_color='white', max_url_length=3700):
3030
"""Convert an SVG to a compressed base64-encoded data URI with specified fill color optimized for 14x14px badges.
3131
3232
Args:
3333
svg_content: SVG content as string
3434
fill_color: Fill color for the SVG paths ('white', 'black', or None)
35-
max_url_length: Maximum URL length before falling back to PNG rasterization
35+
max_url_length: Maximum data URI length before falling back to PNG rasterization.
36+
Default 3700 chars ensures badge URLs stay under GitHub's camo proxy
37+
8192 char limit (which hex-encodes URLs: 76 + url_len*2 <= 8192).
38+
Calculation: safe_url = (8192 - 76) / 2 - overhead ≈ 3850, with 150 char margin = 3700
3639
3740
Uses scour-based SVG compression for optimal file size. For very large SVGs that would exceed
3841
URL length limits, falls back to PNG rasterization at 14x14px.
@@ -421,7 +424,8 @@ def run(args):
421424
if should_embed_svg:
422425
logger.debug(f'Embedding SVG data URI for {icon.slug}')
423426
# Convert SVG to base64 data URI for embedding
424-
icon_data_uri = svg_to_base64_data_uri(icon.svg, icon_hex_comp, max_url_length=6000)
427+
# Use 3700 char limit to stay under GitHub camo's 8192 char limit
428+
icon_data_uri = svg_to_base64_data_uri(icon.svg, icon_hex_comp, max_url_length=3700)
425429
icon_data_uri_encoded = quote(icon_data_uri, safe='')
426430
icon_url = f'{icon_base}/{icon_title_safe}-{badge_color}.svg' if icon_title_safe else f'{icon_base}/-{badge_color}.svg'
427431
icon_url += f'?style={args.badge_style}&logo={icon_data_uri_encoded}'
@@ -445,7 +449,7 @@ def run(args):
445449
background_color = badge_color
446450

447451
# Always use white icons for good contrast against any background
448-
icon_data_uri = svg_to_base64_data_uri(icon.svg, 'white', max_url_length=4000)
452+
icon_data_uri = svg_to_base64_data_uri(icon.svg, 'white', max_url_length=3700)
449453
icon_data_uri_encoded = quote(icon_data_uri, safe='')
450454
icon_url = f'{icon_base}/icon/{icon_title_safe}?icon={icon_data_uri_encoded}&label&color={background_color}&labelColor={background_color}' if icon_title_safe else f'{icon_base}/icon/?icon={icon_data_uri_encoded}&label&color={background_color}&labelColor={background_color}'
451455
else:

tests/test_camo_url_limits.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Tests for GitHub camo URL length limits.
5+
6+
When badges are used in GitHub markdown files, GitHub proxies the image URLs through
7+
camo.githubusercontent.com for security. The camo proxy hex-encodes the original URL,
8+
resulting in: https://camo.githubusercontent.com/<40-char-digest>/<hex-encoded-url>
9+
10+
This approximately doubles the URL length plus 76 chars overhead. The camo service has
11+
an 8192 character limit, so badge URLs must stay under ~4058 chars to work correctly.
12+
"""
13+
14+
import argparse
15+
import tempfile
16+
import os
17+
from badgesort.icons import run, svg_to_base64_data_uri
18+
from simpleicons.all import icons
19+
from urllib.parse import quote
20+
21+
22+
CAMO_URL_LIMIT = 8192
23+
CAMO_OVERHEAD = 76 # base URL (35) + digest (40) + slash (1)
24+
25+
26+
def calculate_camo_url_length(badge_url):
27+
"""Calculate the approximate camo URL length for a badge URL.
28+
29+
GitHub's camo proxy format: https://camo.githubusercontent.com/<digest>/<hex-encoded-url>
30+
"""
31+
return CAMO_OVERHEAD + (len(badge_url.encode('utf-8')) * 2)
32+
33+
34+
def test_svg_data_uri_max_length_default():
35+
"""Test that default max_url_length respects camo limits."""
36+
# The default max_url_length should be 3700 to stay under camo's 8192 limit
37+
# This test verifies the function signature has the correct default
38+
import inspect
39+
sig = inspect.signature(svg_to_base64_data_uri)
40+
max_url_length_default = sig.parameters['max_url_length'].default
41+
42+
assert max_url_length_default == 3700, \
43+
f"Default max_url_length should be 3700 (found {max_url_length_default})"
44+
45+
46+
def test_shields_embedded_svg_urls_under_camo_limit():
47+
"""Test that Shields.io badges with embedded SVGs stay under camo URL limit."""
48+
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
49+
f.write("""# Test File
50+
51+
<!-- start chipwolf/badgesort test -->
52+
<!-- end chipwolf/badgesort test -->
53+
""")
54+
temp_file = f.name
55+
56+
try:
57+
# Test with a few complex icons that have large SVGs
58+
test_slugs = ['github', 'python', 'docker', 'kubernetes', 'amazonaws']
59+
60+
args = argparse.Namespace(
61+
slugs=test_slugs,
62+
random=1,
63+
output=temp_file,
64+
id='test',
65+
format='markdown',
66+
badge_style='for-the-badge',
67+
color_sort='hilbert',
68+
hue_rotate=0,
69+
no_thanks=True,
70+
reverse=False,
71+
provider='shields',
72+
verify=False,
73+
embed_svg=True, # Force SVG embedding to test worst case
74+
skip_logo_check=True
75+
)
76+
77+
run(args)
78+
79+
with open(temp_file, 'r') as f:
80+
result = f.read()
81+
82+
# Extract badge URLs and check their camo lengths
83+
import re
84+
badge_urls = re.findall(r'https://img\.shields\.io/[^\)]+', result)
85+
86+
for url in badge_urls:
87+
camo_length = calculate_camo_url_length(url)
88+
assert camo_length <= CAMO_URL_LIMIT, \
89+
f"Badge URL would exceed camo limit: {camo_length} > {CAMO_URL_LIMIT}\nURL: {url[:100]}..."
90+
91+
print(f"✓ All {len(badge_urls)} badge URLs stay under {CAMO_URL_LIMIT} char camo limit")
92+
93+
finally:
94+
os.unlink(temp_file)
95+
96+
97+
def test_badgen_urls_under_camo_limit():
98+
"""Test that Badgen.net badge URLs stay under camo URL limit."""
99+
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
100+
f.write("""# Test File
101+
102+
<!-- start chipwolf/badgesort test -->
103+
<!-- end chipwolf/badgesort test -->
104+
""")
105+
temp_file = f.name
106+
107+
try:
108+
# Test with a few icons
109+
test_slugs = ['github', 'python', 'docker']
110+
111+
args = argparse.Namespace(
112+
slugs=test_slugs,
113+
random=1,
114+
output=temp_file,
115+
id='test',
116+
format='markdown',
117+
badge_style='flat',
118+
color_sort='hilbert',
119+
hue_rotate=0,
120+
no_thanks=True,
121+
reverse=False,
122+
provider='badgen', # Badgen always embeds SVG
123+
verify=False,
124+
embed_svg=False,
125+
skip_logo_check=True
126+
)
127+
128+
run(args)
129+
130+
with open(temp_file, 'r') as f:
131+
result = f.read()
132+
133+
# Extract badge URLs and check their camo lengths
134+
import re
135+
badge_urls = re.findall(r'https://badgen\.net/[^\)]+', result)
136+
137+
for url in badge_urls:
138+
camo_length = calculate_camo_url_length(url)
139+
assert camo_length <= CAMO_URL_LIMIT, \
140+
f"Badgen URL would exceed camo limit: {camo_length} > {CAMO_URL_LIMIT}\nURL: {url[:100]}..."
141+
142+
print(f"✓ All {len(badge_urls)} Badgen URLs stay under {CAMO_URL_LIMIT} char camo limit")
143+
144+
finally:
145+
os.unlink(temp_file)
146+
147+
148+
def test_data_uri_length_calculation():
149+
"""Test that SVG data URIs are kept under the safe limit."""
150+
# Test with a medium-sized icon
151+
github_icon = icons.get('github')
152+
153+
# Generate data URI with default limit
154+
data_uri = svg_to_base64_data_uri(github_icon.svg, 'white')
155+
156+
# Build a sample badge URL
157+
encoded_uri = quote(data_uri, safe='')
158+
badge_url = f"https://img.shields.io/badge/GitHub-181717.svg?style=for-the-badge&logo={encoded_uri}"
159+
160+
# Calculate camo URL length
161+
camo_length = calculate_camo_url_length(badge_url)
162+
163+
assert camo_length <= CAMO_URL_LIMIT, \
164+
f"Sample badge would exceed camo limit: {camo_length} > {CAMO_URL_LIMIT}"
165+
166+
print(f"✓ Sample badge camo URL: {camo_length} chars (under {CAMO_URL_LIMIT} limit)")
167+
168+
169+
def test_max_url_length_parameter_respected():
170+
"""Test that max_url_length parameter is properly enforced."""
171+
# Get a large icon
172+
large_icons = sorted(icons.items(), key=lambda x: len(x[1].svg), reverse=True)
173+
large_icon = large_icons[0][1]
174+
175+
# Test with a very small limit - should trigger PNG fallback
176+
# (PNG fallback will fail due to missing rsvg-convert, but that's expected)
177+
data_uri = svg_to_base64_data_uri(large_icon.svg, 'white', max_url_length=100)
178+
179+
# The function should either return a short PNG data URI or fall back to the original
180+
# In either case, we're testing that the parameter is being used
181+
assert data_uri.startswith('data:image/'), \
182+
"Should return a valid data URI"
183+
184+
print(f"✓ max_url_length parameter is respected")

0 commit comments

Comments
 (0)