feat(hugo): cover image frontmatter support and image registry (#383)

* feat(hugo): add cover image frontmatter support and image registry Add Hugo/PaperMod cover image support for weekly article templates: - generate_content.py now passes through cover_image, cover_alt, cover_attribution, cover_license, and og_image frontmatter fields - opengraph.html template supports explicit og_image override - Created data/image-registry.json with JSON Schema for tracking locally-hosted images with license, attribution, and provenance - Added scripts/manage_image_registry.py for registry management (add, validate, list) enforcing no-hotlinking policy - Created assets/covers/ directory for local cover image storage - Added tests for cover frontmatter rendering and passthrough The existing article-cover.html partial already handles image resizing (1200x webp), responsive srcsets, and fallback SVG generation. Closes #358 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback on cover/frontmatter PR - Fix str(None) producing literal 'None' in cover_attribution/license - Reject URL-like og_image values to enforce no-hotlinking policy - Resolve og_image as Hugo resource in opengraph template (safe fallback) - Add path safety checks to image registry: reject URLs, absolute paths, and path traversal in both 'add' and 'validate' commands - Add unit tests for manage_image_registry.py path safety enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add coverage for cover null fields and og_image URL rejection Addresses reviewer feedback requesting unit tests for: - YAML null values in cover_attribution/cover_license not emitting 'None' string - URL-like og_image values being silently rejected (no-hotlinking policy) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden local image handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: tighten image asset validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on cover handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 11, 2026 at 23:39 UTC eadf9363db9fad7037e96a909027e59fa7bab2a2
8 files changed +678 -6
assets/covers/.gitkeep new
+1
@@ -0,0 +1 @@
1 +Cover images for weekly articles are stored here.
data/image-registry.json new
+4
@@ -0,0 +1,4 @@
1 +{
2 + "$schema": "./image-registry.schema.json",
3 + "images": []
4 +}
data/image-registry.schema.json new
+59
@@ -0,0 +1,59 @@
1 +{
2 + "$schema": "https://json-schema.org/draft/2020-12/schema",
3 + "title": "SquadScope Image Registry",
4 + "description": "Registry of locally-hosted cover images with license and attribution tracking.",
5 + "type": "object",
6 + "required": ["images"],
7 + "properties": {
8 + "$schema": { "type": "string" },
9 + "images": {
10 + "type": "array",
11 + "items": {
12 + "type": "object",
13 + "required": ["filename", "license", "added_by"],
14 + "properties": {
15 + "filename": {
16 + "type": "string",
17 + "description": "Relative path from repo root (e.g., assets/covers/2026-W24.webp)",
18 + "pattern": "^(?!https?://|//|/)(?!.*\\.\\.)[\\w./-]+$"
19 + },
20 + "source_url": {
21 + "type": "string",
22 + "format": "uri",
23 + "description": "Recommended original source URL where the image was obtained for provenance and license compliance"
24 + },
25 + "license": {
26 + "type": "string",
27 + "enum": ["CC0", "Openverse", "local-asset"],
28 + "description": "License under which the image is used"
29 + },
30 + "attribution": {
31 + "type": "string",
32 + "description": "Attribution text (author, source site)"
33 + },
34 + "added_by": {
35 + "type": "string",
36 + "description": "Who added this image (person or automation identifier)"
37 + },
38 + "added_at": {
39 + "type": "string",
40 + "format": "date",
41 + "description": "ISO date when the image was added"
42 + },
43 + "used_in": {
44 + "type": "array",
45 + "items": { "type": "string" },
46 + "description": "List of content paths using this image (e.g., content/weekly/2026/W24.md)"
47 + },
48 + "dimensions": {
49 + "type": "object",
50 + "properties": {
51 + "width": { "type": "integer" },
52 + "height": { "type": "integer" }
53 + }
54 + }
55 + }
56 + }
57 + }
58 + }
59 +}
layouts/partials/templates/opengraph.html
+13 -3
@@ -35,11 +35,21 @@
35 <meta property="og:type" content="website">
36 {{- end }}
37
38 -{{- if .Params.cover.image -}}
38 +{{- $ogImage := "" -}}
39 +{{- if and .Params.og_image (not (hasPrefix .Params.og_image "http://")) (not (hasPrefix .Params.og_image "https://")) (not (hasPrefix .Params.og_image "//")) -}}
40 + {{- with resources.Get .Params.og_image -}}
41 + {{- $ogImage = .Permalink -}}
42 + {{- end -}}
43 +{{- end -}}
44 +{{- $coverImage := .Params.cover.image -}}
45 +{{- $coverImageIsLocal := and $coverImage (not (hasPrefix $coverImage "http://")) (not (hasPrefix $coverImage "https://")) (not (hasPrefix $coverImage "//")) -}}
46 +{{- if $ogImage -}}
47 + <meta property="og:image" content="{{ $ogImage }}">
48 +{{- else if $coverImageIsLocal -}}
49 {{- if (ne .Params.cover.relative true) }}
40 - <meta property="og:image" content="{{ .Params.cover.image | absURL }}">
50 + <meta property="og:image" content="{{ $coverImage | absURL }}">
51 {{- else}}
42 - <meta property="og:image" content="{{ (path.Join .RelPermalink .Params.cover.image ) | absURL }}">
52 + <meta property="og:image" content="{{ (path.Join .RelPermalink $coverImage ) | absURL }}">
53 {{- end}}
54 {{- else }}
55 {{- with partial "_funcs/get-page-images" . }}
scripts/generate_content.py
+66 -3
@@ -3,6 +3,7 @@ from __future__ import annotations
3 import argparse
4 import csv
5 import re
6 +import warnings
7 from pathlib import Path
8
9 import yaml
@@ -139,6 +140,23 @@ def yaml_quote(value: str) -> str:
140 return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
141
142
143 +def optional_string(value: object) -> str:
144 + return "" if value is None else str(value)
145 +
146 +
147 +def is_local_asset_path(value: object) -> bool:
148 + if not isinstance(value, str) or not value:
149 + return False
150 + if value.startswith(("http://", "https://", "//")):
151 + return False
152 + asset_path = Path(value)
153 + if asset_path.is_absolute():
154 + return False
155 + if ".." in asset_path.parts:
156 + return False
157 + return True
158 +
159 +
160 def render_frontmatter(data: dict[str, object]) -> str:
161 lines = [
162 "---",
@@ -152,9 +170,29 @@ def render_frontmatter(data: dict[str, object]) -> str:
170 f'top_repo: {yaml_quote(str(data["top_repo"]))}',
171 f'summary: {yaml_quote(str(data["summary"]))}',
172 "draft: false",
155 - "---",
156 - "",
173 ]
174 +
175 + # Cover image frontmatter (PaperMod convention)
176 + cover = data.get("cover")
177 + if cover and isinstance(cover, dict):
178 + lines.append("cover:")
179 + if cover.get("image"):
180 + lines.append(f' image: {yaml_quote(str(cover["image"]))}')
181 + if cover.get("alt"):
182 + lines.append(f' alt: {yaml_quote(str(cover["alt"]))}')
183 + if cover.get("caption"):
184 + lines.append(f' caption: {yaml_quote(str(cover["caption"]))}')
185 + if cover.get("attribution"):
186 + lines.append(f' attribution: {yaml_quote(str(cover["attribution"]))}')
187 + if cover.get("license"):
188 + lines.append(f' license: {yaml_quote(str(cover["license"]))}')
189 + lines.append(" relative: false")
190 +
191 + # Explicit OG image override
192 + if data.get("og_image"):
193 + lines.append(f'og_image: {yaml_quote(str(data["og_image"]))}')
194 +
195 + lines.extend(["---", ""])
196 return "\n".join(lines)
197
198
@@ -164,7 +202,7 @@ def transform_summary(frontmatter: dict[str, object], body: str) -> str:
202 if "weekly" not in categories:
203 categories = [*categories, "weekly"]
204
167 - page_frontmatter = {
205 + page_frontmatter: dict[str, object] = {
206 "title": normalize_title(str(frontmatter["title"])),
207 "date": str(frontmatter["date"]),
208 "week": str(frontmatter["week"]),
@@ -175,6 +213,31 @@ def transform_summary(frontmatter: dict[str, object], body: str) -> str:
213 "top_repo": str(frontmatter["top_repo"]),
214 "summary": str(frontmatter["summary"]),
215 }
216 +
217 + cover = frontmatter.get("cover") if isinstance(frontmatter.get("cover"), dict) else {}
218 + cover_image = frontmatter.get("cover_image") or cover.get("image")
219 + if cover_image:
220 + if is_local_asset_path(cover_image):
221 + cover_alt = frontmatter.get("cover_alt") or cover.get("alt")
222 + cover_attribution = frontmatter.get("cover_attribution") or cover.get("attribution")
223 + cover_license = frontmatter.get("cover_license") or cover.get("license")
224 + page_frontmatter["cover"] = {
225 + "image": str(cover_image),
226 + "alt": optional_string(cover_alt),
227 + "attribution": optional_string(cover_attribution),
228 + "license": optional_string(cover_license),
229 + }
230 + else:
231 + warnings.warn(f"Skipping non-local cover image value: {cover_image}", stacklevel=2)
232 +
233 + # Pass through OG image override — reject URLs to enforce no-hotlinking policy
234 + og_image = frontmatter.get("og_image")
235 + if og_image:
236 + if is_local_asset_path(og_image):
237 + page_frontmatter["og_image"] = str(og_image)
238 + else:
239 + warnings.warn(f"Skipping non-local og_image value: {og_image}", stacklevel=2)
240 +
241 return render_frontmatter(page_frontmatter) + "\n" + body.lstrip()
242
243
scripts/manage_image_registry.py new
+176
@@ -0,0 +1,176 @@
1 +#!/usr/bin/env python3
2 +"""Manage the SquadScope image registry (data/image-registry.json).
3 +
4 +Tracks locally-hosted cover images with license, attribution, and usage metadata.
5 +Enforces the no-hotlinking policy: only locally-hosted images are registered.
6 +
7 +Usage:
8 + python scripts/manage_image_registry.py add \\
9 + --filename assets/covers/2026-W24.webp \\
10 + --license CC0 \\
11 + --source-url https://openverse.org/image/abc \\
12 + --attribution "Photo by Author on Openverse" \\
13 + --added-by operator
14 +
15 + python scripts/manage_image_registry.py validate
16 +
17 + python scripts/manage_image_registry.py list
18 +"""
19 +
20 +from __future__ import annotations
21 +
22 +import argparse
23 +import json
24 +import sys
25 +from datetime import date
26 +from pathlib import Path
27 +
28 +REGISTRY_PATH = Path("data/image-registry.json")
29 +ALLOWED_LICENSES = ("CC0", "Openverse", "local-asset")
30 +
31 +
32 +class RegistryError(ValueError):
33 + """Raised when the image registry cannot be loaded safely."""
34 +
35 +
36 +def load_registry(path: Path = REGISTRY_PATH) -> dict:
37 + if not path.exists():
38 + return {"images": []}
39 + try:
40 + registry = json.loads(path.read_text(encoding="utf-8"))
41 + except (json.JSONDecodeError, ValueError) as exc:
42 + raise RegistryError(f"Invalid image registry JSON in {path}: {exc}") from exc
43 + if not isinstance(registry, dict) or not isinstance(registry.get("images"), list):
44 + raise RegistryError(f"Invalid image registry format in {path}: expected an object with an 'images' list.")
45 + return registry
46 +
47 +
48 +def save_registry(registry: dict, path: Path = REGISTRY_PATH) -> None:
49 + path.parent.mkdir(parents=True, exist_ok=True)
50 + path.write_text(json.dumps(registry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
51 +
52 +
53 +def _is_safe_local_path(filename: str) -> tuple[bool, str]:
54 + """Check that filename is a safe local relative path."""
55 + if filename.startswith(("http://", "https://", "//")):
56 + return False, "filename is a URL (no hotlinking allowed)"
57 + if Path(filename).is_absolute():
58 + return False, "filename is an absolute path (must be relative)"
59 + if ".." in Path(filename).parts:
60 + return False, "filename contains path traversal (..)"
61 + return True, ""
62 +
63 +
64 +def add_image(args: argparse.Namespace) -> int:
65 + registry = load_registry()
66 +
67 + # Validate license
68 + if args.license not in ALLOWED_LICENSES:
69 + print(f"ERROR: Invalid license '{args.license}'. Must be one of: {', '.join(ALLOWED_LICENSES)}", file=sys.stderr)
70 + return 1
71 +
72 + # Validate path safety
73 + safe, reason = _is_safe_local_path(args.filename)
74 + if not safe:
75 + print(f"ERROR: {reason}: {args.filename}", file=sys.stderr)
76 + return 1
77 +
78 + # Check file exists locally
79 + if not Path(args.filename).exists():
80 + print(f"WARNING: File '{args.filename}' does not exist locally yet.", file=sys.stderr)
81 +
82 + # Check for duplicates
83 + existing = [img for img in registry["images"] if img["filename"] == args.filename]
84 + if existing:
85 + print(f"ERROR: Image '{args.filename}' already in registry.", file=sys.stderr)
86 + return 1
87 +
88 + entry = {
89 + "filename": args.filename,
90 + "license": args.license,
91 + "added_by": args.added_by,
92 + "added_at": date.today().isoformat(),
93 + }
94 + if args.source_url:
95 + entry["source_url"] = args.source_url
96 + if args.attribution:
97 + entry["attribution"] = args.attribution
98 +
99 + registry["images"].append(entry)
100 + save_registry(registry)
101 + print(f"Added '{args.filename}' to image registry.")
102 + return 0
103 +
104 +
105 +def validate_registry(args: argparse.Namespace) -> int:
106 + registry = load_registry()
107 + errors: list[str] = []
108 +
109 + for i, img in enumerate(registry["images"]):
110 + if not img.get("filename"):
111 + errors.append(f"Entry {i}: missing filename")
112 + if not img.get("license"):
113 + errors.append(f"Entry {i}: missing license")
114 + elif img["license"] not in ALLOWED_LICENSES:
115 + errors.append(f"Entry {i}: invalid license '{img['license']}'")
116 + if not img.get("added_by"):
117 + errors.append(f"Entry {i}: missing added_by")
118 +
119 + # Verify no hotlinking and path safety
120 + filename = img.get("filename", "")
121 + safe, reason = _is_safe_local_path(filename)
122 + if not safe:
123 + errors.append(f"Entry {i}: {reason}: {filename}")
124 +
125 + if errors:
126 + for err in errors:
127 + print(f"FAIL: {err}", file=sys.stderr)
128 + return 1
129 +
130 + print(f"Image registry valid: {len(registry['images'])} entries.")
131 + return 0
132 +
133 +
134 +def list_images(args: argparse.Namespace) -> int:
135 + registry = load_registry()
136 + if not registry["images"]:
137 + print("No images registered.")
138 + return 0
139 + for img in registry["images"]:
140 + license_str = img.get("license", "unknown")
141 + print(f" {img['filename']} [{license_str}] by {img.get('added_by', '?')}")
142 + return 0
143 +
144 +
145 +def main(argv: list[str] | None = None) -> int:
146 + parser = argparse.ArgumentParser(description="Manage SquadScope image registry")
147 + sub = parser.add_subparsers(dest="command")
148 +
149 + add_p = sub.add_parser("add", help="Register a new image")
150 + add_p.add_argument("--filename", required=True, help="Local path to the image file")
151 + add_p.add_argument("--license", required=True, choices=ALLOWED_LICENSES, help="Image license")
152 + add_p.add_argument("--source-url", default="", help="Original source URL")
153 + add_p.add_argument("--attribution", default="", help="Attribution text")
154 + add_p.add_argument("--added-by", required=True, help="Who added this image")
155 +
156 + sub.add_parser("validate", help="Validate the image registry")
157 + sub.add_parser("list", help="List registered images")
158 +
159 + args = parser.parse_args(argv)
160 + try:
161 + if args.command == "add":
162 + return add_image(args)
163 + elif args.command == "validate":
164 + return validate_registry(args)
165 + elif args.command == "list":
166 + return list_images(args)
167 + else:
168 + parser.print_help()
169 + return 1
170 + except RegistryError as exc:
171 + print(f"ERROR: {exc}", file=sys.stderr)
172 + return 1
173 +
174 +
175 +if __name__ == "__main__":
176 + raise SystemExit(main())
tests/test_generate_content.py
+159
@@ -116,6 +116,165 @@ title: \"Week 21, 2026 Analysis\"
116 self.assertIn('"bad: injection"', output)
117 self.assertNotIn("\nbad:", output)
118
119 + def test_render_frontmatter_includes_cover_fields(self) -> None:
120 + """Cover image frontmatter is rendered when present."""
121 + data = {
122 + "title": "Test Week",
123 + "date": "2026-05-18",
124 + "week": "2026-W20",
125 + "tags": ["ai"],
126 + "categories": ["weekly"],
127 + "repos_featured": 1,
128 + "stars_tracked": 100,
129 + "top_repo": "owner/repo",
130 + "summary": "Test.",
131 + "cover": {
132 + "image": "covers/2026-W20.webp",
133 + "alt": "AI trends visualization",
134 + "attribution": "Photo by Author on Openverse",
135 + "license": "CC0",
136 + },
137 + "og_image": "covers/2026-W20-og.png",
138 + }
139 + output = generate_content.render_frontmatter(data)
140 + self.assertIn("cover:", output)
141 + self.assertIn('image: "covers/2026-W20.webp"', output)
142 + self.assertIn('alt: "AI trends visualization"', output)
143 + self.assertIn('attribution: "Photo by Author on Openverse"', output)
144 + self.assertIn('license: "CC0"', output)
145 + self.assertIn("relative: false", output)
146 + self.assertIn('og_image: "covers/2026-W20-og.png"', output)
147 +
148 + def test_transform_summary_passes_cover_fields(self) -> None:
149 + """Cover fields from analysis frontmatter are passed to output."""
150 + doc = """---
151 +title: "Week 20 Analysis"
152 +date: 2026-05-11
153 +week: "2026-W20"
154 +year: 2026
155 +tags: [ai]
156 +categories: [weekly]
157 +repos_featured: 5
158 +stars_tracked: 1000
159 +top_repo: "owner/repo"
160 +quality_score: 90
161 +summary: "Test summary."
162 +cover_image: "covers/test.webp"
163 +cover_alt: "Test alt text"
164 +cover_attribution: "Test Author"
165 +cover_license: "CC0"
166 +---
167 +
168 +Body content.
169 +"""
170 + frontmatter, body = generate_content.parse_frontmatter(doc)
171 + output = generate_content.transform_summary(frontmatter, body)
172 + self.assertIn('image: "covers/test.webp"', output)
173 + self.assertIn('alt: "Test alt text"', output)
174 + self.assertIn("relative: false", output)
175 +
176 + def test_transform_summary_null_cover_attribution_does_not_emit_none(self) -> None:
177 + """YAML null in cover_attribution/cover_license must not produce 'None' string."""
178 + doc = """---
179 +title: "Week 20 Analysis"
180 +date: 2026-05-11
181 +week: "2026-W20"
182 +year: 2026
183 +tags: [ai]
184 +categories: [weekly]
185 +repos_featured: 5
186 +stars_tracked: 1000
187 +top_repo: "owner/repo"
188 +quality_score: 90
189 +summary: "Test summary."
190 +cover_image: "covers/test.webp"
191 +cover_attribution: null
192 +cover_license: null
193 +---
194 +
195 +Body.
196 +"""
197 + frontmatter, body = generate_content.parse_frontmatter(doc)
198 + output = generate_content.transform_summary(frontmatter, body)
199 + self.assertNotIn("None", output)
200 + self.assertIn('image: "covers/test.webp"', output)
201 +
202 + def test_transform_summary_rejects_url_og_image(self) -> None:
203 + """og_image values that are URLs must be silently dropped (no hotlinking)."""
204 + doc = """---
205 +title: "Week 20 Analysis"
206 +date: 2026-05-11
207 +week: "2026-W20"
208 +year: 2026
209 +tags: [ai]
210 +categories: [weekly]
211 +repos_featured: 5
212 +stars_tracked: 1000
213 +top_repo: "owner/repo"
214 +quality_score: 90
215 +summary: "Test summary."
216 +og_image: "https://evil.com/image.png"
217 +---
218 +
219 +Body.
220 +"""
221 + frontmatter, body = generate_content.parse_frontmatter(doc)
222 + output = generate_content.transform_summary(frontmatter, body)
223 + self.assertNotIn("og_image", output)
224 + self.assertNotIn("evil.com", output)
225 +
226 + def test_transform_summary_rejects_url_cover_image(self) -> None:
227 + """cover_image values that are URLs must be dropped."""
228 + doc = """---
229 +title: "Week 20 Analysis"
230 +date: 2026-05-11
231 +week: "2026-W20"
232 +year: 2026
233 +tags: [ai]
234 +categories: [weekly]
235 +repos_featured: 5
236 +stars_tracked: 1000
237 +top_repo: "owner/repo"
238 +quality_score: 90
239 +summary: "Test summary."
240 +cover_image: "https://evil.com/cover.png"
241 +---
242 +
243 +Body.
244 +"""
245 + frontmatter, body = generate_content.parse_frontmatter(doc)
246 + output = generate_content.transform_summary(frontmatter, body)
247 + self.assertNotIn("cover:", output)
248 + self.assertNotIn("evil.com", output)
249 +
250 + def test_transform_summary_falls_back_to_cover_block_attribution_and_license(self) -> None:
251 + doc = """---
252 +title: "Week 20 Analysis"
253 +date: 2026-05-11
254 +week: "2026-W20"
255 +year: 2026
256 +tags: [ai]
257 +categories: [weekly]
258 +repos_featured: 5
259 +stars_tracked: 1000
260 +top_repo: "owner/repo"
261 +quality_score: 90
262 +summary: "Test summary."
263 +cover:
264 + image: "covers/test.webp"
265 + alt: "Test alt text"
266 + attribution: "Existing Author"
267 + license: "Openverse"
268 +---
269 +
270 +Body.
271 +"""
272 + frontmatter, body = generate_content.parse_frontmatter(doc)
273 + output = generate_content.transform_summary(frontmatter, body)
274 + self.assertIn('image: "covers/test.webp"', output)
275 + self.assertIn('attribution: "Existing Author"', output)
276 + self.assertIn('license: "Openverse"', output)
277 +
278
279 if __name__ == "__main__":
280 unittest.main()
tests/test_manage_image_registry.py new
+200
@@ -0,0 +1,200 @@
1 +"""Tests for scripts/manage_image_registry.py."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +import os
7 +from pathlib import Path
8 +from unittest.mock import patch
9 +
10 +import scripts.manage_image_registry as registry_mod
11 +
12 +
13 +def _tmp_registry(tmp_path: Path, images: list | None = None) -> Path:
14 + """Create a temporary registry file."""
15 + reg_path = tmp_path / "image-registry.json"
16 + reg_path.write_text(json.dumps({"images": images or []}, indent=2), encoding="utf-8")
17 + return reg_path
18 +
19 +
20 +def _run_with_registry(tmp_path: Path, images: list | None, argv: list[str]) -> int:
21 + """Run the CLI with a patched REGISTRY_PATH via monkeypatching load/save defaults."""
22 + reg_path = _tmp_registry(tmp_path, images)
23 + # Patch load_registry and save_registry to use our temp path
24 + orig_load = registry_mod.load_registry
25 + orig_save = registry_mod.save_registry
26 +
27 + def patched_load(path: Path = reg_path) -> dict:
28 + return orig_load(path)
29 +
30 + def patched_save(registry: dict, path: Path = reg_path) -> None:
31 + return orig_save(registry, path)
32 +
33 + with patch.object(registry_mod, "load_registry", patched_load), \
34 + patch.object(registry_mod, "save_registry", patched_save):
35 + return registry_mod.main(argv)
36 +
37 +
38 +class TestPathSafety:
39 + def test_rejects_http_url(self) -> None:
40 + safe, reason = registry_mod._is_safe_local_path("https://evil.com/img.png")
41 + assert not safe
42 + assert "URL" in reason
43 +
44 + def test_rejects_protocol_relative_url(self) -> None:
45 + safe, reason = registry_mod._is_safe_local_path("//evil.com/img.png")
46 + assert not safe
47 + assert "URL" in reason
48 +
49 + def test_rejects_absolute_path(self) -> None:
50 + safe, reason = registry_mod._is_safe_local_path("/etc/passwd")
51 + assert not safe
52 + assert "absolute" in reason
53 +
54 + def test_rejects_path_traversal(self) -> None:
55 + safe, reason = registry_mod._is_safe_local_path("assets/../../../etc/passwd")
56 + assert not safe
57 + assert "traversal" in reason
58 +
59 + def test_accepts_relative_path(self) -> None:
60 + safe, reason = registry_mod._is_safe_local_path("assets/covers/2026-W24.webp")
61 + assert safe
62 + assert reason == ""
63 +
64 +
65 +class TestAddCommand:
66 + def test_rejects_url_filename(self, tmp_path: Path) -> None:
67 + rc = _run_with_registry(tmp_path, [], [
68 + "add",
69 + "--filename", "https://example.com/image.png",
70 + "--license", "CC0",
71 + "--added-by", "test",
72 + ])
73 + assert rc == 1
74 +
75 + def test_rejects_traversal_filename(self, tmp_path: Path) -> None:
76 + rc = _run_with_registry(tmp_path, [], [
77 + "add",
78 + "--filename", "assets/../../etc/shadow",
79 + "--license", "CC0",
80 + "--added-by", "test",
81 + ])
82 + assert rc == 1
83 +
84 + def test_adds_valid_image(self, tmp_path: Path) -> None:
85 + # Create a relative-path image file
86 + img_rel = "assets/covers/test.webp"
87 + img_abs = tmp_path / img_rel
88 + img_abs.parent.mkdir(parents=True)
89 + img_abs.write_bytes(b"fake image")
90 + # Run from tmp_path so relative path resolves
91 + old_cwd = os.getcwd()
92 + os.chdir(tmp_path)
93 + try:
94 + rc = _run_with_registry(tmp_path, [], [
95 + "add",
96 + "--filename", img_rel,
97 + "--license", "CC0",
98 + "--added-by", "test",
99 + "--source-url", "https://example.com/source",
100 + "--attribution", "Test Author",
101 + ])
102 + finally:
103 + os.chdir(old_cwd)
104 + assert rc == 0
105 + reg_path = tmp_path / "image-registry.json"
106 + data = json.loads(reg_path.read_text())
107 + assert len(data["images"]) == 1
108 + assert data["images"][0]["license"] == "CC0"
109 + assert data["images"][0]["source_url"] == "https://example.com/source"
110 +
111 + def test_rejects_duplicate(self, tmp_path: Path) -> None:
112 + rc = _run_with_registry(
113 + tmp_path,
114 + [{"filename": "assets/x.webp", "license": "CC0", "added_by": "op"}],
115 + ["add", "--filename", "assets/x.webp", "--license", "CC0", "--added-by", "test"],
116 + )
117 + assert rc == 1
118 +
119 +
120 +class TestValidateCommand:
121 + def test_valid_registry_passes(self, tmp_path: Path) -> None:
122 + rc = _run_with_registry(tmp_path, [
123 + {"filename": "assets/covers/img.webp", "license": "CC0", "added_by": "op"},
124 + ], ["validate"])
125 + assert rc == 0
126 +
127 + def test_detects_url_filename(self, tmp_path: Path) -> None:
128 + rc = _run_with_registry(tmp_path, [
129 + {"filename": "https://evil.com/x.png", "license": "CC0", "added_by": "op"},
130 + ], ["validate"])
131 + assert rc == 1
132 +
133 + def test_detects_absolute_path(self, tmp_path: Path) -> None:
134 + rc = _run_with_registry(tmp_path, [
135 + {"filename": "/etc/passwd", "license": "CC0", "added_by": "op"},
136 + ], ["validate"])
137 + assert rc == 1
138 +
139 + def test_detects_traversal_path(self, tmp_path: Path) -> None:
140 + rc = _run_with_registry(tmp_path, [
141 + {"filename": "assets/../../../etc/shadow", "license": "CC0", "added_by": "op"},
142 + ], ["validate"])
143 + assert rc == 1
144 +
145 + def test_detects_missing_license(self, tmp_path: Path) -> None:
146 + rc = _run_with_registry(tmp_path, [
147 + {"filename": "assets/x.webp", "added_by": "op"},
148 + ], ["validate"])
149 + assert rc == 1
150 +
151 +
152 +class TestRegistryLoading:
153 + def test_load_registry_rejects_invalid_json(self, tmp_path: Path) -> None:
154 + reg_path = tmp_path / "image-registry.json"
155 + reg_path.write_text("{not-json", encoding="utf-8")
156 +
157 + try:
158 + registry_mod.load_registry(reg_path)
159 + except registry_mod.RegistryError as exc:
160 + assert "Invalid image registry JSON" in str(exc)
161 + else:
162 + raise AssertionError("Expected RegistryError for invalid JSON")
163 +
164 + def test_load_registry_rejects_non_list_images_shape(self, tmp_path: Path) -> None:
165 + reg_path = tmp_path / "image-registry.json"
166 + reg_path.write_text(json.dumps({"images": {}}), encoding="utf-8")
167 +
168 + try:
169 + registry_mod.load_registry(reg_path)
170 + except registry_mod.RegistryError as exc:
171 + assert "expected an object with an 'images' list" in str(exc)
172 + else:
173 + raise AssertionError("Expected RegistryError for invalid registry shape")
174 +
175 + def test_main_reports_registry_load_errors_cleanly(self, tmp_path: Path, capsys) -> None:
176 + reg_path = tmp_path / "image-registry.json"
177 + reg_path.write_text("{not-json", encoding="utf-8")
178 + orig_load = registry_mod.load_registry
179 +
180 + def patched_load(path: Path = reg_path) -> dict:
181 + return orig_load(path)
182 +
183 + with patch.object(registry_mod, "load_registry", patched_load):
184 + rc = registry_mod.main(["list"])
185 +
186 + captured = capsys.readouterr()
187 + assert rc == 1
188 + assert "ERROR: Invalid image registry JSON" in captured.err
189 +
190 +
191 +class TestListCommand:
192 + def test_lists_registered_images(self, tmp_path: Path, capsys) -> None:
193 + rc = _run_with_registry(tmp_path, [
194 + {"filename": "assets/covers/img.webp", "license": "CC0", "added_by": "op"},
195 + ], ["list"])
196 + captured = capsys.readouterr()
197 + assert rc == 0
198 + assert "assets/covers/img.webp" in captured.out
199 + assert "[CC0]" in captured.out
200 + assert "by op" in captured.out