33import datetime as dt
44import hashlib
55import json
6+ import re
67from collections .abc import Mapping , Sequence
78from pathlib import Path
89from typing import Any
@@ -161,6 +162,7 @@ def validate_latest_signal(
161162 payload : Mapping [str , Any ],
162163 * ,
163164 theme_artifact_path : str | Path | None = None ,
165+ signal_base_dir : str | Path | None = None ,
164166) -> None :
165167 """Validate a v2 signal against its declared theme artifact linkage."""
166168 validate_signal (payload )
@@ -173,8 +175,18 @@ def validate_latest_signal(
173175 if len (theme_sources ) != 1 :
174176 raise SignalValidationError ("latest signal must declare exactly one theme source" )
175177 declared_source = theme_sources [0 ]
176- declared_path = Path (declared_source ).resolve ()
177- artifact_path = Path (theme_artifact_path ).resolve () if theme_artifact_path else declared_path
178+ base_dir = Path (signal_base_dir ).resolve () if signal_base_dir else None
179+
180+ def resolve_source (value : str | Path ) -> Path :
181+ path = Path (value )
182+ if path .is_absolute ():
183+ return path .resolve ()
184+ if base_dir is None :
185+ raise SignalValidationError ("relative theme source requires signal_base_dir" )
186+ return (base_dir / path ).resolve ()
187+
188+ declared_path = resolve_source (declared_source )
189+ artifact_path = resolve_source (theme_artifact_path ) if theme_artifact_path else declared_path
178190 if artifact_path != declared_path :
179191 raise SignalValidationError ("theme artifact override must match the declared theme source" )
180192 if not artifact_path .exists ():
@@ -185,11 +197,21 @@ def validate_latest_signal(
185197 source_hashes = {}
186198 if not isinstance (source_hashes , Mapping ):
187199 raise SignalValidationError ("evidence.source_hashes must be an object" )
188- expected_hash = source_hashes .get (declared_source ) or source_hashes .get (str (declared_path ))
189- if expected_hash is not None :
190- _require_string (expected_hash , "evidence.source_hashes value" )
191- digest = hashlib .sha256 (artifact_path .read_bytes ()).hexdigest ()
192- if digest != expected_hash :
200+ if declared_source in source_hashes :
201+ expected_hash = source_hashes [declared_source ]
202+ elif str (declared_path ) in source_hashes :
203+ expected_hash = source_hashes [str (declared_path )]
204+ else :
205+ expected_hash = None
206+ if expected_hash is not None or declared_source in source_hashes or str (declared_path ) in source_hashes :
207+ expected_hash = _require_string (expected_hash , "evidence.source_hashes value" )
208+ if not re .fullmatch (r"[0-9a-fA-F]{64}" , expected_hash ):
209+ raise SignalValidationError ("evidence.source_hashes value must be a SHA-256 hex digest" )
210+ try :
211+ digest = hashlib .sha256 (artifact_path .read_bytes ()).hexdigest ()
212+ except OSError as exc :
213+ raise SignalValidationError (f"declared theme artifact unreadable: { artifact_path } " ) from exc
214+ if digest != expected_hash .lower ():
193215 raise SignalValidationError ("declared theme artifact sha256 does not match" )
194216
195217 try :
0 commit comments