diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py index 3af1ccb8..ec614af0 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance.py @@ -12,6 +12,7 @@ from urllib.parse import urlparse from pydantic import BaseModel, Field from yuqei_ai_platform_api.provider_legal_approval import SignedProviderLegalApproval +from yuqei_ai_platform_api.provider_review_package import canonical_review_asset_bytes ACCEPTANCE_POLICY_VERSION = "phase13-provider-acceptance-v1" @@ -385,7 +386,7 @@ def _manifest_hash(paths: list[Path]) -> str: for path in paths: digest.update(path.name.encode("utf-8")) digest.update(b"\0") - digest.update(path.read_bytes()) + digest.update(canonical_review_asset_bytes(path)) digest.update(b"\0") return digest.hexdigest() diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py index b9fe7310..04b98e7d 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_review_package.py @@ -9,12 +9,14 @@ from __future__ import annotations import hashlib import json from datetime import UTC, datetime +from pathlib import Path from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field REVIEW_PACKAGE_VERSION = "provider-technical-review-package-v1" +CANONICAL_TEXT_ASSET_SUFFIXES = frozenset({".csv", ".json", ".md"}) class StrictModel(BaseModel): @@ -82,6 +84,14 @@ def sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() +def canonical_review_asset_bytes(path: Path) -> bytes: + """Return stable review bytes independent of checkout line endings.""" + raw = path.read_bytes() + if path.suffix.lower() in CANONICAL_TEXT_ASSET_SUFFIXES: + return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + return raw + + def _as_utc(value: datetime) -> datetime: if value.tzinfo is None or value.utcoffset() is None: raise ValueError("review package timestamps must be timezone-aware") diff --git a/yuqei-infra/scripts/prepare-provider-legal-approval.py b/yuqei-infra/scripts/prepare-provider-legal-approval.py index c2ca2810..b7b74406 100644 --- a/yuqei-infra/scripts/prepare-provider-legal-approval.py +++ b/yuqei-infra/scripts/prepare-provider-legal-approval.py @@ -44,6 +44,7 @@ from yuqei_ai_platform_api.provider_acceptance import expected_acceptance_invent from yuqei_ai_platform_api.provider_review_package import ( # noqa: E402 ProviderTechnicalReviewPackage, canonical_json, + canonical_review_asset_bytes, canonical_review_package, review_package_sha256, sha256_bytes, @@ -170,7 +171,7 @@ def _validate_provider_package_inventory(package: ProviderTechnicalReviewPackage expected_assets: dict[str, tuple[str, int]] = {} for asset_path in sorted(item for item in PHASE13_ROOT.rglob("*") if item.is_file()): - raw = asset_path.read_bytes() + raw = canonical_review_asset_bytes(asset_path) expected_assets[asset_path.relative_to(PHASE13_ROOT).as_posix()] = ( sha256_bytes(raw), len(raw), @@ -211,7 +212,7 @@ def _safe_asset(relative: str) -> tuple[str, str, int]: raise ValueError("contract product golden asset escapes its root") from error if not candidate.is_file(): raise ValueError("contract product golden asset is not a regular file") - raw = candidate.read_bytes() + raw = canonical_review_asset_bytes(candidate) return relative.replace("\\", "/"), sha256_bytes(raw), len(raw) @@ -276,7 +277,7 @@ def build_product_golden_evidence(summary_file: Path) -> dict[str, Any]: } ) cases.sort(key=lambda item: (item["suite"], item["sample_id"])) - inventory_raw = GENERATION_INVENTORY.read_bytes() + inventory_raw = canonical_review_asset_bytes(GENERATION_INVENTORY) return { "summary_file_sha256": sha256_bytes(summary_raw), "inventory_file_sha256": sha256_bytes(inventory_raw), diff --git a/yuqei-infra/scripts/run-ai-provider-acceptance.py b/yuqei-infra/scripts/run-ai-provider-acceptance.py index d5562fe4..5dd62a05 100644 --- a/yuqei-infra/scripts/run-ai-provider-acceptance.py +++ b/yuqei-infra/scripts/run-ai-provider-acceptance.py @@ -52,6 +52,7 @@ from yuqei_ai_platform_api.provider_review_package import ( # noqa: E402 ProviderReviewCase, ProviderTechnicalReviewPackage, canonical_json, + canonical_review_asset_bytes, canonical_review_package, review_package_sha256, sha256_bytes, @@ -627,7 +628,7 @@ def _manifest_hash(paths: list[Path]) -> str: for path in paths: digest.update(path.name.encode("utf-8")) digest.update(b"\0") - digest.update(path.read_bytes()) + digest.update(canonical_review_asset_bytes(path)) digest.update(b"\0") return digest.hexdigest() @@ -683,7 +684,7 @@ def _review_assets() -> list[ProviderReviewAsset]: assets: list[ProviderReviewAsset] = [] for path in sorted(item for item in PHASE13_ROOT.rglob("*") if item.is_file()): relative = path.relative_to(PHASE13_ROOT).as_posix() - raw = path.read_bytes() + raw = canonical_review_asset_bytes(path) assets.append( ProviderReviewAsset(path=relative, sha256=sha256_bytes(raw), size_bytes=len(raw)) ) diff --git a/yuqei-infra/tests/test_provider_legal_signing_helper.py b/yuqei-infra/tests/test_provider_legal_signing_helper.py index 361e7df5..3b5fd967 100644 --- a/yuqei-infra/tests/test_provider_legal_signing_helper.py +++ b/yuqei-infra/tests/test_provider_legal_signing_helper.py @@ -42,6 +42,7 @@ from yuqei_ai_platform_api.provider_review_package import ( # noqa: E402 ProviderReviewCase, ProviderTechnicalReviewPackage, canonical_json, + canonical_review_asset_bytes, canonical_review_package, sha256_bytes, ) @@ -61,7 +62,7 @@ def _review_package() -> ProviderTechnicalReviewPackage: inventory = expected_acceptance_inventory() assets = [] for path in sorted(item for item in helper.PHASE13_ROOT.rglob("*") if item.is_file()): - raw = path.read_bytes() + raw = canonical_review_asset_bytes(path) assets.append( ProviderReviewAsset( path=path.relative_to(helper.PHASE13_ROOT).as_posix(), diff --git a/yuqei-infra/tests/test_provider_promotion_two_stage.py b/yuqei-infra/tests/test_provider_promotion_two_stage.py index 56e4d7dc..c8be8b2e 100644 --- a/yuqei-infra/tests/test_provider_promotion_two_stage.py +++ b/yuqei-infra/tests/test_provider_promotion_two_stage.py @@ -36,7 +36,24 @@ from yuqei_ai_platform_api.provider_legal_approval import ( # noqa: E402 ProviderLegalApprovalPublicKey, sign_legal_approval, ) -from yuqei_ai_platform_api.provider_review_package import review_package_sha256 # noqa: E402 +from yuqei_ai_platform_api.provider_review_package import ( # noqa: E402 + canonical_review_asset_bytes, + review_package_sha256, +) + + +def test_review_asset_and_manifest_hashes_ignore_checkout_line_endings(tmp_path: Path) -> None: + lf_dir = tmp_path / "lf" + crlf_dir = tmp_path / "crlf" + lf_dir.mkdir() + crlf_dir.mkdir() + lf_path = lf_dir / "inventory.csv" + crlf_path = crlf_dir / "inventory.csv" + lf_path.write_bytes(b"id,title\n1,alpha\n2,beta\n") + crlf_path.write_bytes(b"id,title\r\n1,alpha\r\n2,beta\r\n") + + assert canonical_review_asset_bytes(lf_path) == canonical_review_asset_bytes(crlf_path) + assert cli._manifest_hash([lf_path]) == cli._manifest_hash([crlf_path]) def _technical_package():