Skip to content

Commit c4c4bca

Browse files
takemi-ohamaclaude
andcommitted
fix(env): PR #13 round1 codex/gemini 指摘対応
- _import_merge: 未対応 arcname を黙って捨てず MergeError で停止 (filter_members の logger.debug+continue → raise MergeError) - _import_merge: merge 経路で値が変更されていないキーは raw 行を温存し、 PATH=$HOME/bin のような未クオート値が PATH="\$HOME/bin" に勝手に エスケープされて source 時の意味が変わるのを防ぐ - cipher: age-keygen 出力の先頭コメント (# created / # public key) を 考慮し、行単位でコメント / 空行を除いて AGE-SECRET-KEY-1 行を検出 - 上記 3 件に対する回帰テストを追加 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f1fa837 commit c4c4bca

4 files changed

Lines changed: 133 additions & 20 deletions

File tree

lib/devbase/env/_import_merge.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from devbase.errors import DevbaseError
2020
from devbase.log import get_logger
2121

22-
from devbase.env.store import EnvEntry, EnvFile
22+
from devbase.env.store import EnvFile
2323

2424
logger = get_logger(__name__)
2525

@@ -85,9 +85,13 @@ def filter_members(
8585
continue
8686
m = _PROJECT_ENV_RE.match(arcname)
8787
if not m:
88-
# 他の形式は manifest 検証で拒否されているはずだが念のため。
89-
logger.debug("未対応の arcname を無視します: %s", arcname)
90-
continue
88+
# manifest 検証 (bundle._validate_manifest) は path のパターンを制限していないため、
89+
# 未対応 arcname がここに来た場合は黙って捨てると "manifest と適用結果が食い違う"
90+
# 整合性問題になる。明示的にエラーで止める (PR #13 codex 指摘)。
91+
raise MergeError(
92+
f"バンドルに未対応の arcname が含まれています: {arcname} "
93+
"(対応形式: env/global.env / env/sources.yml / env/projects/<name>/.env)"
94+
)
9195
name = m.group(1)
9296
if name in excluded:
9397
continue
@@ -104,24 +108,35 @@ def _merge_into_existing_bytes(existing_bytes: bytes,
104108
既存に無いキーは末尾に sorted 順で append。``merged`` から除外されたキーは
105109
出力からも除外する (現状の merge ロジック上発生しないが、安全側で対応)。
106110
111+
値が変更されていないキーは ``raw`` 行をそのまま温存して出力する。これにより
112+
例えば ``PATH=$HOME/bin`` のような未クオート値が ``PATH="\\$HOME/bin"`` に
113+
勝手にエスケープされて source 時の意味が変わるのを防ぐ (PR #13 codex 指摘)。
114+
値が変わったキーと新規キーのみ ``EnvFile._format_kv_line`` でフォーマットする。
115+
107116
``EnvFile.dump_bytes`` で再シリアライズするとコメント・空行が失われるため、
108117
``EnvFile.parse_entries`` ベースで再構成している (PR #15 gemini 指摘)。
109118
"""
110119
seen: set[str] = set()
111-
out_entries: List[EnvEntry] = []
120+
out_lines: List[str] = []
112121
for e in EnvFile.parse_entries(existing_bytes):
113122
if e.kind != 'kv' or e.key is None:
114-
out_entries.append(e)
123+
out_lines.append(e.raw + '\n')
115124
continue
116125
if e.key in merged:
117-
out_entries.append(EnvEntry(
118-
kind='kv', raw=e.raw, key=e.key, value=merged[e.key]
119-
))
120126
seen.add(e.key)
127+
new_value = merged[e.key]
128+
if e.value == new_value:
129+
# 値が変わっていないキーは元の raw 行を温存する (escape 形式や
130+
# クオート有無を保持して source 時の意味が変わらないように)
131+
out_lines.append(e.raw + '\n')
132+
else:
133+
out_lines.append(
134+
EnvFile._format_kv_line(e.key, new_value)
135+
)
121136
# merged から除外されているキーは entries からも落とす
122137
for key in sorted(k for k in merged if k not in seen):
123-
out_entries.append(EnvEntry(kind='kv', raw='', key=key, value=merged[key]))
124-
return EnvFile.dump_entries_bytes(out_entries)
138+
out_lines.append(EnvFile._format_kv_line(key, merged[key]))
139+
return ''.join(out_lines).encode('utf-8')
125140

126141

127142
def _plan_replace(target: Path, arcname: str, incoming: Dict[str, str],

lib/devbase/env/cipher.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,30 @@ def _resolve_identity(path_spec: str):
105105
f"OpenSSH 秘密鍵の解釈に失敗しました ({path}): {e}"
106106
) from e
107107

108-
if raw.strip().startswith(b'AGE-SECRET-KEY-1'):
109-
try:
110-
text = raw.decode('utf-8').strip()
111-
except UnicodeDecodeError as e:
112-
raise CipherError(f"age 秘密鍵が UTF-8 でデコードできません ({path}): {e}") from e
113-
try:
114-
return pyrage.x25519.Identity.from_str(text)
115-
except Exception as e:
116-
raise CipherError(f"age 秘密鍵の解釈に失敗しました ({path}): {e}") from e
108+
# age-keygen が生成する秘密鍵ファイルは先頭に `# created: ...` などの
109+
# コメント行を含むため、`raw.strip().startswith(b'AGE-SECRET-KEY-1')` では
110+
# 検出できない。`_resolve_recipient` と同様に行単位で走査して、コメント /
111+
# 空行を除いた最初の有効行が AGE-SECRET-KEY-1 で始まるかで判定する
112+
# (PR #13 gemini 指摘)。
113+
try:
114+
text = raw.decode('utf-8')
115+
except UnicodeDecodeError:
116+
text = None
117+
if text is not None:
118+
for line in text.splitlines():
119+
stripped = line.strip()
120+
if not stripped or stripped.startswith('#'):
121+
continue
122+
if stripped.startswith('AGE-SECRET-KEY-1'):
123+
try:
124+
# pyrage.x25519.Identity.from_str は単独の AGE-SECRET-KEY-1
125+
# 行のみを受け付けるため、ファイル全体ではなく該当行を渡す。
126+
return pyrage.x25519.Identity.from_str(stripped)
127+
except Exception as e:
128+
raise CipherError(
129+
f"age 秘密鍵の解釈に失敗しました ({path}): {e}"
130+
) from e
131+
break # 最初の有効行が AGE-SECRET-KEY-1 でなければ age 鍵ではない
117132

118133
# ヘッダから判別できなかった場合のフォールバック。OpenSSH 互換の他形式
119134
# (rsa 以外の PEM など) を pyrage に任せて受け付ける。

tests/cli/test_env_import.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,3 +943,66 @@ def test_env_import_comment_only_existing_replace_reports_op_replace(
943943
assert len(snapshots) >= 1
944944
backed = (snapshots[0] / ".env").read_text()
945945
assert "# user-managed header (no kv yet)" in backed
946+
947+
948+
def test_env_import_merge_preserves_raw_unchanged_unquoted_dollar(
949+
dest_root, age_keys, tmp_path):
950+
"""merge 経路で値が変更されていないキーは ``raw`` 行をそのまま温存し、
951+
``PATH=$HOME/bin`` のような未クオート値が ``PATH="\\$HOME/bin"`` に
952+
勝手にエスケープされないこと (PR #13 codex 指摘)。
953+
954+
シェル ``source`` 時に ``$HOME`` の変数展開が効くか効かないかは
955+
クオートの有無で意味が変わるため、merge 対象でない既存値は元の形式を
956+
保たなければならない。
957+
"""
958+
_, id_file = age_keys
959+
pub_file, _ = age_keys
960+
961+
src_root = tmp_path / "raw-preserve-src"
962+
src_root.mkdir()
963+
# incoming 側には別キーだけ (PATH は触らない)
964+
(src_root / ".env").write_text("INCOMING=v\n")
965+
bundle_path = tmp_path / "raw-preserve.dbenv"
966+
rc = export(src_root, ExportOptions(
967+
dest=str(bundle_path), recipients=[f"@{pub_file}"]))
968+
assert rc == 0
969+
970+
# 既存 dest .env に未クオートの $ を含む値を仕込む (シェルで展開される形)
971+
(dest_root / ".env").write_text(
972+
"PATH=$HOME/bin:/usr/local/bin\n"
973+
"PLAIN=keep_me\n"
974+
)
975+
os.chmod(dest_root / ".env", 0o600)
976+
977+
rc = import_bundle(dest_root, ImportOptions(
978+
source=str(bundle_path), identities=[str(id_file)],
979+
merge='prefer-incoming'))
980+
assert rc == 0
981+
982+
out = (dest_root / ".env").read_text()
983+
# raw 行が温存されているので、$HOME はそのまま (\\$ にエスケープされていない)
984+
assert "PATH=$HOME/bin:/usr/local/bin" in out, out
985+
# 同じく PLAIN もそのまま
986+
assert "PLAIN=keep_me" in out, out
987+
# 新規追加された incoming キーは appended
988+
assert "INCOMING=v" in out, out
989+
990+
991+
def test_env_import_filter_members_rejects_unknown_arcname():
992+
"""``filter_members`` が manifest 範囲外の未対応 arcname を黙って捨てず、
993+
``MergeError`` で明示的に止めること (PR #13 codex 指摘)。
994+
"""
995+
from devbase.env._import_merge import MergeError, filter_members
996+
997+
members = {
998+
'env/global.env': b'GLOBAL=1\n',
999+
'env/secrets.yml': b'secret: x\n', # 未対応 path
1000+
}
1001+
with pytest.raises(MergeError, match="未対応の arcname"):
1002+
filter_members(
1003+
members,
1004+
include_global=True,
1005+
include_metadata=True,
1006+
include_projects=None,
1007+
exclude_projects=(),
1008+
)

tests/env/test_cipher.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,23 @@ def test_default_identity_paths_includes_ed25519():
176176
assert "id_ed25519" in names
177177
assert "id_rsa" in names
178178
assert names.index("id_ed25519") < names.index("id_rsa")
179+
180+
181+
def test_resolve_identity_accepts_age_keygen_output_with_comments(
182+
tmp_path, x25519_keypair):
183+
"""``age-keygen`` が生成する秘密鍵ファイル (先頭に ``# created`` / ``# public key``
184+
のコメント行) を age 鍵として正しく検出して復号できること (PR #13 gemini 指摘)。
185+
"""
186+
pub, priv_str = x25519_keypair
187+
188+
# age-keygen の出力フォーマットを再現
189+
keygen_output = (
190+
f"# created: 2024-01-01T00:00:00Z\n"
191+
f"# public key: {pub}\n"
192+
f"{priv_str}\n"
193+
)
194+
id_path = tmp_path / "age-keygen.key"
195+
id_path.write_text(keygen_output)
196+
197+
blob = cipher.encrypt(b"payload", recipients=[pub])
198+
assert cipher.decrypt(blob, identities=[str(id_path)]) == b"payload"

0 commit comments

Comments
 (0)