From 279ef73802bf44cf5f78f24c44667855b1d98269 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:21:25 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20PLAN32-config-loader=20Draft=20PR?= =?UTF-8?q?=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 0b5c68555f1d352251ba803ccaf3d1a058d69f1a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:25:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(project):=20project.yml=20=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=80=E3=81=A8=20clone=20=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=81=AE=20wire=20format=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN32 Task 1。projects//project.yml を読み、defaults 継承・既定値 補完・検証を経て正規化した ProjectConfig を返すライブラリを追加した。 コンテナへは base64 TSV の clone プランとして渡す契約 (encode/decode) も ここで確定させる。呼び出し元の差し替えは後続 PR で行うため、この PR 単体 では挙動は変わらない。 未知キー・dir 重複・primary 複数・空白混入は黙って通さずエラーにする。 project.yml が無い場合も旧 env 形式へフォールバックせず、移行コマンドを 案内するエラーにした (PLAN32 は後方互換を持たないため、移行漏れを検出 できる必要がある)。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/__init__.py | 10 + lib/devbase/project/config.py | 349 ++++++++++++++++++++++++++++++++ tests/project/__init__.py | 0 tests/project/test_config.py | 327 ++++++++++++++++++++++++++++++ 4 files changed, 686 insertions(+) create mode 100644 lib/devbase/project/__init__.py create mode 100644 lib/devbase/project/config.py create mode 100644 tests/project/__init__.py create mode 100644 tests/project/test_config.py diff --git a/lib/devbase/project/__init__.py b/lib/devbase/project/__init__.py new file mode 100644 index 0000000..bc07db6 --- /dev/null +++ b/lib/devbase/project/__init__.py @@ -0,0 +1,10 @@ +"""プロジェクト設定 (``projects//project.yml``) の読み込み。""" + +from .config import ( # noqa: F401 + ProjectConfig, + RepoSpec, + decode_repo_plan, + encode_repo_plan, + load_project_config, + parse_project_config, +) diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py new file mode 100644 index 0000000..abf1d10 --- /dev/null +++ b/lib/devbase/project/config.py @@ -0,0 +1,349 @@ +"""``projects//project.yml`` の読み込み・正規化・検証 (PLAN32)。 + +1 プロジェクト = 1 コンテナ = **複数リポジトリ**構成の設定ファイルを扱う。 +人間が編集する正は YAML であり、コンテナへは正規化した「clone プラン」を +base64 TSV (:func:`encode_repo_plan`) にして渡す。YAML の解釈をホスト側の +Python に閉じ込めることで、entrypoint (bash) は ``base64 -d`` と ``while read`` +だけで済み、コンテナイメージへ YAML パーサ依存を持ち込まずに済む。 + +スキーマ:: + + version: 1 # 必須 + scale: 1 # 任意。旧 CONTAINER_SCALE + open_editor: true # 任意。旧 DEVBASE_OPEN_EDITOR + work_dir: /work/carmo # 任意。既定は primary repo の /work/ + defaults: # 任意。repos の各要素へ継承させる既定値 + host: github.com + owner: volareinc + repos: + - repo: carmo # 必須 + primary: true # 任意。未指定なら先頭要素が primary + - repo: carmo-batch + dir: batch # 任意。/work 配下の clone 先名 (既定 repo 名) + branch: develop # 任意。clone 後に checkout + init: false # 任意 (既定 true)。clone 後の ./init.sh 実行有無 + +旧方式 (``env`` の ``GIT_USER`` / ``GIT_REPO``) への後方互換は持たない。 +``project.yml`` が無いプロジェクトは移行手順を案内して :class:`ConfigError` +を送出する (黙って単一 repo として動かすと、移行漏れが検出できないため)。 +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import ConfigError + +#: 設定ファイル名 (プロジェクトディレクトリ直下) +PROJECT_CONFIG_FILENAME = "project.yml" + +#: 対応するスキーマ版 +SUPPORTED_VERSION = 1 + +_TOP_LEVEL_KEYS = frozenset( + {"version", "scale", "open_editor", "work_dir", "defaults", "repos"}) +_REPO_KEYS = frozenset({"host", "owner", "repo", "dir", "branch", "init", "primary"}) +#: ``defaults`` に書けるのは repo ごとに異なるとは限らない項目だけ。 +#: ``dir`` / ``primary`` は repo 固有 (継承すると必ず重複・複数 primary になる)。 +_DEFAULTS_KEYS = frozenset({"host", "owner", "branch", "init"}) + +_DEFAULT_HOST = "github.com" + +_WIRE_FIELD_SEPARATOR = "\t" + + +@dataclass(frozen=True) +class RepoSpec: + """正規化済みの 1 リポジトリ分の clone 指定。""" + + host: str + owner: str + repo: str + dir: str + branch: Optional[str] + init: bool + primary: bool + + @property + def url(self) -> str: + """clone 先 URL。認証は既存の git 資格情報機構に委ねる (URL に含めない)。""" + return f"https://{self.host}/{self.owner}/{self.repo}.git" + + +@dataclass(frozen=True) +class RepoPlanEntry: + """wire format を復号した 1 行分 (entrypoint が受け取る情報と同じ)。""" + + url: str + dir: str + branch: Optional[str] + init: bool + + +@dataclass(frozen=True) +class ProjectConfig: + """``project.yml`` 1 ファイル分の正規化済み設定。""" + + version: int + repos: Tuple[RepoSpec, ...] + scale: Optional[int] = None + open_editor: Optional[bool] = None + work_dir: Optional[str] = None + + @property + def primary(self) -> RepoSpec: + """``cd`` 先・エディタの既定フォルダになる repo (常にちょうど 1 件)。""" + return next(repo for repo in self.repos if repo.primary) + + def resolved_work_dir(self) -> str: + """コンテナ内で開く既定フォルダ。明示指定が無ければ primary repo の dir。""" + return self.work_dir or f"/work/{self.primary.dir}" + + +# --------------------------------------------------------------------------- +# 読み込み +# --------------------------------------------------------------------------- + +def config_path(project_dir: Path) -> Path: + """プロジェクトディレクトリ内の ``project.yml`` のパス。""" + return Path(project_dir) / PROJECT_CONFIG_FILENAME + + +def load_project_config(project_dir: Path) -> ProjectConfig: + """``/project.yml`` を読み込む。 + + Raises: + ConfigError: ファイルが無い / YAML が壊れている / スキーマ違反。 + 旧 ``env`` 形式へのフォールバックはしない (PLAN32 は後方互換なし)。 + """ + path = config_path(project_dir) + if not path.is_file(): + raise ConfigError( + f"{path} がありません。PLAN32 以降、プロジェクトのリポジトリ構成は " + f"{PROJECT_CONFIG_FILENAME} で指定します。" + "旧 env 形式 (GIT_USER / GIT_REPO) からの移行は " + "`devbase project migrate-config` を実行してください。" + ) + + try: + raw = yaml.safe_load(path.read_text()) + except OSError as e: + raise ConfigError(f"{path} を読み込めません: {e}") from e + except yaml.YAMLError as e: + raise ConfigError(f"{path} の YAML を解釈できません: {e}") from e + + if raw is None: + raise ConfigError(f"{path} が空です。version と repos が必要です。") + if not isinstance(raw, Mapping): + raise ConfigError(f"{path} の最上位はマッピングである必要があります。") + + return parse_project_config(raw, source=str(path)) + + +def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: + """読み込み済みのマッピングを正規化・検証する (I/O を伴わない)。 + + Args: + data: YAML を読み込んだマッピング + source: エラーメッセージに出す出所 (ファイルパス等) + """ + _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") + + version = data.get("version") + if version != SUPPORTED_VERSION: + raise ConfigError( + f"{source}: version は {SUPPORTED_VERSION} である必要があります " + f"(現在: {version!r})") + + defaults = data.get("defaults") or {} + if not isinstance(defaults, Mapping): + raise ConfigError(f"{source}: defaults はマッピングである必要があります。") + _reject_unknown_keys(defaults, _DEFAULTS_KEYS, source, "defaults") + + raw_repos = data.get("repos") + if not isinstance(raw_repos, Sequence) or isinstance(raw_repos, (str, bytes)): + raise ConfigError(f"{source}: repos はリストである必要があります。") + if not raw_repos: + raise ConfigError(f"{source}: repos が空です。1 件以上指定してください。") + + repos = [_parse_repo(entry, defaults, source, i) + for i, entry in enumerate(raw_repos)] + _validate_dirs(repos, source) + repos = _assign_primary(repos, source) + + return ProjectConfig( + version=SUPPORTED_VERSION, + repos=tuple(repos), + scale=_parse_scale(data.get("scale"), source), + open_editor=_parse_open_editor(data.get("open_editor"), source), + work_dir=_parse_work_dir(data.get("work_dir"), source), + ) + + +# --------------------------------------------------------------------------- +# wire format (entrypoint との契約) +# --------------------------------------------------------------------------- + +def encode_repo_plan(repos: Iterable[RepoSpec]) -> str: + """clone プランを base64 TSV へ符号化する。 + + 1 行 1 repo で ``urldirbranchinit`` (``init`` は ``1``/``0``、 + ``branch`` 未指定は空文字)。base64 にするのは、compose の変数展開 (``$``) や + 改行を含む値で構成ファイルが壊れないようにするため。primary は列に含めず + ``DEVBASE_PRIMARY_DIR`` で別に渡す (entrypoint の ``cd`` 先判定を単純に保つ)。 + """ + lines = [ + _WIRE_FIELD_SEPARATOR.join( + [repo.url, repo.dir, repo.branch or "", "1" if repo.init else "0"]) + for repo in repos + ] + return base64.b64encode("\n".join(lines).encode()).decode() + + +def decode_repo_plan(encoded: str) -> Tuple[RepoPlanEntry, ...]: + """:func:`encode_repo_plan` の逆変換 (契約テストと診断用)。""" + try: + text = base64.b64decode(encoded, validate=True).decode() + except (ValueError, UnicodeDecodeError) as e: + raise ConfigError(f"clone プランを復号できません: {e}") from e + + entries = [] + for line in text.splitlines(): + if not line: + continue + fields = line.split(_WIRE_FIELD_SEPARATOR) + if len(fields) != 4: + raise ConfigError(f"clone プランの列数が不正です: {line!r}") + url, directory, branch, init = fields + entries.append(RepoPlanEntry( + url=url, dir=directory, branch=branch or None, init=init == "1")) + return tuple(entries) + + +# --------------------------------------------------------------------------- +# 内部: 検証 +# --------------------------------------------------------------------------- + +def _reject_unknown_keys(data: Mapping[str, Any], allowed: frozenset, + source: str, where: str) -> None: + """未知キーは黙って無視せずエラーにする (typo が設定漏れとして表れないように)。""" + unknown = sorted(str(key) for key in data if key not in allowed) + if unknown: + raise ConfigError( + f"{source}: {where}に未知のキーがあります: {', '.join(unknown)} " + f"(使えるキー: {', '.join(sorted(allowed))})") + + +def _parse_repo(entry: Any, defaults: Mapping[str, Any], source: str, + index: int) -> RepoSpec: + where = f"repos[{index}]" + if not isinstance(entry, Mapping): + raise ConfigError(f"{source}: {where} はマッピングである必要があります。") + _reject_unknown_keys(entry, _REPO_KEYS, source, where) + + merged = {**defaults, **entry} + + repo = _require_token(merged.get("repo"), "repo", source, where, + allow_slash=False) + owner = _require_token(merged.get("owner"), "owner", source, where, + allow_slash=True) + host = _require_token(merged.get("host", _DEFAULT_HOST), "host", source, where, + allow_slash=False) + + directory = merged.get("dir", repo) + directory = _require_token(directory, "dir", source, where, allow_slash=False) + if directory in (".", ".."): + raise ConfigError( + f"{source}: {where} の dir は /work 直下の名前である必要があります " + f"({directory!r})") + + branch = merged.get("branch") + if branch is not None: + branch = _require_token(branch, "branch", source, where, allow_slash=True) + + init = merged.get("init", True) + if not isinstance(init, bool): + raise ConfigError(f"{source}: {where} の init は真偽値です ({init!r})") + + primary = entry.get("primary", False) + if not isinstance(primary, bool): + raise ConfigError(f"{source}: {where} の primary は真偽値です ({primary!r})") + + return RepoSpec(host=host, owner=owner, repo=repo, dir=directory, + branch=branch, init=init, primary=primary) + + +def _require_token(value: Any, field: str, source: str, where: str, + allow_slash: bool) -> str: + """URL 組み立てと TSV を壊さない文字列であることを確かめる。 + + 空白・タブ・改行は wire format (タブ区切り・行区切り) を壊し、``/`` は + ``https:////.git`` の構造や ``/work/`` の階層を + 壊すため、項目ごとに許可を分ける (gitlab のサブグループやブランチ名の + ``feature/x`` は ``/`` を含むため許可する)。 + """ + if not isinstance(value, str) or not value: + raise ConfigError(f"{source}: {where} の {field} は必須です ({value!r})") + if any(c.isspace() for c in value): + raise ConfigError( + f"{source}: {where} の {field} に空白文字は使えません ({value!r})") + if not allow_slash and "/" in value: + raise ConfigError( + f"{source}: {where} の {field} に / は使えません ({value!r})") + if allow_slash and (value.startswith("/") or value.endswith("/")): + raise ConfigError( + f"{source}: {where} の {field} は / で始まる・終わることはできません " + f"({value!r})") + return value + + +def _validate_dirs(repos: Sequence[RepoSpec], source: str) -> None: + """同じ ``/work/`` を 2 つの repo が奪い合わないこと。""" + seen = set() + for repo in repos: + if repo.dir in seen: + raise ConfigError( + f"{source}: clone 先の dir が重複しています: {repo.dir!r}") + seen.add(repo.dir) + + +def _assign_primary(repos: Sequence[RepoSpec], source: str) -> list: + """primary をちょうど 1 件に確定する (未指定なら先頭)。""" + explicit = [repo for repo in repos if repo.primary] + if len(explicit) > 1: + names = ", ".join(repo.dir for repo in explicit) + raise ConfigError( + f"{source}: primary: true は 1 件だけ指定できます ({names})") + if explicit: + return list(repos) + first, *rest = repos + return [replace(first, primary=True), *rest] + + +def _parse_scale(value: Any, source: str) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ConfigError(f"{source}: scale は 1 以上の整数です ({value!r})") + return value + + +def _parse_open_editor(value: Any, source: str) -> Optional[bool]: + if value is None: + return None + if not isinstance(value, bool): + raise ConfigError(f"{source}: open_editor は真偽値です ({value!r})") + return value + + +def _parse_work_dir(value: Any, source: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{source}: work_dir は文字列です ({value!r})") + return value.strip() diff --git a/tests/project/__init__.py b/tests/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/project/test_config.py b/tests/project/test_config.py new file mode 100644 index 0000000..a924e80 --- /dev/null +++ b/tests/project/test_config.py @@ -0,0 +1,327 @@ +"""project.yml の読み込み・正規化・検証と clone プランの wire format""" + +from __future__ import annotations + +import base64 + +import pytest + +from devbase.errors import ConfigError +from devbase.project.config import ( + decode_repo_plan, + encode_repo_plan, + load_project_config, + parse_project_config, +) + + +def write_project_yml(tmp_path, text: str): + (tmp_path / "project.yml").write_text(text) + return tmp_path + + +# --------------------------------------------------------------------------- +# 正常系 +# --------------------------------------------------------------------------- + +def test_single_repo_defaults(): + """最小構成: host は github.com、dir は repo 名、init は有効、先頭が primary""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + (repo,) = config.repos + assert repo.host == "github.com" + assert repo.owner == "volareinc" + assert repo.repo == "carmo" + assert repo.dir == "carmo" + assert repo.branch is None + assert repo.init is True + assert repo.primary is True + assert repo.url == "https://github.com/volareinc/carmo.git" + assert config.primary is repo + + +def test_defaults_are_inherited_and_overridable(): + config = parse_project_config({ + "version": 1, + "defaults": {"host": "github.com", "owner": "uttaro-dev2"}, + "repos": [ + {"repo": "uttarov2", "host": "gitlab.com", "owner": "uttaro_dev", "dir": "system"}, + {"repo": "uttarov2-doc"}, + {"repo": "uttarov2migration", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + system, doc, migration = config.repos + assert system.url == "https://gitlab.com/uttaro_dev/uttarov2.git" + assert system.dir == "system" + assert doc.url == "https://github.com/uttaro-dev2/uttarov2-doc.git" + assert doc.dir == "uttarov2-doc" + assert migration.branch == "develop" + assert migration.init is False + + +def test_primary_can_be_chosen_explicitly(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}], + }, source="project.yml") + + assert config.primary.repo == "carmo" + assert [r.primary for r in config.repos] == [False, True] + + +def test_optional_settings_are_read(): + config = parse_project_config({ + "version": 1, + "scale": 3, + "open_editor": False, + "work_dir": "/work/carmo/app", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.scale == 3 + assert config.open_editor is False + assert config.work_dir == "/work/carmo/app" + + +def test_optional_settings_default_to_none(): + """未指定の設定は None。既定値の解釈は呼び出し側 (env / 既定値) に委ねる""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.scale is None + assert config.open_editor is None + assert config.work_dir is None + + +def test_work_dir_defaults_to_primary_repo_dir(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}], + }, source="project.yml") + + assert config.resolved_work_dir() == "/work/carmo" + + +def test_resolved_work_dir_prefers_explicit_value(): + config = parse_project_config({ + "version": 1, + "work_dir": "/work/carmo/app", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.resolved_work_dir() == "/work/carmo/app" + + +def test_load_project_config_reads_file(tmp_path): + write_project_yml(tmp_path, """ +version: 1 +scale: 1 +defaults: + owner: KK-Generation +repos: + - repo: project-trygroup-prd + - repo: project-trygroup-prd-customer +""") + + config = load_project_config(tmp_path) + + assert config.scale == 1 + assert [r.dir for r in config.repos] == [ + "project-trygroup-prd", "project-trygroup-prd-customer"] + + +# --------------------------------------------------------------------------- +# 異常系 (後方互換は無いので、曖昧な設定は黙って通さない) +# --------------------------------------------------------------------------- + +def test_missing_file_is_an_error_with_migration_hint(tmp_path): + (tmp_path / "env").write_text("GIT_USER=volareinc\nGIT_REPO=carmo\n") + + with pytest.raises(ConfigError) as excinfo: + load_project_config(tmp_path) + + message = str(excinfo.value) + assert "project.yml" in message + assert "migrate-config" in message + + +def test_missing_owner_is_an_error(): + with pytest.raises(ConfigError, match="owner"): + parse_project_config({"version": 1, "repos": [{"repo": "carmo"}]}, + source="project.yml") + + +def test_missing_repo_is_an_error(): + with pytest.raises(ConfigError, match="repo"): + parse_project_config({"version": 1, "repos": [{"owner": "volareinc"}]}, + source="project.yml") + + +def test_duplicated_dir_is_an_error(): + with pytest.raises(ConfigError, match="dir"): + parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch", "dir": "carmo"}], + }, source="project.yml") + + +def test_multiple_primary_is_an_error(): + with pytest.raises(ConfigError, match="primary"): + parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo", "primary": True}, + {"repo": "carmo-batch", "primary": True}], + }, source="project.yml") + + +def test_empty_repos_is_an_error(): + with pytest.raises(ConfigError, match="repos"): + parse_project_config({"version": 1, "repos": []}, source="project.yml") + + +def test_unknown_key_is_an_error(): + """typo を黙って無視しない""" + with pytest.raises(ConfigError, match="brunch"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "brunch": "main"}], + }, source="project.yml") + + +def test_unknown_top_level_key_is_an_error(): + with pytest.raises(ConfigError, match="container_scale"): + parse_project_config({ + "version": 1, + "container_scale": 2, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_unsupported_version_is_an_error(): + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "version": 2, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_missing_version_is_an_error(): + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +@pytest.mark.parametrize("bad_dir", ["../escape", "nested/dir", ".", "..", "/abs"]) +def test_dir_must_stay_directly_under_work(bad_dir): + """/work の外へ抜ける dir を許すと clone 先が予測できなくなる""" + with pytest.raises(ConfigError, match="dir"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "dir": bad_dir}], + }, source="project.yml") + + +@pytest.mark.parametrize("field,value", [ + ("owner", "vola reinc"), + ("repo", "car\tmo"), + ("host", "github.com/extra"), + ("branch", "main\nrm -rf"), +]) +def test_fields_reject_whitespace_and_separators(field, value): + """wire format (タブ区切り) と URL 組み立てを壊す値を弾く""" + spec = {"owner": "volareinc", "repo": "carmo"} + spec[field] = value + with pytest.raises(ConfigError, match=field): + parse_project_config({"version": 1, "repos": [spec]}, source="project.yml") + + +def test_scale_must_be_a_positive_integer(): + with pytest.raises(ConfigError, match="scale"): + parse_project_config({ + "version": 1, "scale": 0, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_open_editor_must_be_boolean(): + with pytest.raises(ConfigError, match="open_editor"): + parse_project_config({ + "version": 1, "open_editor": "yes", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_broken_yaml_is_an_error(tmp_path): + write_project_yml(tmp_path, "version: 1\nrepos: [") + + with pytest.raises(ConfigError, match="project.yml"): + load_project_config(tmp_path) + + +def test_yaml_root_must_be_a_mapping(tmp_path): + write_project_yml(tmp_path, "- repo: carmo") + + with pytest.raises(ConfigError, match="project.yml"): + load_project_config(tmp_path) + + +# --------------------------------------------------------------------------- +# wire format (entrypoint との契約) +# --------------------------------------------------------------------------- + +def test_encode_repo_plan_is_base64_tsv(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "uttaro-dev2"}, + "repos": [ + {"repo": "uttarov2", "host": "gitlab.com", "owner": "uttaro_dev", "dir": "system"}, + {"repo": "uttarov2-doc", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + encoded = encode_repo_plan(config.repos) + decoded = base64.b64decode(encoded).decode() + + assert decoded.splitlines() == [ + "https://gitlab.com/uttaro_dev/uttarov2.git\tsystem\t\t1", + "https://github.com/uttaro-dev2/uttarov2-doc.git\tuttarov2-doc\tdevelop\t0", + ] + + +def test_repo_plan_round_trips(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch", "branch": "main"}], + }, source="project.yml") + + restored = decode_repo_plan(encode_repo_plan(config.repos)) + + assert [(e.url, e.dir, e.branch, e.init) for e in restored] == [ + ("https://github.com/volareinc/carmo.git", "carmo", None, True), + ("https://github.com/volareinc/carmo-batch.git", "carmo-batch", "main", True), + ] + + +def test_encoded_plan_has_no_shell_or_compose_hazards(): + """compose の変数展開・改行で壊れないこと (base64 なので英数と = のみ)""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + encoded = encode_repo_plan(config.repos) + + assert encoded.strip() == encoded + assert all(c.isalnum() or c in "+/=" for c in encoded) From 7a33c33a9dcff60f4934e174152adb734ae6aad4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:31:42 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(project):=20YAML=20=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=82=92=E5=8E=B3=E5=AF=86=E5=8C=96=E3=81=97?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=20UTF-8=20=E3=81=A7=E8=AA=AD=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version: YAML の `true` が Python では `1` と等価なため bool を明示的に拒否 - defaults: `or {}` をやめ、null/未指定のみ空マッピングへ正規化 (`defaults: []` / `false` が黙って受理され型検証が素通りするのを防ぐ) - project.yml の読み込みに encoding="utf-8" を明示し、UnicodeDecodeError は 「UTF-8 で保存してください」と案内する ConfigError に変換 - テストの write_text にも encoding="utf-8" を明示 + 上記の回帰テストを追加 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/config.py | 13 ++++++++++--- tests/project/test_config.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py index abf1d10..75ac6aa 100644 --- a/lib/devbase/project/config.py +++ b/lib/devbase/project/config.py @@ -131,7 +131,11 @@ def load_project_config(project_dir: Path) -> ProjectConfig: ) try: - raw = yaml.safe_load(path.read_text()) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except UnicodeDecodeError as e: + raise ConfigError( + f"{path} を UTF-8 として読めません ({e})。" + f"{PROJECT_CONFIG_FILENAME} は UTF-8 で保存してください。") from e except OSError as e: raise ConfigError(f"{path} を読み込めません: {e}") from e except yaml.YAMLError as e: @@ -155,12 +159,15 @@ def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") version = data.get("version") - if version != SUPPORTED_VERSION: + # YAML の ``true`` は Python では ``1`` と等価なので bool を明示的に弾く。 + if isinstance(version, bool) or version != SUPPORTED_VERSION: raise ConfigError( f"{source}: version は {SUPPORTED_VERSION} である必要があります " f"(現在: {version!r})") - defaults = data.get("defaults") or {} + defaults = data.get("defaults") + if defaults is None: + defaults = {} if not isinstance(defaults, Mapping): raise ConfigError(f"{source}: defaults はマッピングである必要があります。") _reject_unknown_keys(defaults, _DEFAULTS_KEYS, source, "defaults") diff --git a/tests/project/test_config.py b/tests/project/test_config.py index a924e80..3d17299 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -16,7 +16,7 @@ def write_project_yml(tmp_path, text: str): - (tmp_path / "project.yml").write_text(text) + (tmp_path / "project.yml").write_text(text, encoding="utf-8") return tmp_path @@ -222,6 +222,38 @@ def test_missing_version_is_an_error(): }, source="project.yml") +@pytest.mark.parametrize("bad_version", [True, False]) +def test_boolean_version_is_an_error(bad_version): + """YAML の ``true`` は Python では ``1`` と等価なため明示的に弾く""" + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "version": bad_version, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +@pytest.mark.parametrize("bad_defaults", [[], False, 0, "host", ["host"]]) +def test_non_mapping_defaults_is_an_error(bad_defaults): + """falsy な非マッピングを空マッピング扱いで黙って受理しない""" + with pytest.raises(ConfigError, match="defaults"): + parse_project_config({ + "version": 1, + "defaults": bad_defaults, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_null_defaults_is_treated_as_empty(): + """``defaults:`` と書いただけ (null) は未指定と同じ扱い""" + config = parse_project_config({ + "version": 1, + "defaults": None, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.repos[0].host == "github.com" + + @pytest.mark.parametrize("bad_dir", ["../escape", "nested/dir", ".", "..", "/abs"]) def test_dir_must_stay_directly_under_work(bad_dir): """/work の外へ抜ける dir を許すと clone 先が予測できなくなる""" From b4fa7766e0d18e95a34071e6cc3092109c57fc83 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:39:05 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(project):=20version=20=E3=81=AE?= =?UTF-8?q?=E5=9E=8B=E5=88=A4=E5=AE=9A=E3=82=92=E5=8E=B3=E5=AF=86=E5=8C=96?= =?UTF-8?q?=E3=81=97=20wire=20format=20=E3=81=AE=20init=20=E5=88=97?= =?UTF-8?q?=E3=82=92=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version は `type(...) is int` で判定。YAML の `1.0` は float だが `1.0 == 1` を満たすため、これまで schema version 1 として受理されていた - decode_repo_plan で init 列が `1`/`0` 以外なら ConfigError。壊れた値や将来の 未知値を「init しない」として黙って通さない - 非 UTF-8 (cp932) の project.yml が UTF-8 保存の案内を出すことをテストで固定 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/config.py | 12 ++++++++++-- tests/project/test_config.py | 25 ++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py index 75ac6aa..0954e5f 100644 --- a/lib/devbase/project/config.py +++ b/lib/devbase/project/config.py @@ -159,8 +159,11 @@ def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") version = data.get("version") - # YAML の ``true`` は Python では ``1`` と等価なので bool を明示的に弾く。 - if isinstance(version, bool) or version != SUPPORTED_VERSION: + # YAML では ``true`` が ``1``、``1.0`` が float として読まれ、どちらも + # ``== 1`` を満たしてしまう。整数のスキーマ版という契約を保つため、値の + # 一致だけでなく型そのものを厳密に見る (``type(...) is int`` は bool を + # 部分型として受理しない)。 + if type(version) is not int or version != SUPPORTED_VERSION: raise ConfigError( f"{source}: version は {SUPPORTED_VERSION} である必要があります " f"(現在: {version!r})") @@ -227,6 +230,11 @@ def decode_repo_plan(encoded: str) -> Tuple[RepoPlanEntry, ...]: if len(fields) != 4: raise ConfigError(f"clone プランの列数が不正です: {line!r}") url, directory, branch, init = fields + # init 列は wire format 上 ``1``/``0`` だけ。それ以外を False へ丸めると + # 壊れた値や将来の未知値が「init しない」として黙って通ってしまう。 + if init not in ("0", "1"): + raise ConfigError( + f"clone プランの init 列は 1 か 0 である必要があります: {line!r}") entries.append(RepoPlanEntry( url=url, dir=directory, branch=branch or None, init=init == "1")) return tuple(entries) diff --git a/tests/project/test_config.py b/tests/project/test_config.py index 3d17299..ea763ad 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -222,9 +222,9 @@ def test_missing_version_is_an_error(): }, source="project.yml") -@pytest.mark.parametrize("bad_version", [True, False]) -def test_boolean_version_is_an_error(bad_version): - """YAML の ``true`` は Python では ``1`` と等価なため明示的に弾く""" +@pytest.mark.parametrize("bad_version", [True, False, 1.0, "1"]) +def test_non_integer_version_is_an_error(bad_version): + """YAML の ``true`` / ``1.0`` は ``== 1`` を満たすため型で明示的に弾く""" with pytest.raises(ConfigError, match="version"): parse_project_config({ "version": bad_version, @@ -301,6 +301,15 @@ def test_broken_yaml_is_an_error(tmp_path): load_project_config(tmp_path) +def test_non_utf8_file_is_an_error_with_encoding_hint(tmp_path): + """Shift-JIS 等で保存されたファイルは UTF-8 での保存を案内する""" + (tmp_path / "project.yml").write_bytes( + "version: 1 # 日本語コメント\n".encode("cp932")) + + with pytest.raises(ConfigError, match="UTF-8"): + load_project_config(tmp_path) + + def test_yaml_root_must_be_a_mapping(tmp_path): write_project_yml(tmp_path, "- repo: carmo") @@ -346,6 +355,16 @@ def test_repo_plan_round_trips(): ] +@pytest.mark.parametrize("bad_init", ["", "2", "true", "0 "]) +def test_decode_rejects_init_column_outside_one_and_zero(bad_init): + """壊れた値・将来の未知値を「init しない」として黙って通さない""" + line = f"https://github.com/volareinc/carmo.git\tcarmo\t\t{bad_init}" + encoded = base64.b64encode(line.encode()).decode() + + with pytest.raises(ConfigError, match="init"): + decode_repo_plan(encoded) + + def test_encoded_plan_has_no_shell_or_compose_hazards(): """compose の変数展開・改行で壊れないこと (base64 なので英数と = のみ)""" config = parse_project_config({ From bc946888fb1906bfba7ea546484c1f01c22218ca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:49:26 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(project):=20wire=20format=20=E3=81=AE?= =?UTF-8?q?=E5=8C=BA=E5=88=87=E3=82=8A=E3=82=92=20US=20=E3=81=AB=E3=81=97?= =?UTF-8?q?=E6=9C=AB=E5=B0=BE=20LF=20=E3=82=92=E4=BB=98=E3=81=91=E3=81=A6?= =?UTF-8?q?=20bash=20=E5=A5=91=E7=B4=84=E3=82=92=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タブ区切りは bash の既定 IFS と同じ空白類のため、`IFS=$'\t' read -r url dir branch init` では連続区切りが 1 つに畳まれ、branch 未指定 (空フィールド) の行で init の値が branch にずれ込んでいた。区切りを US (\x1f) に変えると空白類ではない ため空フィールドが保持され、bash 側の素直な読み方がそのまま正しく動く。 あわせて符号化結果に末尾 LF を付ける。`while read` は EOF 直前の改行なし行を 読み捨てるため、末尾 LF が無いと repo 1 件構成で唯一の行が丸ごと落ちていた。 契約 (区切りは \x1f / 行区切りは LF で末尾にもあり / 各フィールドは検証済みで 空白・制御文字を含まない) を docstring と PLAN32 の wire format 節に明記し、 実際の bash を `bash -c` で起動して読ませる回帰テストを追加した (Docker 不要)。 また YAML が int として読む `repo: 123` に「必須です」と出て紛らわしかったため、 未指定と型不一致でエラーメッセージを分けた。 Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN32_multi-repo-project.md | 32 ++++++-- lib/devbase/project/config.py | 61 +++++++++++---- tests/project/test_config.py | 111 ++++++++++++++++++++++++++-- 3 files changed, 177 insertions(+), 27 deletions(-) diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index 8f4cbd4..1ca9df7 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -73,8 +73,9 @@ | --- | --- | --- | --- | | 設定ファイル名 `project.yml` (repos + devbase 設定を集約) | `repos` に加え `scale` / `open_editor` / `work_dir` を持つ | **採用** | issue の「`CONTAINER_SCALE` は yaml が相応しい」に沿う。設定の置き場所が 1 つに決まり「どっちに書くか」の迷いが消える | | 設定ファイル名 `repos.yml` (repo 定義のみ) | scale 等は `env` に残す | 不採用 | devbase 設定とコンテナ環境変数が `env` に同居したままで、issue の振り分け要求を半分しか満たさない | -| repo リストの transport: **base64 TSV** を `DEVBASE_REPOS` で渡す | `url\tdir\tbranch\tinit` の行を base64 化 | **採用** | entrypoint 側が `base64 -d` + `while read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | -| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。TSV で足りる | +| repo リストの transport: **base64 の US 区切りテキスト** を `DEVBASE_REPOS` で渡す | `url\x1fdir\x1fbranch\x1finit` の行を base64 化 | **採用** | entrypoint 側が `base64 -d` + `while read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | +| 区切り文字: タブ | `url\tdir\tbranch\tinit` | 不採用 | タブは bash の既定 `IFS` と同じ空白類で、`IFS=$'\t' read` では連続区切りが 1 つに畳まれる。branch 未指定 (空フィールド) の行で init の値が branch にずれ込む | +| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。US 区切りで足りる | | transport: `project.yml` を bind mount して entrypoint で解釈 | コンテナ内で YAML を読む | 不採用 | project ディレクトリは現在マウントしていない。マウント経路の追加とコンテナ内 YAML パーサ依存の 2 つを同時に増やす | | workspace ファイル: **host で JSON を組み立て base64 で渡し entrypoint が書き出す** | `DEVBASE_WORKSPACE_B64` | **採用** | 生成ロジックを Python 側 (テスト可能) に置ける。entrypoint は `base64 -d > file` の 1 行 | | workspace ファイル: entrypoint で shell 組み立て | printf で JSON を書く | 不採用 | テストできない場所にエスケープ処理を置くことになる | @@ -88,7 +89,7 @@ | project | `projects//` 1 ディレクトリ = 1 compose プロジェクト = 1 dev コンテナ (群) | | repo | project が `/work` 配下へ clone する git リポジトリ。今回から**複数** | | primary repo | ログイン時の `cd` 先、および repo 1 件時にエディタが開くフォルダ。既定は `repos` の先頭 | -| clone プラン | `project.yml` を正規化した内部表現。base64 TSV で `DEVBASE_REPOS` としてコンテナへ渡る | +| clone プラン | `project.yml` を正規化した内部表現。base64 の US 区切りテキストで `DEVBASE_REPOS` としてコンテナへ渡る | | plugin repo | project 定義を配布するリポジトリ (`volareinc/devbase-ext` 等)。`projects/*` はここへの symlink | ## 不変条件 @@ -139,15 +140,30 @@ repos: - `dir` 重複はエラー。`primary: true` が 2 件以上はエラー。`repos` が空はエラー。 - 未知キーはエラー (typo を黙って無視しない)。 -wire format (`DEVBASE_REPOS`, base64 TSV / 1 行 1 repo, タブ区切り): +wire format (`DEVBASE_REPOS`, base64 / 1 行 1 repo, US (`\x1f`) 区切り): ``` -https://github.com/volareinc/carmo.gitcarmomain1 -https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 +https://github.com/volareinc/carmo.gitcarmomain1 +https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 ``` 列: `url`, `dir`, `branch` (空可), `init` (`1`/`0`)。primary は別変数 `DEVBASE_PRIMARY_DIR` で渡す -(TSV の列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 +(列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 + +entrypoint との契約: + +- フィールド区切りは US (`\x1f`)。**タブは使わない** — bash の既定 `IFS` と同じ空白類のため + 連続区切りが畳まれ、branch 未指定の行で init が branch にずれ込む。 +- 行区切りは LF で、**末尾にも LF を付ける**。`while read` は EOF 直前の改行なし行を読み捨てる + ため、末尾 LF が無いと repo 1 件構成でその唯一の行が丸ごと落ちる。 +- 各フィールドは host 側で検証済みで、空白・制御文字を含まない (エスケープ不要)。 + +```bash +printf '%s' "$DEVBASE_REPOS" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + ... + done +``` ## 修正対象 @@ -179,7 +195,7 @@ plugin リポジトリ (別 PR): - **対象ファイル:** `lib/devbase/project/config.py`, `tests/project/test_config.py` - **変更内容:** `ProjectConfig` / `RepoSpec` dataclass、YAML 読み込み・`defaults` 継承・正規化・検証、 - `encode_repo_plan()` / `decode_repo_plan()` (base64 TSV)。`project.yml` 不在・不正時は移行手順を含む + `encode_repo_plan()` / `decode_repo_plan()` (base64 / US 区切り)。`project.yml` 不在・不正時は移行手順を含む `ConfigError` を送出。この PR では**呼び出し元を差し替えない** (挙動変更なし)。 - **満たす受け入れ条件:** AC6 の一部 (エラー文言)、AC2/AC3 のデータ表現 - **進め方:** テスト駆動。正常系 (defaults 継承 / dir 明示 / host 混在 / primary 指定) と diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py index 0954e5f..f9bb4ff 100644 --- a/lib/devbase/project/config.py +++ b/lib/devbase/project/config.py @@ -2,7 +2,7 @@ 1 プロジェクト = 1 コンテナ = **複数リポジトリ**構成の設定ファイルを扱う。 人間が編集する正は YAML であり、コンテナへは正規化した「clone プラン」を -base64 TSV (:func:`encode_repo_plan`) にして渡す。YAML の解釈をホスト側の +base64 テキスト (:func:`encode_repo_plan`) にして渡す。YAML の解釈をホスト側の Python に閉じ込めることで、entrypoint (bash) は ``base64 -d`` と ``while read`` だけで済み、コンテナイメージへ YAML パーサ依存を持ち込まずに済む。 @@ -54,7 +54,11 @@ _DEFAULT_HOST = "github.com" -_WIRE_FIELD_SEPARATOR = "\t" +#: wire format のフィールド区切り。US (unit separator, ``\x1f``) を使う。 +#: タブは bash の既定 ``IFS`` と同じ空白類に分類され、``IFS=$'\t' read`` では +#: 連続する区切りが 1 つに畳まれてしまうため、空フィールド (branch 未指定) が +#: 消えて以降の列がずれる。US は空白類ではないので空フィールドが保持される。 +_WIRE_FIELD_SEPARATOR = "\x1f" @dataclass(frozen=True) @@ -200,23 +204,46 @@ def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: # --------------------------------------------------------------------------- def encode_repo_plan(repos: Iterable[RepoSpec]) -> str: - """clone プランを base64 TSV へ符号化する。 - - 1 行 1 repo で ``urldirbranchinit`` (``init`` は ``1``/``0``、 - ``branch`` 未指定は空文字)。base64 にするのは、compose の変数展開 (``$``) や - 改行を含む値で構成ファイルが壊れないようにするため。primary は列に含めず - ``DEVBASE_PRIMARY_DIR`` で別に渡す (entrypoint の ``cd`` 先判定を単純に保つ)。 + """clone プランを base64 テキストへ符号化する。 + + entrypoint (bash) との契約: + + - 1 行 1 repo で ``urldirbranchinit``。```` は unit separator + (``\x1f``)、``init`` は ``1``/``0``、``branch`` 未指定は**空フィールド**。 + - フィールド区切りが空白類 (タブ) ではないため、``IFS=$'\x1f' read -r url + dir branch init`` で空フィールドが畳まれず、素直に 4 列として読める。 + - 行区切りは LF。**末尾にも LF を付ける**。``while read`` は EOF 直前の + 改行なし行を読み捨てる実装があるため、末尾 LF が無いと最後の行 (repo が + 1 件ならその唯一の行) が丸ごと落ちる。 + - 各フィールドは :func:`_require_token` で検証済みで、空白・制御文字 + (タブ・改行・US を含む) を一切含まない。よって区切り文字とフィールド値が + 衝突することはなく、エスケープも不要。 + + base64 にするのは、compose の変数展開 (``$``) や改行を含む値で構成ファイルが + 壊れないようにするため。primary は列に含めず ``DEVBASE_PRIMARY_DIR`` で別に + 渡す (entrypoint の ``cd`` 先判定を単純に保つ)。 + + 典型的な consumer:: + + printf '%s' "$DEVBASE_REPOS" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + ... + done """ lines = [ _WIRE_FIELD_SEPARATOR.join( [repo.url, repo.dir, repo.branch or "", "1" if repo.init else "0"]) for repo in repos ] - return base64.b64encode("\n".join(lines).encode()).decode() + text = "".join(f"{line}\n" for line in lines) + return base64.b64encode(text.encode()).decode() def decode_repo_plan(encoded: str) -> Tuple[RepoPlanEntry, ...]: - """:func:`encode_repo_plan` の逆変換 (契約テストと診断用)。""" + """:func:`encode_repo_plan` の逆変換 (契約テストと診断用)。 + + 末尾 LF や空行は無視するので、末尾 LF の有無は round trip に影響しない。 + """ try: text = base64.b64decode(encoded, validate=True).decode() except (ValueError, UnicodeDecodeError) as e: @@ -295,15 +322,21 @@ def _parse_repo(entry: Any, defaults: Mapping[str, Any], source: str, def _require_token(value: Any, field: str, source: str, where: str, allow_slash: bool) -> str: - """URL 組み立てと TSV を壊さない文字列であることを確かめる。 + """URL 組み立てと wire format を壊さない文字列であることを確かめる。 - 空白・タブ・改行は wire format (タブ区切り・行区切り) を壊し、``/`` は + 空白・タブ・改行は wire format (行区切り) を壊し、``/`` は ``https:////.git`` の構造や ``/work/`` の階層を 壊すため、項目ごとに許可を分ける (gitlab のサブグループやブランチ名の ``feature/x`` は ``/`` を含むため許可する)。 """ - if not isinstance(value, str) or not value: - raise ConfigError(f"{source}: {where} の {field} は必須です ({value!r})") + # YAML は ``repo: 123`` を int として読むため、「未指定」と「型が違う」を + # 同じ "必須です" で片づけると「指定したのに必須と言われる」ことになる。 + if value is None or value == "": + raise ConfigError(f"{source}: {where} の {field} は必須です") + if not isinstance(value, str): + raise ConfigError( + f"{source}: {where} の {field} は文字列で指定してください " + f"({value!r})") if any(c.isspace() for c in value): raise ConfigError( f"{source}: {where} の {field} に空白文字は使えません ({value!r})") diff --git a/tests/project/test_config.py b/tests/project/test_config.py index ea763ad..bf6ba66 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -3,6 +3,8 @@ from __future__ import annotations import base64 +import shutil +import subprocess import pytest @@ -321,7 +323,35 @@ def test_yaml_root_must_be_a_mapping(tmp_path): # wire format (entrypoint との契約) # --------------------------------------------------------------------------- -def test_encode_repo_plan_is_base64_tsv(): +#: wire format のフィールド区切り (unit separator)。 +US = "\x1f" + +#: entrypoint (bash) が想定する読み取り方をそのまま再現する consumer。 +#: 素朴な ``while read`` で 4 列が欠けずに読めることを、実際の bash で確かめる。 +_BASH_CONSUMER = r""" +set -eu +printf '%s' "$PLAN" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + printf '[%s][%s][%s][%s]\n' "$url" "$dir" "$branch" "$init" + done +""" + + +def run_bash_consumer(encoded: str) -> list: + """符号化済み clone プランを bash の ``while read`` で読ませて行を返す。""" + result = subprocess.run( + ["bash", "-c", _BASH_CONSUMER], + env={"PLAN": encoded, "PATH": "/usr/bin:/bin:/usr/local/bin"}, + capture_output=True, text=True, check=True, + ) + return result.stdout.splitlines() + + +requires_bash = pytest.mark.skipif( + shutil.which("bash") is None, reason="bash が無い環境ではスキップ") + + +def test_encode_repo_plan_is_base64_unit_separated(): config = parse_project_config({ "version": 1, "defaults": {"owner": "uttaro-dev2"}, @@ -334,12 +364,62 @@ def test_encode_repo_plan_is_base64_tsv(): encoded = encode_repo_plan(config.repos) decoded = base64.b64decode(encoded).decode() - assert decoded.splitlines() == [ - "https://gitlab.com/uttaro_dev/uttarov2.git\tsystem\t\t1", - "https://github.com/uttaro-dev2/uttarov2-doc.git\tuttarov2-doc\tdevelop\t0", + assert decoded == ( + f"https://gitlab.com/uttaro_dev/uttarov2.git{US}system{US}{US}1\n" + f"https://github.com/uttaro-dev2/uttarov2-doc.git{US}uttarov2-doc" + f"{US}develop{US}0\n" + ) + + +def test_encoded_plan_ends_with_newline(): + """末尾 LF が無いと素朴な ``while read`` consumer が最後の行を落とす""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + decoded = base64.b64decode(encode_repo_plan(config.repos)).decode() + + assert decoded.endswith("\n") + + +@requires_bash +def test_bash_consumer_reads_every_column_including_empty_branch(): + """branch 未指定 (空フィールド) でも init が branch にずれ込まないこと + + 区切りをタブにすると bash の IFS 空白扱いで連続区切りが 1 つに畳まれ、 + ``1`` が branch に入って init が空になる。US (\x1f) なら空フィールドが残る。 + """ + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [ + {"repo": "carmo"}, + {"repo": "carmo-batch", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + lines = run_bash_consumer(encode_repo_plan(config.repos)) + + assert lines == [ + "[https://github.com/volareinc/carmo.git][carmo][][1]", + "[https://github.com/volareinc/carmo-batch.git][carmo-batch][develop][0]", ] +@requires_bash +def test_bash_consumer_does_not_drop_the_last_line_for_a_single_repo(): + """末尾 LF が無いと 1 repo 構成では唯一の行がループ本体に入らない""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + lines = run_bash_consumer(encode_repo_plan(config.repos)) + + assert lines == ["[https://github.com/volareinc/carmo.git][carmo][][1]"] + + def test_repo_plan_round_trips(): config = parse_project_config({ "version": 1, @@ -358,13 +438,34 @@ def test_repo_plan_round_trips(): @pytest.mark.parametrize("bad_init", ["", "2", "true", "0 "]) def test_decode_rejects_init_column_outside_one_and_zero(bad_init): """壊れた値・将来の未知値を「init しない」として黙って通さない""" - line = f"https://github.com/volareinc/carmo.git\tcarmo\t\t{bad_init}" + line = f"https://github.com/volareinc/carmo.git{US}carmo{US}{US}{bad_init}" encoded = base64.b64encode(line.encode()).decode() with pytest.raises(ConfigError, match="init"): decode_repo_plan(encoded) +def test_numeric_repo_name_reports_a_type_error_not_a_missing_field(): + """YAML が int として読む ``repo: 123`` に「必須です」と言わない + + 「指定したのに必須と言われる」を避けるため、未指定と型不一致を書き分ける。 + """ + with pytest.raises(ConfigError, match="repo は文字列で指定してください"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": 123}], + }, source="project.yml") + + +@pytest.mark.parametrize("missing", [None, ""]) +def test_unspecified_repo_reports_a_missing_field(missing): + with pytest.raises(ConfigError, match="repo は必須です"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": missing}], + }, source="project.yml") + + def test_encoded_plan_has_no_shell_or_compose_hazards(): """compose の変数展開・改行で壊れないこと (base64 なので英数と = のみ)""" config = parse_project_config({ From cf2d07a07de333b40afb655f0846dc42363db339 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:59:59 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(project):=20=E7=A9=BA=E6=96=87=E5=AD=97?= =?UTF-8?q?=E3=81=A8=E9=9D=9E=E7=A9=BA=E7=99=BD=E3=81=AE=E5=88=B6=E5=BE=A1?= =?UTF-8?q?=E6=96=87=E5=AD=97=E3=82=92=E6=AD=A3=E3=81=97=E3=81=8F=E5=BC=BE?= =?UTF-8?q?=E3=81=8D=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 省略可能な branch に `branch: ""` を書くと「必須です」と返り矛盾していたため、 未指定 (None) / 型不一致 / 空文字 の 3 つをそれぞれのメッセージで書き分ける - `isspace()` だけでは NUL・BEL・DEL やゼロ幅空白がすり抜け、encode_repo_plan の 「制御文字を一切含まない」という契約を満たせていなかったので、印字できない文字も まとめて弾くようにした Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/config.py | 20 ++++++++++++---- tests/project/test_config.py | 45 +++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py index f9bb4ff..2c4023a 100644 --- a/lib/devbase/project/config.py +++ b/lib/devbase/project/config.py @@ -324,22 +324,32 @@ def _require_token(value: Any, field: str, source: str, where: str, allow_slash: bool) -> str: """URL 組み立てと wire format を壊さない文字列であることを確かめる。 - 空白・タブ・改行は wire format (行区切り) を壊し、``/`` は + 空白・タブ・改行・制御文字は wire format (行区切り・US 区切り) を壊し、``/`` は ``https:////.git`` の構造や ``/work/`` の階層を 壊すため、項目ごとに許可を分ける (gitlab のサブグループやブランチ名の ``feature/x`` は ``/`` を含むため許可する)。 """ - # YAML は ``repo: 123`` を int として読むため、「未指定」と「型が違う」を + # YAML は ``repo: 123`` を int として読むため、「未指定」「型が違う」「空」を # 同じ "必須です" で片づけると「指定したのに必須と言われる」ことになる。 - if value is None or value == "": + # 特に branch のような省略可能なフィールドでは ``branch: ""`` に対して + # 「必須です」と返るのが矛盾して見える。 + if value is None: raise ConfigError(f"{source}: {where} の {field} は必須です") if not isinstance(value, str): raise ConfigError( f"{source}: {where} の {field} は文字列で指定してください " f"({value!r})") - if any(c.isspace() for c in value): + if not value: + raise ConfigError( + f"{source}: {where} の {field} に空文字は指定できません") + # ``isspace()`` だけでは NUL・DEL のような非空白の制御文字やゼロ幅空白が + # すり抜ける。URL 組み立て・``/work/``・wire format のいずれにとっても + # 害なので「印字できない文字」をまとめて弾く (通常の空白は印字可能なので + # ``isspace()`` 側で拾う)。 + if any(c.isspace() or not c.isprintable() for c in value): raise ConfigError( - f"{source}: {where} の {field} に空白文字は使えません ({value!r})") + f"{source}: {where} の {field} に空白文字・制御文字は使えません " + f"({value!r})") if not allow_slash and "/" in value: raise ConfigError( f"{source}: {where} の {field} に / は使えません ({value!r})") diff --git a/tests/project/test_config.py b/tests/project/test_config.py index bf6ba66..7d99ff6 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -271,15 +271,35 @@ def test_dir_must_stay_directly_under_work(bad_dir): ("repo", "car\tmo"), ("host", "github.com/extra"), ("branch", "main\nrm -rf"), + ("repo", "car\x1fmo"), # US — wire format の列区切りそのもの ]) def test_fields_reject_whitespace_and_separators(field, value): - """wire format (タブ区切り) と URL 組み立てを壊す値を弾く""" + """wire format (US 区切り・LF 行区切り) と URL 組み立てを壊す値を弾く""" spec = {"owner": "volareinc", "repo": "carmo"} spec[field] = value with pytest.raises(ConfigError, match=field): parse_project_config({"version": 1, "repos": [spec]}, source="project.yml") +@pytest.mark.parametrize("value", [ + "car\x00mo", # NUL — bash の read / git のどちらにとっても異物 + "car\x07mo", # BEL + "car\x7fmo", # DEL + "car\u200bmo", # ゼロ幅空白 — 目視できないまま URL に混ざる +]) +def test_fields_reject_non_whitespace_control_characters(value): + """``isspace()`` ではすり抜ける制御文字・ゼロ幅空白も弾く + + :func:`encode_repo_plan` の docstring が「制御文字を一切含まない」と + 宣言している以上、空白判定だけでは契約を満たせない。 + """ + with pytest.raises(ConfigError, match="repo"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": value}], + }, source="project.yml") + + def test_scale_must_be_a_positive_integer(): with pytest.raises(ConfigError, match="scale"): parse_project_config({ @@ -457,12 +477,29 @@ def test_numeric_repo_name_reports_a_type_error_not_a_missing_field(): }, source="project.yml") -@pytest.mark.parametrize("missing", [None, ""]) -def test_unspecified_repo_reports_a_missing_field(missing): +def test_unspecified_repo_reports_a_missing_field(): with pytest.raises(ConfigError, match="repo は必須です"): parse_project_config({ "version": 1, - "repos": [{"owner": "volareinc", "repo": missing}], + "repos": [{"owner": "volareinc", "repo": None}], + }, source="project.yml") + + +def test_empty_repo_reports_an_empty_value_not_a_missing_field(): + """``repo: ""`` は「指定はされている」ので「必須です」とは言わない""" + with pytest.raises(ConfigError, match="repo に空文字は指定できません"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": ""}], + }, source="project.yml") + + +def test_empty_optional_branch_is_rejected_as_empty_not_as_missing(): + """branch は省略可能なので ``branch: ""`` に「必須です」と返すと矛盾する""" + with pytest.raises(ConfigError, match="branch に空文字は指定できません"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "branch": ""}], }, source="project.yml")