Skip to content

Commit e59da78

Browse files
jawwad-aliclaude
andauthored
fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
* fix(extensions): handle prefix-colliding env vars in _get_env_config _get_env_config built the nested dict with 'if part not in current: current[part] = {}' and an unconditional leaf assignment. Two env vars that collide on a prefix — e.g. SPECKIT_X_CONNECTION and SPECKIT_X_CONNECTION_URL — then either crash (scalar processed first: the walk indexes into a str -> TypeError 'str object does not support item assignment') or silently clobber the nested dict (scalar processed last). Via should_execute_hook's blanket except, the crash silently disables every config-based hook for the extension. Guard the walk and the leaf assignment with isinstance checks so a colliding scalar yields to the nested dict; result is order-independent ({'connection': {'url': ...}} either way), matching _merge_configs' dict-preserving semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(extensions): assert via public should_execute_hook, not private helper Per review: the colliding-env hook test described should_execute_hook swallowing the TypeError, but asserted on the private _evaluate_condition. Assert on the public should_execute_hook instead — it returns False (silently disabled) before the fix and True after, matching the real-world failure mode and not coupling to a private helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extensions): ignore malformed env var names in _get_env_config Per review: a name like SPECKIT_<EXT>_ (no key) or with consecutive underscores produced empty path components, creating surprising entries under an empty key (env_config[''] = ...). Filter out empty parts and skip the variable entirely when nothing remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a10fd2f commit e59da78

2 files changed

Lines changed: 73 additions & 6 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2755,18 +2755,32 @@ def _get_env_config(self) -> Dict[str, Any]:
27552755
if not key.startswith(prefix):
27562756
continue
27572757

2758-
# Remove prefix and split into parts
2759-
config_path = key[len(prefix) :].lower().split("_")
2758+
# Remove prefix and split into parts. Drop empty components from a
2759+
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
2760+
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
2761+
# entry under an empty key.
2762+
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
2763+
if not config_path:
2764+
continue
27602765

2761-
# Build nested dict
2766+
# Build nested dict. Two env vars can collide on a prefix, e.g.
2767+
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
2768+
# walk so a colliding scalar is replaced by a dict (deeper/more
2769+
# specific vars win) instead of being indexed into — which raised
2770+
# TypeError ('str' object does not support item assignment) — and
2771+
# guard the leaf so a scalar processed after the nested var does
2772+
# not clobber the nested dict. Order-independent: both insertion
2773+
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
2774+
# _merge_configs' dict-preserving semantics.
27622775
current = env_config
27632776
for part in config_path[:-1]:
2764-
if part not in current:
2777+
if not isinstance(current.get(part), dict):
27652778
current[part] = {}
27662779
current = current[part]
27672780

2768-
# Set the final value
2769-
current[config_path[-1]] = value
2781+
# Set the final value, unless a nested dict already occupies it.
2782+
if not isinstance(current.get(config_path[-1]), dict):
2783+
current[config_path[-1]] = value
27702784

27712785
return env_config
27722786

tests/test_extensions.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7628,3 +7628,56 @@ def test_hook_condition_returns_false_without_raising(self, tmp_path):
76287628
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
76297629
executor = HookExecutor(tmp_path)
76307630
assert executor._evaluate_condition("config.x is set", "jira") is False
7631+
7632+
7633+
class TestConfigManagerEnvPrefixCollision:
7634+
"""Prefix-colliding env vars must not crash or clobber nested config."""
7635+
7636+
def test_scalar_then_nested_yields_nested(self, tmp_path, monkeypatch):
7637+
"""SPECKIT_X_CONNECTION=x then SPECKIT_X_CONNECTION_URL=y.
7638+
7639+
The scalar-first order previously raised TypeError ('str' object
7640+
does not support item assignment) when the walk indexed into 'x'.
7641+
"""
7642+
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
7643+
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
7644+
cm = ConfigManager(tmp_path, "testext")
7645+
assert cm._get_env_config() == {"connection": {"url": "y"}}
7646+
7647+
def test_nested_then_scalar_does_not_clobber(self, tmp_path, monkeypatch):
7648+
"""Reverse order previously returned {'connection': 'x'}, losing url."""
7649+
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
7650+
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
7651+
cm = ConfigManager(tmp_path, "testext")
7652+
assert cm._get_env_config() == {"connection": {"url": "y"}}
7653+
7654+
def test_colliding_env_does_not_disable_hook_condition(self, tmp_path, monkeypatch):
7655+
"""`config.connection.url is set` must stay True under colliding env.
7656+
7657+
Before the fix the TypeError propagated into should_execute_hook's
7658+
blanket `except Exception: return False`, silently disabling the hook.
7659+
"""
7660+
ext_dir = tmp_path / ".specify" / "extensions" / "testext"
7661+
ext_dir.mkdir(parents=True)
7662+
(ext_dir / "testext-config.yml").write_text(
7663+
"connection:\n url: https://example.com\n", encoding="utf-8"
7664+
)
7665+
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
7666+
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
7667+
executor = HookExecutor(tmp_path)
7668+
# Exercise the public API: before the fix the TypeError was swallowed
7669+
# by should_execute_hook's `except Exception: return False`, so the
7670+
# hook was silently disabled (False); after the fix it returns True.
7671+
assert executor.should_execute_hook(
7672+
{"condition": "config.connection.url is set", "extension": "testext"}
7673+
) is True
7674+
7675+
def test_malformed_env_names_ignored(self, tmp_path, monkeypatch):
7676+
"""A name with no key (SPECKIT_X_) or empty parts (consecutive
7677+
underscores) must not create an entry under an empty key."""
7678+
monkeypatch.setenv("SPECKIT_TESTEXT_", "orphan") # no key at all
7679+
monkeypatch.setenv("SPECKIT_TESTEXT_A__B", "z") # empty middle part
7680+
cm = ConfigManager(tmp_path, "testext")
7681+
cfg = cm._get_env_config()
7682+
assert "" not in cfg
7683+
assert cfg == {"a": {"b": "z"}}

0 commit comments

Comments
 (0)