Skip to content

Commit e088b4b

Browse files
takemi-ohamaclaude
andcommitted
fix: env 変数展開を $VAR/${VAR} 限定の専用関数にし $ エスケープを尊重
os.path.expandvars は \$ を誤展開し shell source のリテラル $ や EnvFile が書く \$ 付き値を壊すため、$VAR/${VAR} のみ展開し \$ をリテラル $ にデエスケープ、 未定義は空、$(...) は素通しする _expand_env_vars に置換 (cross-review codex major)。 あわせてテストの os.environ 隔離を dict 置換から in-place 退避 (autouse fixture) に 変更し os._Environ の putenv 同期を保つ (cross-review gemini major)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e8d1970 commit e088b4b

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

lib/devbase/commands/container.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ def _env_var_keys(env_file: Path) -> set:
157157
}
158158

159159

160+
# env 値中の変数参照を shell `source ./env` 相当に展開する。
161+
# - `$VAR` / `${VAR}` を environ から展開 (未定義は空文字 = shell source 準拠)
162+
# - `\$` はリテラル `$` にデエスケープ (shell の `\$` と同じ。EnvFile が `$` を
163+
# 保護するため書く `\$` 付き値を壊さない)
164+
# - `$(...)` 等 変数名にならない `$` は素通し (コマンド置換は非対応のまま)
165+
_ENV_VAR_REF = re.compile(r'\\\$|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)')
166+
167+
168+
def _expand_env_vars(value: str, environ) -> str:
169+
def _repl(m):
170+
if m.group(0) == '\\$':
171+
return '$'
172+
name = m.group(1) or m.group(2)
173+
return environ.get(name, '')
174+
return _ENV_VAR_REF.sub(_repl, value)
175+
176+
160177
def _load_project_env(env_file: Path) -> None:
161178
"""プロジェクトの ``env`` ファイルを os.environ へ反映する (wrapper 同等)。
162179
@@ -183,7 +200,7 @@ def _load_project_env(env_file: Path) -> None:
183200
受容する (仕様統一ではなく制約の明示)::
184201
185202
FOO=$(cmd) # shell: コマンド置換 → 本実装: リテラル "$(cmd)"
186-
# (os.path.expandvars は $(...) を変数とみなさない)
203+
# (_expand_env_vars は $(...) を変数とみなさず素通し)
187204
FOO=a"b"c # shell: クォート除去で "abc" → 本実装: 行頭/行末以外の
188205
# クォートは除去せず "a\"b\"c"
189206
FOO=bar # x # shell: インラインコメント無効 (値は "bar # x") →
@@ -214,10 +231,13 @@ def _load_project_env(env_file: Path) -> None:
214231
# 参照しており (行順に os.environ へ載せるため参照時には解決済み)、展開
215232
# しないと TUI (list) 経路でワークスペースパスが未展開のまま開いてしまう。
216233
# 単一引用符はリテラル ($BAR を展開しない) という shell 規則に合わせ、
217-
# `'...'` の場合のみ展開しない。os.path.expandvars は `$(...)` を変数とは
218-
# みなさず素通しするため、コマンド置換は従来どおりリテラルのまま残る。
234+
# `'...'` の場合のみ展開しない。展開は _expand_env_vars に委ね、`$VAR` /
235+
# `${VAR}` のみ展開し (未定義は空文字 = shell source 準拠)、`\$` はリテラル
236+
# `$` にデエスケープする (shell の `\$` と同じ。EnvFile が `$` を保護する
237+
# ため書く `\$` 付き値を壊さない)。`$(...)` 等 変数名にならない `$` は素通し
238+
# するため、コマンド置換は従来どおりリテラルのまま残る。
219239
if not single_quoted:
220-
value = os.path.expandvars(value)
240+
value = _expand_env_vars(value, os.environ)
221241
os.environ[key] = value
222242

223243

tests/cli/test_project_name_resolution.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@
2828
WRAPPER = REPO_ROOT / "bin" / "devbase"
2929

3030

31+
@pytest.fixture(autouse=True)
32+
def _isolate_os_environ():
33+
"""各テストの os.environ 変更を in-place で退避・復元する (後続テストへの漏出防止)。
34+
35+
os.environ を os._Environ のまま扱う (dict で置換しない) ため putenv 同期は保たれ、
36+
subprocess へ環境が伝わらなくなる問題を避ける。
37+
"""
38+
saved = dict(os.environ)
39+
try:
40+
yield
41+
finally:
42+
os.environ.clear()
43+
os.environ.update(saved)
44+
45+
3146
# ===========================================================================
3247
# Python: _resolve_project_name
3348
# ===========================================================================
@@ -195,7 +210,6 @@ def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch):
195210
変数展開 (``$VAR`` / ``${VAR}``) は shell ``source`` 同様にサポートするが、
196211
コマンド置換・行中クォート除去・インラインコメントは解釈しない。この境界を pin する。
197212
"""
198-
monkeypatch.setattr(os, "environ", os.environ.copy())
199213
for k in ("LIT_CMD", "INNER_Q", "INLINE_C"):
200214
monkeypatch.delenv(k, raising=False)
201215
env_path = tmp_path / "env"
@@ -219,7 +233,6 @@ def test_load_project_env_expands_variable_references(tmp_path, monkeypatch):
219233
が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。
220234
単一引用符値はリテラル扱いで展開しないことも併せて pin する。
221235
"""
222-
monkeypatch.setattr(os, "environ", os.environ.copy())
223236
for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"):
224237
monkeypatch.delenv(k, raising=False)
225238
env_path = tmp_path / "env"
@@ -238,6 +251,24 @@ def test_load_project_env_expands_variable_references(tmp_path, monkeypatch):
238251
assert os.environ["SINGLE_Q"] == "/work/$GIT_REPO"
239252

240253

254+
def test_load_project_env_escaped_dollar_and_undefined(tmp_path, monkeypatch):
255+
"""`\\$` はリテラル `$`、未定義参照は空 (shell source 準拠)。$(...) は別テストで担保。"""
256+
for k in ("DEFINED", "ESCAPED", "UNDEF_REF", "NOPE"):
257+
monkeypatch.delenv(k, raising=False)
258+
env_path = tmp_path / "env"
259+
env_path.write_text(
260+
"DEFINED=x\n"
261+
"ESCAPED=a\\$DEFINED\n" # \\$ → リテラル $ (展開しない)
262+
"UNDEF_REF=/p/$NOPE/q\n" # 未定義は空
263+
)
264+
265+
container._load_project_env(env_path)
266+
267+
assert os.environ["DEFINED"] == "x"
268+
assert os.environ["ESCAPED"] == "a$DEFINED"
269+
assert os.environ["UNDEF_REF"] == "/p//q"
270+
271+
241272
# ===========================================================================
242273
# wrapper: cd + argv strip + 存在性ベースの曖昧性回避
243274
# ===========================================================================

0 commit comments

Comments
 (0)