feat(sync): enforce one-way yinduzhanxing import policy

This commit is contained in:
Jesse
2026-08-06 11:25:34 +08:00
parent 49da8f9169
commit 685ed00e2f
5 changed files with 664 additions and 0 deletions
@@ -0,0 +1,24 @@
# Yinduzhanxing -> Jyotisha one-way import
## Authority boundary
- Direction: `732642856/yinduzhanxing` research source -> `root/Jyotisha` commercial target only.
- Reverse synchronization is forbidden and is not expressible by the v2 policy or importer CLI.
- The user-provided source at `../yinduzhanxing-main` is a snapshot without usable Git metadata. Imports from it must record `source_commit=unknown` and a deterministic `source_tree_hash`; they must not claim parity with a GitHub commit.
- Commercial frontend, identity, billing, database, deployment and commercial truth overlays remain protected.
## Review flow
```bash
.venv/bin/python scripts/import_yinduzhanxing.py \
--source ../yinduzhanxing-main \
--policy references/cross_project_contract/sync_policy.v2.json \
--dry-run \
--output artifacts/yinduzhanxing-import-plan.json
```
A reviewer must inspect semantic merge rows. Only then may the mirror allowlist be applied. `SKILL.md`, `AGENTS.md`, orchestrators and API entrypoints are semantic-merge paths and are never overwritten by the importer.
## Rollback
Revert the commercial import commit. Preserve the manifest for audit history. Never modify or push the research snapshot as part of rollback.
@@ -0,0 +1,66 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://jyotisha.chat/contracts/yinduzhanxing-import-manifest.v1.schema.json",
"title": "Yinduzhanxing one-way import manifest",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "source_repository", "source_commit", "source_tree_hash",
"source_mode", "target_repository", "target_base_commit", "policy_version",
"generated_at", "mirror_files", "semantic_merge_files", "protected_rejections",
"tests_run", "privacy_scan", "operator_review_required"
],
"properties": {
"schema_version": {"const": 1},
"source_repository": {"type": "string", "minLength": 1},
"source_commit": {"type": "string", "pattern": "^(unknown|[0-9a-f]{40})$"},
"source_tree_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"source_mode": {"enum": ["git", "snapshot"]},
"target_repository": {"type": "string", "minLength": 1},
"target_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"policy_version": {"const": 2},
"generated_at": {"type": "string", "format": "date-time"},
"mirror_files": {"type": "array", "items": {"$ref": "#/$defs/file"}},
"semantic_merge_files": {"type": "array", "items": {"$ref": "#/$defs/semantic"}},
"protected_rejections": {"type": "array", "items": {"type": "string"}},
"tests_run": {"type": "array", "items": {"type": "string"}},
"privacy_scan": {
"type": "object",
"additionalProperties": false,
"required": ["status", "scanned_files", "rejections"],
"properties": {
"status": {"enum": ["pass", "rejected"]},
"scanned_files": {"type": "integer", "minimum": 0},
"rejections": {"type": "array", "items": {"type": "string"}}
}
},
"operator_review_required": {"type": "boolean"}
},
"$defs": {
"file": {
"type": "object",
"additionalProperties": false,
"required": ["source", "target", "status", "source_sha256", "target_sha256_before", "target_sha256_after", "license"],
"properties": {
"source": {"type": "string"}, "target": {"type": "string"},
"status": {"enum": ["new", "update", "unchanged", "applied"]},
"source_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"target_sha256_before": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
"target_sha256_after": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"license": {"type": "string"}
}
},
"semantic": {
"type": "object",
"additionalProperties": false,
"required": ["path", "status", "source_sha256", "target_sha256", "diff_summary"],
"properties": {
"path": {"type": "string"},
"status": {"enum": ["unchanged", "source_missing", "target_missing", "review_required"]},
"source_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
"target_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
"diff_summary": {"type": "string"}
}
}
}
}
@@ -0,0 +1,52 @@
{
"schema_version": 2,
"direction": "research_to_commercial_only",
"source_repository": "732642856/yinduzhanxing",
"target_repository": "root/Jyotisha",
"reverse_sync": "forbidden",
"modes": {
"mirror": [
{
"source": "SKILL.md",
"target": "references/upstream/yinduzhanxing/SKILL.md",
"license": "MIT"
}
],
"semantic_merge": [
"SKILL.md",
"AGENTS.md",
"references/strict-workflow-router.md",
"scripts/unified_consultation_orchestrator.py",
"scripts/report_orchestrator.py",
"scripts/jyotish_api_server.py"
],
"protected": [
"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*"
]
},
"privacy": {
"forbidden_extensions": [".pem", ".key", ".p12", ".pfx", ".dump", ".sql.gz", ".pdf"],
"forbidden_basenames": [".env", "cookies.txt", "id_rsa", "id_ed25519"],
"content_markers": ["-----BEGIN PRIVATE KEY-----", "SUPABASE_SERVICE_ROLE_KEY=", "VEDASTRO_API_KEY=", "OPENAI_API_KEY=", "DATABASE_URL=postgres"]
},
"notes": [
"Only explicit mirror mappings may be copied byte-for-byte.",
"Semantic-merge paths are review inputs and are never overwritten by the importer.",
"Commercial product, identity, billing, database and deployment surfaces are protected.",
"There is no commercial-to-research mode or gate in schema v2."
]
}
+348
View File
@@ -0,0 +1,348 @@
#!/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. A source without usable Git
metadata is recorded as an auditable snapshot with ``source_commit=unknown``.
"""
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
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
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:
path = root / _safe_relative(relative)
root_resolved = root.resolve()
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")
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"]]
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 source_identity(source: Path) -> tuple[str, str]:
inside = _git_output(source, "rev-parse", "--show-toplevel")
commit = _git_output(source, "rev-parse", "HEAD") if inside and Path(inside).resolve() == source.resolve() else None
if commit and len(commit) == 40 and all(ch in "0123456789abcdef" for ch in commit.lower()):
return "git", commit.lower()
return "snapshot", "unknown"
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 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 or path.is_symlink() or 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 _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,
) -> dict[str, Any]:
source = source.expanduser().resolve(strict=True)
target = target.expanduser().resolve(strict=True)
validate_roots(source, target)
policy = load_policy(policy_path)
protected = list(policy["modes"]["protected"])
privacy = policy.get("privacy") if isinstance(policy.get("privacy"), dict) else {}
source_mode, source_commit = source_identity(source)
target_commit = _git_output(target, "rev-parse", "HEAD")
if not target_commit or len(target_commit) != 40:
raise ImportRejected("target_base_commit_unavailable")
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_commit": source_commit,
"source_tree_hash": source_tree_hash(source),
"source_mode": source_mode,
"target_repository": policy["target_repository"],
"target_base_commit": target_commit.lower(),
"policy_version": 2,
"generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"mirror_files": mirror_files,
"semantic_merge_files": semantic_files,
"protected_rejections": [],
"tests_run": [],
"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 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)
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:
manifest = build_manifest(args.source, policy_path=args.policy, apply=args.apply)
write_json_atomic(args.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())
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import hashlib
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import import_yinduzhanxing as importer # noqa: E402
POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json"
def _git(root: Path, *args: str) -> str:
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
def _target(tmp_path: Path) -> Path:
target = tmp_path / "commercial"
target.mkdir()
subprocess.run(["git", "init", "-q", str(target)], check=True)
subprocess.run(["git", "-C", str(target), "config", "user.email", "tests@example.invalid"], check=True)
subprocess.run(["git", "-C", str(target), "config", "user.name", "Tests"], check=True)
(target / "README.md").write_text("commercial\n", encoding="utf-8")
subprocess.run(["git", "-C", str(target), "add", "README.md"], check=True)
subprocess.run(["git", "-C", str(target), "commit", "-qm", "base"], check=True)
return target
def _source(tmp_path: Path) -> Path:
source = tmp_path / "snapshot"
(source / "scripts").mkdir(parents=True)
(source / "references").mkdir()
(source / "SKILL.md").write_text("# Research skill\n", encoding="utf-8")
(source / "AGENTS.md").write_text("research agent\n", encoding="utf-8")
(source / "references/strict-workflow-router.md").write_text("router\n", encoding="utf-8")
(source / "scripts/unified_consultation_orchestrator.py").write_text("# source\n", encoding="utf-8")
(source / "scripts/report_orchestrator.py").write_text("# report\n", encoding="utf-8")
(source / "scripts/jyotish_api_server.py").write_text("# api\n", encoding="utf-8")
return source
def _policy(tmp_path: Path, *, mirror: list[dict[str, str]] | None = None) -> Path:
value = json.loads(POLICY.read_text(encoding="utf-8"))
if mirror is not None:
value["modes"]["mirror"] = mirror
path = tmp_path / "policy.json"
path.write_text(json.dumps(value), encoding="utf-8")
return path
def test_v2_policy_is_strictly_one_way_and_has_no_reverse_gate() -> None:
policy = importer.load_policy(POLICY)
assert policy["direction"] == "research_to_commercial_only"
assert policy["reverse_sync"] == "forbidden"
assert "commercial_to_research" not in json.dumps(policy, sort_keys=True)
assert policy["modes"]["protected"]
def test_dry_run_writes_no_target_file_and_snapshot_commit_is_unknown(tmp_path: Path) -> None:
source, target = _source(tmp_path), _target(tmp_path)
before = _git(target, "status", "--porcelain=v1")
manifest = importer.build_manifest(source, policy_path=POLICY, target=target)
after = _git(target, "status", "--porcelain=v1")
assert before == after == ""
assert manifest["source_mode"] == "snapshot"
assert manifest["source_commit"] == "unknown"
assert manifest["mirror_files"][0]["status"] == "new"
assert not (target / "references/upstream/yinduzhanxing/SKILL.md").exists()
def test_apply_handles_new_update_and_unchanged_with_recomputable_hashes(tmp_path: Path) -> None:
source, target = _source(tmp_path), _target(tmp_path)
destination = target / "references/upstream/yinduzhanxing/SKILL.md"
first = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True)
assert first["mirror_files"][0]["status"] == "applied"
assert destination.read_bytes() == (source / "SKILL.md").read_bytes()
unchanged = importer.build_manifest(source, policy_path=POLICY, target=target)
assert unchanged["mirror_files"][0]["status"] == "unchanged"
assert unchanged["mirror_files"][0]["source_sha256"] == hashlib.sha256(destination.read_bytes()).hexdigest()
(source / "SKILL.md").write_text("# Updated research skill\n", encoding="utf-8")
update = importer.build_manifest(source, policy_path=POLICY, target=target)
assert update["mirror_files"][0]["status"] == "update"
assert update["mirror_files"][0]["target_sha256_before"] != update["mirror_files"][0]["target_sha256_after"]
def test_semantic_files_never_overwrite_target(tmp_path: Path) -> None:
source, target = _source(tmp_path), _target(tmp_path)
(target / "SKILL.md").write_text("commercial skill\n", encoding="utf-8")
before = (target / "SKILL.md").read_bytes()
manifest = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True)
assert (target / "SKILL.md").read_bytes() == before
row = next(item for item in manifest["semantic_merge_files"] if item["path"] == "SKILL.md")
assert row["status"] == "review_required"
assert manifest["operator_review_required"] is True
def test_protected_mapping_is_rejected_instead_of_silently_skipped(tmp_path: Path) -> None:
source, target = _source(tmp_path), _target(tmp_path)
policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": "frontend/src/stolen.ts", "license": "MIT"}])
with pytest.raises(importer.ImportRejected, match="protected_rejected"):
importer.build_manifest(source, policy_path=policy, target=target, apply=True)
assert not (target / "frontend/src/stolen.ts").exists()
@pytest.mark.parametrize("path", ["../escape", "/absolute/path", "safe/../../escape"])
def test_path_traversal_and_absolute_paths_are_rejected(tmp_path: Path, path: str) -> None:
source, target = _source(tmp_path), _target(tmp_path)
policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": path, "license": "MIT"}])
with pytest.raises(importer.ImportRejected, match="unsafe_path"):
importer.build_manifest(source, policy_path=policy, target=target)
def test_symlink_escape_is_rejected(tmp_path: Path) -> None:
source, target = _source(tmp_path), _target(tmp_path)
outside = tmp_path / "outside.md"
outside.write_text("outside\n", encoding="utf-8")
(source / "linked.md").symlink_to(outside)
policy = _policy(tmp_path, mirror=[{"source": "linked.md", "target": "references/upstream/linked.md", "license": "MIT"}])
with pytest.raises(importer.ImportRejected, match="symlink_escape|non_regular_or_symlink"):
importer.build_manifest(source, policy_path=policy, target=target)
def test_sensitive_source_is_rejected(tmp_path: Path) -> None:
source, target = _source(tmp_path), _target(tmp_path)
(source / ".env").write_text("OPENAI_API_KEY=not-a-real-key\n", encoding="utf-8")
policy = _policy(tmp_path, mirror=[{"source": ".env", "target": "references/upstream/env.txt", "license": "MIT"}])
with pytest.raises(importer.ImportRejected, match="sensitive_filename|sensitive_content"):
importer.build_manifest(source, policy_path=policy, target=target)
def test_overlapping_source_and_target_are_rejected(tmp_path: Path) -> None:
target = _target(tmp_path)
source = target / "source"
source.mkdir()
with pytest.raises(importer.ImportRejected, match="must_not_overlap"):
importer.build_manifest(source, policy_path=POLICY, target=target)
def test_apply_rolls_back_earlier_files_when_later_replace_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source, target = _source(tmp_path), _target(tmp_path)
(source / "second.md").write_text("second\n", encoding="utf-8")
policy = _policy(tmp_path, mirror=[
{"source": "SKILL.md", "target": "references/upstream/one.md", "license": "MIT"},
{"source": "second.md", "target": "references/upstream/two.md", "license": "MIT"},
])
original_replace = importer.os.replace
calls = 0
def fail_second(source_path: str, target_path: Path) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError("simulated replace failure")
original_replace(source_path, target_path)
monkeypatch.setattr(importer.os, "replace", fail_second)
with pytest.raises(OSError, match="simulated"):
importer.build_manifest(source, policy_path=policy, target=target, apply=True)
assert not (target / "references/upstream/one.md").exists()
assert not (target / "references/upstream/two.md").exists()