Files
Jyotisha/scripts/import_yinduzhanxing.py

595 lines
24 KiB
Python

#!/usr/bin/env python3
"""Safely import an explicit research allowlist into the commercial repository.
The importer is deliberately one-way and offline. It never writes semantic-merge
or protected paths, and defaults to a dry run. Non-Git archives are accepted
only when an operator binds them to an explicit commit and expected source-tree
SHA-256; missing or mismatched provenance fails closed.
"""
from __future__ import annotations
import argparse
import difflib
import fnmatch
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json"
EXIT_OK = 0
EXIT_SEMANTIC_REVIEW = 2
EXIT_POLICY_REJECTED = 3
EXIT_VALIDATION_FAILED = 4
COMMIT_HEX_LENGTH = 40
SHA256_HEX_LENGTH = 64
IMPORT_RECORD_DIR = ROOT / "references/cross_project_contract/imports"
REQUIRED_PROTECTED_PATTERNS = frozenset({
"skills/jyotish-birth-time-rectification/**",
"skills/jyotish-vedic-astrology/**",
"SKILL.md",
"frontend/**",
"deploy/**",
".gitea/**",
".github/**",
"frontend/db/**",
"frontend/supabase/**",
"references/oracle/commercial_skill_truth_overlay.v1.json",
"**/.env",
"**/.env.*",
"**/*payment*",
"**/*billing*",
"**/*subscription*",
"**/*entitlement*",
"**/*admin*",
"**/*service_role*",
})
class ImportRejected(ValueError):
"""The requested import violates a direction, path, privacy or repository guard."""
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_file(path: Path) -> str:
return sha256_bytes(path.read_bytes())
def _safe_relative(value: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ImportRejected("empty_path")
candidate = PurePosixPath(value.replace("\\", "/"))
if candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts:
raise ImportRejected(f"unsafe_path:{value}")
normalized = candidate.as_posix()
if normalized.startswith("/") or "\x00" in normalized:
raise ImportRejected(f"unsafe_path:{value}")
return normalized
def _resolved_file(root: Path, relative: str, *, must_exist: bool) -> Path:
root_resolved = root.resolve()
safe_relative = _safe_relative(relative)
path = root_resolved / safe_relative
current = root_resolved
for part in PurePosixPath(safe_relative).parts:
current = current / part
if current.is_symlink():
raise ImportRejected(f"symlink_component_rejected:{relative}")
if must_exist and not path.exists():
return path
resolved = path.resolve(strict=must_exist)
if resolved != root_resolved and root_resolved not in resolved.parents:
raise ImportRejected(f"symlink_escape:{relative}")
if must_exist and (not path.is_file() or path.is_symlink()):
raise ImportRejected(f"non_regular_or_symlink:{relative}")
return path
def _matches(path: str, patterns: list[str]) -> bool:
lowered = path.casefold()
return any(fnmatch.fnmatchcase(lowered, pattern.casefold()) for pattern in patterns)
def load_policy(path: Path) -> dict[str, Any]:
policy = json.loads(path.read_text(encoding="utf-8"))
if policy.get("schema_version") != 2:
raise ImportRejected("policy_schema_version_must_be_2")
if policy.get("direction") != "research_to_commercial_only":
raise ImportRejected("direction_must_be_research_to_commercial_only")
if policy.get("reverse_sync") != "forbidden":
raise ImportRejected("reverse_sync_must_be_forbidden")
if "commercial_to_research" in json.dumps(policy, sort_keys=True):
raise ImportRejected("reverse_direction_is_not_expressible")
expected_identity = _repository_identity(str(policy.get("source_repository_url", "")))
if expected_identity is None:
raise ImportRejected("source_repository_url_invalid")
if expected_identity[1] != str(policy.get("source_repository", "")).strip("/").casefold():
raise ImportRejected("source_repository_url_slug_mismatch")
modes = policy.get("modes")
if not isinstance(modes, dict) or set(modes) != {"mirror", "semantic_merge", "protected"}:
raise ImportRejected("policy_modes_invalid")
if not isinstance(modes["mirror"], list) or not isinstance(modes["semantic_merge"], list):
raise ImportRejected("policy_allowlists_invalid")
protected = [_safe_relative(value) if "*" not in value else value.replace("\\", "/") for value in modes["protected"]]
missing_protected = sorted(REQUIRED_PROTECTED_PATTERNS - set(protected))
if missing_protected:
raise ImportRejected(f"required_protected_patterns_missing:{','.join(missing_protected)}")
for mapping in modes["mirror"]:
if not isinstance(mapping, dict) or not {"source", "target", "license"} <= set(mapping):
raise ImportRejected("mirror_mapping_invalid")
_safe_relative(mapping["source"])
target = _safe_relative(mapping["target"])
if _matches(target, protected):
raise ImportRejected(f"protected_rejected:{target}")
for value in modes["semantic_merge"]:
_safe_relative(value)
return policy
def _git_output(root: Path, *args: str) -> str | None:
try:
return subprocess.check_output(
["git", "-C", str(root), *args], stderr=subprocess.DEVNULL, text=True, timeout=8
).strip()
except (OSError, subprocess.SubprocessError):
return None
def _normalized_hex(value: str | None, *, length: int, label: str) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
if len(normalized) != length or any(ch not in "0123456789abcdef" for ch in normalized):
raise ImportRejected(f"{label}_invalid")
return normalized
def source_identity(
source: Path,
*,
source_mode: str = "auto",
source_commit: str | None = None,
expected_tree_sha256: str | None = None,
actual_tree_sha256: str | None = None,
) -> tuple[str, str]:
if source_mode not in {"auto", "git", "archive"}:
raise ImportRejected("source_mode_must_be_auto_git_or_archive")
if source_mode != "archive" and (source_commit is None) != (expected_tree_sha256 is None):
raise ImportRejected("source_commit_and_expected_tree_sha256_must_be_provided_together")
explicit_commit = _normalized_hex(
source_commit, length=COMMIT_HEX_LENGTH, label="source_commit"
)
expected_tree = _normalized_hex(
expected_tree_sha256, length=SHA256_HEX_LENGTH, label="expected_tree_sha256"
)
actual_tree = actual_tree_sha256 or source_tree_hash(source)
inside = _git_output(source, "rev-parse", "--show-toplevel")
detected_commit = (
_git_output(source, "rev-parse", "HEAD")
if inside and Path(inside).resolve() == source.resolve()
else None
)
detected_commit = _normalized_hex(
detected_commit, length=COMMIT_HEX_LENGTH, label="detected_source_commit"
)
if source_mode == "archive" and detected_commit:
raise ImportRejected("archive_source_must_not_be_git_checkout")
if source_mode != "archive" and detected_commit:
status = _git_output(source, "status", "--porcelain=v1", "--untracked-files=all")
ignored = _git_output(source, "ls-files", "--others", "--ignored", "--exclude-standard")
if status is None or ignored is None:
raise ImportRejected("git_source_status_unavailable")
if status or ignored:
raise ImportRejected("git_source_must_be_clean")
if explicit_commit and explicit_commit != detected_commit:
raise ImportRejected("source_commit_mismatch")
if expected_tree and expected_tree != actual_tree:
raise ImportRejected("source_tree_sha256_mismatch")
return "git", detected_commit
if source_mode == "git":
raise ImportRejected("git_source_metadata_unavailable")
if explicit_commit is None:
raise ImportRejected("archive_source_commit_required")
if expected_tree is None:
raise ImportRejected("archive_expected_tree_sha256_required")
if expected_tree != actual_tree:
raise ImportRejected("source_tree_sha256_mismatch")
return "archive", explicit_commit
def _normalized_remote(root: Path) -> str | None:
remote = _git_output(root, "remote", "get-url", "origin")
if not remote:
return None
value = remote.strip().lower().removesuffix(".git").replace("git@", "ssh://git@")
return value.rstrip("/")
def _repository_identity(value: str) -> tuple[str, str] | None:
value = value.strip().removesuffix(".git").rstrip("/")
if not value:
return None
if "://" in value:
parsed = urlsplit(value)
host = (parsed.hostname or "").casefold()
path = parsed.path
elif ":" in value and "@" in value.split(":", 1)[0]:
host = value.split("@", 1)[1].split(":", 1)[0].casefold()
path = value.split(":", 1)[1]
else:
return None
parts = [part for part in path.split("/") if part]
if not host or len(parts) < 2:
return None
return host, "/".join(parts).casefold()
def _remote_repository_identity(root: Path) -> tuple[str, str] | None:
remote = _git_output(root, "remote", "get-url", "origin")
return _repository_identity(remote or "")
def validate_git_source_repository(source: Path, expected_repository: str, expected_url: str) -> None:
expected = expected_repository.strip().removesuffix(".git").strip("/").casefold()
expected_identity = _repository_identity(expected_url)
actual = _remote_repository_identity(source)
if actual is None:
raise ImportRejected("git_source_origin_unavailable")
if expected_identity is None or expected_identity[1] != expected:
raise ImportRejected("source_repository_url_invalid")
if actual != expected_identity:
raise ImportRejected(f"git_source_repository_mismatch:{actual[0]}/{actual[1]}")
def validate_clean_target(target: Path) -> None:
top_level = _git_output(target, "rev-parse", "--show-toplevel")
if not top_level or Path(top_level).resolve() != target.resolve():
raise ImportRejected("target_must_be_git_repository_root")
status = _git_output(target, "status", "--porcelain=v1", "--untracked-files=all")
ignored = _git_output(target, "ls-files", "--others", "--ignored", "--exclude-standard")
if status is None or ignored is None:
raise ImportRejected("target_status_unavailable")
if status or ignored:
raise ImportRejected("target_must_be_clean")
def validate_roots(source: Path, target: Path) -> None:
source = source.expanduser().resolve(strict=True)
target = target.expanduser().resolve(strict=True)
if not source.is_dir() or not target.is_dir():
raise ImportRejected("source_and_target_must_be_directories")
if source == target or source in target.parents or target in source.parents:
raise ImportRejected("source_and_target_must_not_overlap")
source_remote = _normalized_remote(source)
target_remote = _normalized_remote(target)
if source_remote and target_remote and source_remote == target_remote:
raise ImportRejected("source_and_target_git_remote_must_differ")
def source_tree_hash(source: Path) -> str:
digest = hashlib.sha256()
for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()):
relative = path.relative_to(source).as_posix()
if ".git" in PurePosixPath(relative).parts:
continue
if path.is_symlink():
raise ImportRejected(f"source_tree_symlink_rejected:{relative}")
if not path.is_file():
continue
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(bytes.fromhex(sha256_file(path)))
digest.update(b"\0")
return digest.hexdigest()
def _normalized_commit_timestamp(value: str | None) -> str | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return None
return parsed.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
def deterministic_generated_at(
source: Path,
*,
source_mode: str,
source_commit: str,
target: Path,
target_commit: str,
) -> str:
timestamp = None
if source_mode == "git":
timestamp = _git_output(source, "show", "-s", "--format=%cI", source_commit)
if timestamp is None:
timestamp = _git_output(target, "show", "-s", "--format=%cI", target_commit)
normalized = _normalized_commit_timestamp(timestamp)
if normalized is None:
raise ImportRejected("deterministic_generated_at_unavailable")
return normalized
def _privacy_rejection(path: Path, relative: str, privacy: dict[str, Any]) -> str | None:
lowered = relative.casefold()
basenames = {str(value).casefold() for value in privacy.get("forbidden_basenames", [])}
extensions = [str(value).casefold() for value in privacy.get("forbidden_extensions", [])]
if path.name.casefold() in basenames or any(lowered.endswith(ext) for ext in extensions):
return f"sensitive_filename:{relative}"
data = path.read_bytes()
if b"\x00" in data[:8192]:
return f"binary_content_rejected:{relative}"
text = data.decode("utf-8", errors="replace")
for marker in privacy.get("content_markers", []):
if str(marker).casefold() in text.casefold():
return f"sensitive_content:{relative}"
return None
def _diff_summary(source: Path | None, target: Path | None) -> str:
if source is None:
return "source missing"
if target is None:
return "target missing; manual semantic merge required"
try:
before = target.read_text(encoding="utf-8").splitlines()
after = source.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
return "binary difference; manual semantic merge required"
added = removed = 0
for line in difflib.ndiff(before, after):
added += line.startswith("+ ")
removed += line.startswith("- ")
return f"manual semantic merge required: +{added}/-{removed} lines"
def build_manifest(
source: Path,
*,
policy_path: Path = DEFAULT_POLICY,
target: Path = ROOT,
apply: bool = False,
source_mode: str = "auto",
source_commit: str | None = None,
expected_tree_sha256: str | None = None,
tests_run: list[str] | None = None,
) -> dict[str, Any]:
source = source.expanduser().resolve(strict=True)
target = target.expanduser().resolve(strict=True)
policy = load_policy(policy_path)
validate_roots(source, target)
validate_clean_target(target)
protected = list(policy["modes"]["protected"])
privacy = policy.get("privacy") if isinstance(policy.get("privacy"), dict) else {}
actual_tree_sha256 = source_tree_hash(source)
resolved_source_mode, resolved_source_commit = source_identity(
source,
source_mode=source_mode,
source_commit=source_commit,
expected_tree_sha256=expected_tree_sha256,
actual_tree_sha256=actual_tree_sha256,
)
if resolved_source_mode == "git":
validate_git_source_repository(
source,
str(policy["source_repository"]),
str(policy["source_repository_url"]),
)
source_skill_file = _resolved_file(source, "SKILL.md", must_exist=True)
if not source_skill_file.exists():
raise ImportRejected("root_skill_missing")
source_skill_sha256 = sha256_file(source_skill_file)
target_commit = _normalized_hex(
_git_output(target, "rev-parse", "HEAD"),
length=COMMIT_HEX_LENGTH,
label="target_base_commit",
)
if target_commit is None:
raise ImportRejected("target_base_commit_unavailable")
generated_at = deterministic_generated_at(
source,
source_mode=resolved_source_mode,
source_commit=resolved_source_commit,
target=target,
target_commit=target_commit,
)
mirror_files: list[dict[str, Any]] = []
privacy_rejections: list[str] = []
pending_writes: list[tuple[Path, bytes, dict[str, Any]]] = []
for mapping in policy["modes"]["mirror"]:
source_rel = _safe_relative(mapping["source"])
target_rel = _safe_relative(mapping["target"])
if _matches(target_rel, protected):
raise ImportRejected(f"protected_rejected:{target_rel}")
source_file = _resolved_file(source, source_rel, must_exist=True)
if not source_file.exists():
raise ImportRejected(f"mirror_source_missing:{source_rel}")
target_file = _resolved_file(target, target_rel, must_exist=False)
rejection = _privacy_rejection(source_file, source_rel, privacy)
if rejection:
privacy_rejections.append(rejection)
continue
data = source_file.read_bytes()
source_hash = sha256_bytes(data)
before_hash = sha256_file(target_file) if target_file.exists() and target_file.is_file() else None
status = "unchanged" if source_hash == before_hash else "update" if before_hash else "new"
row = {
"source": source_rel,
"target": target_rel,
"status": status,
"source_sha256": source_hash,
"target_sha256_before": before_hash,
"target_sha256_after": source_hash,
"license": str(mapping["license"]),
}
mirror_files.append(row)
if status != "unchanged":
pending_writes.append((target_file, data, row))
if privacy_rejections:
raise ImportRejected(";".join(privacy_rejections))
semantic_files: list[dict[str, Any]] = []
for relative_value in policy["modes"]["semantic_merge"]:
relative = _safe_relative(relative_value)
source_file = _resolved_file(source, relative, must_exist=False)
target_file = _resolved_file(target, relative, must_exist=False)
source_exists = source_file.exists() and source_file.is_file() and not source_file.is_symlink()
target_exists = target_file.exists() and target_file.is_file() and not target_file.is_symlink()
source_hash = sha256_file(source_file) if source_exists else None
target_hash = sha256_file(target_file) if target_exists else None
if not source_exists:
status = "source_missing"
elif not target_exists:
status = "target_missing"
elif source_hash == target_hash:
status = "unchanged"
else:
status = "review_required"
semantic_files.append({
"path": relative,
"status": status,
"source_sha256": source_hash,
"target_sha256": target_hash,
"diff_summary": _diff_summary(source_file if source_exists else None, target_file if target_exists else None),
})
if apply and pending_writes:
backups: list[tuple[Path, bytes | None]] = []
try:
for destination, data, row in pending_writes:
destination.parent.mkdir(parents=True, exist_ok=True)
backups.append((destination, destination.read_bytes() if destination.exists() else None))
fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temp_name, 0o644)
os.replace(temp_name, destination)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
row["status"] = "applied"
except Exception:
for destination, previous in reversed(backups):
if previous is None:
destination.unlink(missing_ok=True)
else:
destination.write_bytes(previous)
raise
return {
"schema_version": 1,
"source_repository": policy["source_repository"],
"source_repository_url": policy["source_repository_url"],
"source_commit": resolved_source_commit,
"source_tree_hash": actual_tree_sha256,
"source_skill_sha256": source_skill_sha256,
"source_mode": resolved_source_mode,
"target_repository": policy["target_repository"],
"target_base_commit": target_commit,
"policy_version": 2,
"generated_at": generated_at,
"mirror_files": mirror_files,
"semantic_merge_files": semantic_files,
"protected_rejections": [],
"tests_run": sorted(set(tests_run or [])),
"privacy_scan": {"status": "pass", "scanned_files": len(mirror_files), "rejections": []},
"operator_review_required": any(row["status"] in {"review_required", "target_missing"} for row in semantic_files),
}
def validate_output_path(path: Path, *, target: Path = ROOT, source: Path | None = None) -> Path:
"""Allow manifests outside the target or inside the dedicated import-record directory only."""
resolved = path.expanduser().resolve(strict=False)
target_resolved = target.expanduser().resolve(strict=True)
if source is not None:
source_resolved = source.expanduser().resolve(strict=True)
if resolved == source_resolved or source_resolved in resolved.parents:
raise ImportRejected("manifest_output_must_not_overlap_source")
if resolved == target_resolved or target_resolved in resolved.parents:
import_root = (target_resolved / IMPORT_RECORD_DIR.relative_to(ROOT)).resolve(strict=False)
if import_root not in resolved.parents:
raise ImportRejected("manifest_output_must_be_external_or_import_record_json")
if resolved.suffix.casefold() != ".json":
raise ImportRejected("manifest_output_must_be_json")
return resolved
def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY)
parser.add_argument("--source-mode", choices=("auto", "git", "archive"), default="auto")
parser.add_argument("--source-commit")
parser.add_argument("--expected-tree-sha256")
parser.add_argument("--test-run", action="append", default=[], help="Test path recorded in the manifest; repeatable.")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--apply", action="store_true")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args(argv)
try:
output = validate_output_path(args.output, source=args.source)
manifest = build_manifest(
args.source,
policy_path=args.policy,
apply=args.apply,
source_mode=args.source_mode,
source_commit=args.source_commit,
expected_tree_sha256=args.expected_tree_sha256,
tests_run=args.test_run,
)
write_json_atomic(output, manifest)
except ImportRejected as error:
print(json.dumps({"status": "rejected", "reason": str(error)}, ensure_ascii=False), file=sys.stderr)
return EXIT_POLICY_REJECTED
except (OSError, ValueError, json.JSONDecodeError) as error:
print(json.dumps({"status": "invalid", "reason": str(error)}, ensure_ascii=False), file=sys.stderr)
return EXIT_VALIDATION_FAILED
print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
return EXIT_SEMANTIC_REVIEW if manifest["operator_review_required"] else EXIT_OK
if __name__ == "__main__":
raise SystemExit(main())