diff --git a/automation/reddit_output_transaction.psm1 b/automation/reddit_output_transaction.psm1 index efa4890..b88c9f4 100644 --- a/automation/reddit_output_transaction.psm1 +++ b/automation/reddit_output_transaction.psm1 @@ -1,5 +1,13 @@ Set-StrictMode -Version Latest +function Assert-RedditGitEnvironment { + $pattern = '^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|' + + 'NAMESPACE|.*PREFIX|SHALLOW_FILE|GRAFT_FILE|CONFIG.*|CEILING_DIRECTORIES|' + + 'DISCOVERY_ACROSS_FILESYSTEM|IMPLICIT_WORK_TREE|REPLACE_REF_BASE)$' + $rejected = @(Get-ChildItem Env: | Where-Object { $_.Name -match $pattern } | Select-Object -ExpandProperty Name) + if ($rejected.Count -gt 0) { throw "Rejected Git environment: $($rejected -join ', ')" } +} + function Resolve-RedditOutputPath { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, @@ -136,4 +144,4 @@ function Restore-RedditOutputSnapshot { Remove-RedditOutputSnapshot -Snapshot $Snapshot } -Export-ModuleMember -Function New-RedditOutputSnapshot, Restore-RedditOutputSnapshot, Restore-RedditOutputSnapshotPaths, Remove-RedditOutputSnapshot +Export-ModuleMember -Function New-RedditOutputSnapshot, Restore-RedditOutputSnapshot, Restore-RedditOutputSnapshotPaths, Remove-RedditOutputSnapshot, Assert-RedditGitEnvironment diff --git a/automation/run_reddit_baseline.ps1 b/automation/run_reddit_baseline.ps1 index ca81fea..ff33b41 100644 --- a/automation/run_reddit_baseline.ps1 +++ b/automation/run_reddit_baseline.ps1 @@ -1,3 +1,4 @@ +param([string]$PreparedMainSha = "") $ErrorActionPreference = "Stop" $repo = Split-Path -Parent $PSScriptRoot $py = Join-Path $repo ".venv311\Scripts\python.exe" @@ -20,12 +21,14 @@ $ignoredOutputs = @( function Run-Step { param([string]$Label, [string[]]$Command) + if ($Command[0] -eq "git") { Assert-RedditGitEnvironment } Write-Host "==> $Label" & $Command[0] $Command[1..($Command.Length - 1)] if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE" } } function Assert-CleanWorktree { + Assert-RedditGitEnvironment $status = @(git status --porcelain) if ($LASTEXITCODE -ne 0) { throw "git status failed" } $ignored = @(git status --porcelain --ignored --untracked-files=all -- datos/latest datos/history datos/metadata) @@ -36,6 +39,15 @@ function Assert-CleanWorktree { if ($LASTEXITCODE -ne 0 -or $currentBranch -ne "main") { throw "The automation requires clean main: $currentBranch" } } +function Assert-PreparedMainSha { + Assert-RedditGitEnvironment + if ($PreparedMainSha -notmatch '^[0-9a-f]{40}$') { throw "Missing or invalid prepared main SHA" } + $head = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $head -ne $PreparedMainSha) { throw "HEAD differs from prepared main SHA" } + $currentBranch = (git branch --show-current).Trim() + if ($LASTEXITCODE -ne 0 -or $currentBranch -ne "main") { throw "Prepared producer requires main" } +} + function Get-TopicMentions { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing Reddit topics CSV: $Path" } @@ -89,6 +101,7 @@ function Assert-RedditSourcePackage { function Invoke-SourcePublication { param([Parameter(Mandatory = $true)]$Snapshot) try { + Assert-PreparedMainSha Run-Step "git checkout branch" @("git", "checkout", "-b", $branch) Run-Step "git add target files" (@("git", "add", "--") + $filesToStage) Run-Step "git commit" @("git", "commit", "-m", "chore(data): refresh reddit source package $utcDate") @@ -128,6 +141,7 @@ function Invoke-SourcePublication { Set-Location $repo Assert-CleanWorktree +Assert-PreparedMainSha $baselineMentions = Get-TopicMentions -Path (Join-Path $repo $filesToStage[1]) $outputSnapshot = New-RedditOutputSnapshot -ProjectRoot $repo -RelativePaths ($filesToStage + $ignoredOutputs) $env:ETL_REFERENCE_DATE_UTC = $utcDate diff --git a/automation/run_reddit_baseline_guarded.ps1 b/automation/run_reddit_baseline_guarded.ps1 index 569299f..7a1a0a4 100644 --- a/automation/run_reddit_baseline_guarded.ps1 +++ b/automation/run_reddit_baseline_guarded.ps1 @@ -179,6 +179,26 @@ if (Test-Path $stateFile) { } } +$py = Join-Path $repo ".venv311\Scripts\python.exe" +Import-Module (Join-Path $PSScriptRoot "reddit_output_transaction.psm1") -Force +try { + Assert-RedditGitEnvironment + $preparation = @(& $py -I -B (Join-Path $repo "scripts\prepare_reddit_producer.py") --root $repo 2>&1) + $preparationExit = $LASTEXITCODE +} +catch { + $preparation = @("Preparation invocation failed; HEAD and fetched refs require inspection.") + if ($_.Exception.Message.StartsWith("Rejected Git environment:")) { + $preparation = @("Preparation preflight failed: " + $_.Exception.Message) + } + $preparationExit = 1 +} +if ($preparationExit -ne 0 -or $preparation.Count -ne 1 -or [string]$preparation[0] -notmatch '^[0-9a-f]{40}$') { + Write-Status -Outcome "preparation_failed" -Message "Producer preparation failed; preserve Git state for inspection." -WindowKey $windowKey + foreach ($line in $preparation) { Write-Log ([string]$line); Write-Output ([string]$line) } + exit 1 +} +$preparedSha = [string]$preparation[0] $currentUtcDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") $topicsLatest = Get-BridgeLatestDate -Path $topicsBridgePath $intersectionLatest = Get-BridgeLatestDate -Path $intersectionBridgePath @@ -195,7 +215,8 @@ if ($topicsLatest -eq $currentUtcDate -and $intersectionLatest -eq $currentUtcDa Write-Host "==> guarded run reason=$Reason window=$windowKey" Write-Log "guard executing main script for $windowKey" -$commandLine = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$mainScript`" 2>&1" +Assert-RedditGitEnvironment +$commandLine = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$mainScript`" -PreparedMainSha $preparedSha 2>&1" $runOutput = New-Object System.Collections.Generic.List[string] & cmd.exe /d /c $commandLine | ForEach-Object { $line = [string]$_ diff --git a/scripts/prepare_reddit_producer.py b/scripts/prepare_reddit_producer.py new file mode 100644 index 0000000..2776e1f --- /dev/null +++ b/scripts/prepare_reddit_producer.py @@ -0,0 +1,127 @@ +"""Prepare clean producer main without installing packages or replacing bootstrap code.""" + +import argparse +from importlib import metadata +import os +from pathlib import Path +import re +import subprocess +import sys +import uuid + +ORIGIN = "https://github.com/Sam-24-dev/Technology-trend-analysis-platform" +CONTROLS = ("automation/run_reddit_baseline_guarded.ps1", "automation/run_reddit_baseline.ps1", + "automation/reddit_output_transaction.psm1", "scripts/prepare_reddit_producer.py") +GIT_SELECTORS = (r"^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|" + r"NAMESPACE|.*PREFIX|SHALLOW_FILE|GRAFT_FILE|CONFIG.*|CEILING_DIRECTORIES|" + r"DISCOVERY_ACROSS_FILESYSTEM|IMPLICIT_WORK_TREE|REPLACE_REF_BASE)$") + + +def git(root, *args): + rejected = sorted(name for name in os.environ if re.match(GIT_SELECTORS, name, re.IGNORECASE)) + if rejected: + raise RuntimeError("Rejected Git environment: " + ", ".join(rejected)) + env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GCM_INTERACTIVE="Never", + GIT_SSH_COMMAND="ssh -oBatchMode=yes") + result = subprocess.run(["git", "-C", str(root), *args], + env=env, capture_output=True, text=True, timeout=60, check=False) + if result.returncode: + raise RuntimeError(f"Git {args[0]} failed; Git state was not rolled back") + return result.stdout.strip() + + +def clean_main(root): + if git(root, "branch", "--show-current") != "main": + raise RuntimeError("Producer requires main") + for operation in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-apply", + "rebase-merge", "sequencer", "index.lock"): + path = Path(git(root, "rev-parse", "--git-path", operation)) + if (path if path.is_absolute() else Path(root) / path).exists(): + raise RuntimeError("Git operation or index lock exists; preserve it for inspection") + if git(root, "status", "--porcelain", "--untracked-files=all") or any(line.startswith("!! ") for line in + git(root, "status", "--porcelain", "--ignored", "--untracked-files=all", "--", + "datos/latest", "datos/history", "datos/metadata").splitlines()): + raise RuntimeError("Existing changes or ignored outputs; refusing preparation") + return git(root, "rev-parse", "HEAD") + + +def dependencies(lock): + if sys.version_info[:2] != (3, 11) or sys.prefix == sys.base_prefix: + raise RuntimeError("An explicitly invoked isolated Python 3.11 is required") + pins = {} + for line in lock.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + match = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9_.-]*)==([0-9]+(?:\.[0-9]+)*(?:\.post[0-9]+)?)", line) + if not match: + raise RuntimeError("Unsupported lock syntax") + name, version = match.groups() + name = re.sub(r"[-_.]+", "-", name).lower() + if name in pins: + raise RuntimeError("Duplicate lock pin") + pins[name] = version + try: + installed = metadata.version(name) + except metadata.PackageNotFoundError as exc: + raise RuntimeError(f"Missing locked dependency: {name}") from exc + if installed != version: + raise RuntimeError(f"Installed dependency differs from frozen lock: {name}") + if not pins: + raise RuntimeError("Empty dependency lock") + + +def prepare(root, expected_origin=ORIGIN): + """The origin argument is for direct local fixtures; CLI never overrides production origin.""" + stage = "preflight" + head = target = "unknown" + try: + head = clean_main(root) + origin = git(root, "remote", "get-url", "--all", "origin").removesuffix(".git") + if origin != expected_origin.removesuffix(".git"): + raise RuntimeError("Unexpected origin") + stage = "fetch" + capture = f"refs/ttap/preparation/{uuid.uuid4().hex}" + git(root, "fetch", "--atomic", "--no-tags", "--no-recurse-submodules", "origin", + "refs/heads/main:refs/remotes/origin/main", f"refs/heads/main:{capture}") + target = git(root, "rev-parse", f"{capture}^{{commit}}") + git(root, "update-ref", "-d", capture, target) + stage = "admission" + git(root, "merge-base", "--is-ancestor", head, target) + original = git(root, "ls-tree", head, "--", *CONTROLS) + if len(original.splitlines()) != len(CONTROLS) or original != git(root, "ls-tree", target, "--", *CONTROLS): + raise RuntimeError("Bootstrap blobs or modes changed; activate reviewed controls explicitly") + stage = "dependencies" + dependencies(git(root, "show", f"{target}:backend/requirements.lock")) + stage = "advance" + if clean_main(root) != head: + raise RuntimeError("HEAD changed during preparation") + if head != target: + git(root, "merge", "--ff-only", target) + if clean_main(root) != target: + raise RuntimeError("Prepared HEAD differs from frozen main") + return target + except (RuntimeError, subprocess.TimeoutExpired, OSError, KeyboardInterrupt) as exc: + try: + actual = git(root, "rev-parse", "HEAD") + except (RuntimeError, subprocess.TimeoutExpired, OSError, KeyboardInterrupt): + actual = "unknown" + reason = str(exc) if isinstance(exc, RuntimeError) else type(exc).__name__ + raise RuntimeError(f"Preparation failed at {stage}; H={head}; T={target}; actualHEAD={actual}; " + f"fetched refs may have changed; no Git rollback: {reason}") from exc + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + args = parser.parse_args() + try: + print(prepare(args.root)) + except RuntimeError as exc: + print(str(exc)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_local_reddit_runner.py b/tests/test_local_reddit_runner.py index 3539f72..f160b2b 100644 --- a/tests/test_local_reddit_runner.py +++ b/tests/test_local_reddit_runner.py @@ -418,6 +418,7 @@ def test_runner_dirty_preflight_preserves_existing_bytes_and_stops_before_mutati $preamble = $runner.Substring(0, $runner.IndexOf('Set-Location $repo')) $preamble = $preamble -replace 'Import-Module.*\r?\n', '' $preamble = $preamble.Replace('$repo = Split-Path -Parent $PSScriptRoot', '$repo = $env:TEST_ROOT') +Import-Module $env:TRANSACTION_MODULE -Force Invoke-Expression $preamble $repo = $env:TEST_ROOT Set-Location $repo @@ -425,6 +426,7 @@ def test_runner_dirty_preflight_preserves_existing_bytes_and_stops_before_mutati ''' env = os.environ.copy() env["RUNNER_PATH"] = str(RUNNER_PATH) + env["TRANSACTION_MODULE"] = str(TRANSACTION_MODULE) env["TEST_ROOT"] = str(tmp_path) result = _run_powershell(script, env) @@ -432,4 +434,5 @@ def test_runner_dirty_preflight_preserves_existing_bytes_and_stops_before_mutati assert result.returncode == 0, result.stderr else: assert result.returncode != 0 + assert "Pre-existing changes or ignored outputs" in result.stderr assert target.read_bytes() == b"preexisting-bytes" diff --git a/tests/test_reddit_producer_preparation.py b/tests/test_reddit_producer_preparation.py new file mode 100644 index 0000000..4b078dd --- /dev/null +++ b/tests/test_reddit_producer_preparation.py @@ -0,0 +1,355 @@ +"""Preparation acceptance uses temporary local Git and mocked guarded children only.""" +import os +import hashlib +from pathlib import Path +import shutil +import subprocess +import sys +import uuid + +import pytest + +from scripts import prepare_reddit_producer as prep + +ROOT = Path(__file__).resolve().parents[1] +PS = shutil.which("powershell.exe") or shutil.which("pwsh") + + +@pytest.fixture(autouse=True) +def isolated_interpreter(monkeypatch): + monkeypatch.setattr(prep.sys, "base_prefix", "fixture-only-base-prefix") + + +def git(root, *args): + return subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True, text=True).stdout.strip() + + +def snapshot(root): + return {"HEAD": git(root, "rev-parse", "HEAD"), "refs": git(root, "show-ref"), + "symbolic": git(root, "symbolic-ref", "HEAD"), "status": git(root, "status", "--porcelain"), + "index": hashlib.sha256((root / ".git/index").read_bytes()).hexdigest(), + "FETCH_HEAD": ((root / ".git/FETCH_HEAD").read_bytes() + if (root / ".git/FETCH_HEAD").exists() else None), + "files": {p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in root.rglob("*") if p.is_file() and ".git" not in p.parts}} + + +@pytest.fixture +def producer(tmp_path, monkeypatch): + repo, remote = tmp_path / "producer", tmp_path / "remote.git" + repo.mkdir() + git(repo, "init", "-q") + subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) + for key, value in (("user.name", "Fixture"), ("user.email", "fixture@example.invalid"), + ("core.autocrlf", "true"), ("core.hooksPath", str(repo / ".git/hooks"))): + git(repo, "config", key, value) + for name in (*prep.CONTROLS, "backend/requirements.lock", "datos/reddit_temas_emergentes.csv"): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("sample==1.0\n" if name.endswith(".lock") else "old\n", encoding="utf-8") + (repo / ".gitignore").write_text("datos/latest/\ndatos/history/\ndatos/metadata/\n", encoding="utf-8") + (repo / ".gitattributes").write_text( + (ROOT / ".gitattributes").read_text(encoding="utf-8") + "*.ps1 text\n", encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-qm", "test: create baseline") + git(repo, "branch", "-M", "main") + baseline = git(repo, "rev-parse", "HEAD") + (repo / "datos/reddit_temas_emergentes.csv").write_bytes(b"fresh\n") + git(repo, "commit", "-qam", "test: refresh data") + target = git(repo, "rev-parse", "HEAD") + git(repo, "remote", "add", "origin", remote.as_posix()) + git(repo, "push", "-q", "origin", "main") + git(repo, "reset", "--hard", baseline) + monkeypatch.setattr(prep.metadata, "version", lambda name: "1.0") + return repo, remote.as_posix(), baseline, target + + +@pytest.mark.parametrize("kind", ["equal", "behind", "crlf", "tracking_ref"]) +def test_prepares_exact_main_and_fresh_baseline(producer, monkeypatch, kind): + repo, origin, baseline, target = producer + if kind == "equal": + git(repo, "reset", "--hard", target) + if kind == "crlf": + (repo / prep.CONTROLS[0]).write_bytes(b"old\r\n") + git(repo, "-c", "core.autocrlf=true", "add", "--", prep.CONTROLS[0]) + if kind == "tracking_ref": + injected = [] + original = prep.git + def command(root, *args): + if args[0] == "rev-parse" and args[1].startswith("refs/ttap/preparation/"): + git(repo, "update-ref", "refs/remotes/origin/main", baseline) + injected.append(True) + return original(root, *args) + monkeypatch.setattr(prep, "git", command) + assert prep.prepare(repo, origin) == target + if kind == "tracking_ref": + assert injected == [True] and git(repo, "rev-parse", "origin/main") == baseline + assert git(repo, "rev-parse", "HEAD") == target + assert not git(repo, "status", "--porcelain") + assert (repo / "datos/reddit_temas_emergentes.csv").read_bytes() == b"fresh\n" + + +@pytest.mark.parametrize("kind", ["dirty", "untracked", "ignored", "ahead", "diverged", "index.lock", "MERGE_HEAD", + "fetch", "advance", "intervening", "interrupt", "blob", "mode", + "missing", "mismatch", "origin"]) +def test_failures_preserve_main_files_and_report_stage(producer, monkeypatch, kind): + repo, origin, baseline, target = producer + if kind in {"blob", "mode"}: + git(repo, "reset", "--hard", target) + if kind == "blob": + (repo / prep.CONTROLS[0]).write_text("changed\n", encoding="utf-8") + git(repo, "add", prep.CONTROLS[0]) + else: + git(repo, "update-index", "--chmod=+x", prep.CONTROLS[0]) + git(repo, "commit", "-qm", "test: change bootstrap") + git(repo, "push", "-q", "origin", "main") + git(repo, "reset", "--hard", baseline) + if kind in {"ahead", "diverged"}: + if kind == "ahead": + git(repo, "reset", "--hard", target) + (repo / "local.txt").write_text("local", encoding="utf-8") + git(repo, "add", "local.txt") + git(repo, "commit", "-qm", "test: create local commit") + if kind == "dirty": + (repo / "datos/reddit_temas_emergentes.csv").write_bytes(b"protected") + if kind == "untracked": + git(repo, "config", "status.showUntrackedFiles", "no") + (repo / "protected.txt").write_bytes(b"protected") + if kind == "ignored": + path = repo / "datos/latest/protected.csv" + path.parent.mkdir() + path.write_bytes(b"protected") + if kind in {"index.lock", "MERGE_HEAD"}: + (repo / ".git" / kind).write_bytes(b"preserve operation") + if kind == "origin": + origin += "-unexpected" + if kind in {"missing", "mismatch"}: + def version(name): + if kind == "missing": + raise prep.metadata.PackageNotFoundError(name) + return "0.9" + monkeypatch.setattr(prep.metadata, "version", version) + original, steps = prep.git, [] + def command(root, *args): + steps.append(args[0]) + if args[0] == kind or (kind == "advance" and args[0] == "merge"): + raise RuntimeError("injected command failure") + if kind == "interrupt" and args[0] == "show": + raise KeyboardInterrupt + if kind == "intervening" and args[0] == "show": + git(repo, "reset", "--hard", target) + return original(root, *args) + monkeypatch.setattr(prep, "git", command) + before_head = git(repo, "rev-parse", "HEAD") + before = {p: p.read_bytes() for p in repo.rglob("*") if p.is_file() and ".git" not in p.parts} + with pytest.raises(RuntimeError, match="Preparation failed at") as failure: + prep.prepare(repo, origin) + preflight = {"dirty", "untracked", "ignored", "index.lock", "MERGE_HEAD", "origin"} + stage = "admission" + if kind in preflight: + stage = "preflight" + elif kind == "fetch": + stage = "fetch" + elif kind in {"advance", "intervening"}: + stage = "advance" + elif kind in {"missing", "mismatch", "interrupt"}: + stage = "dependencies" + assert f"Preparation failed at {stage};" in str(failure.value) + assert steps.count("fetch") == (0 if kind in preflight else 1) + assert git(repo, "rev-parse", "HEAD") == (target if kind == "intervening" else before_head) + if kind != "intervening": + assert before == {p: p.read_bytes() for p in repo.rglob("*") if p.is_file() and ".git" not in p.parts} + assert "merge" not in steps or kind == "advance" + if kind in {"index.lock", "MERGE_HEAD"}: + assert (repo / ".git" / kind).read_bytes() == b"preserve operation" + assert not (repo / "publication.txt").exists() + + +@pytest.mark.parametrize("lock", ["", "sample>=1.0", "sample==1.0; python_version>='3'", + "--index-url https://example.invalid", "sample==1.0\nsample==1.0"]) +def test_unsupported_locks_fail_closed(lock, monkeypatch): + monkeypatch.setattr(prep.metadata, "version", lambda name: "1.0") + with pytest.raises(RuntimeError): + prep.dependencies(lock) + + +def test_all_current_pins_and_isolated_python_are_checked(monkeypatch): + lock = (ROOT / "backend/requirements.lock").read_text(encoding="utf-8") + from importlib.metadata import version + checked = [] + monkeypatch.setattr(prep.metadata, "version", lambda name: (checked.append(name), version(name))[1]) + prep.dependencies(lock) + assert len(checked) == 29 + isolated = prep.sys.prefix + monkeypatch.setattr(prep.sys, "prefix", prep.sys.base_prefix) + with pytest.raises(RuntimeError, match="isolated Python"): + prep.dependencies(lock) + monkeypatch.setattr(prep.sys, "prefix", isolated) + monkeypatch.setattr(prep.sys, "version_info", (3, 12)) + with pytest.raises(RuntimeError, match="isolated Python"): + prep.dependencies(lock) + + +@pytest.mark.skipif(PS is None, reason="PowerShell required") +@pytest.mark.parametrize("kind", ["missing", "invalid", "drift", "branch"]) +def test_child_rejects_unprepared_revision_before_baseline(producer, kind): + repo, origin, baseline, target = producer + runner = (ROOT / "automation/run_reddit_baseline.ps1").read_text(encoding="utf-8") + assertion = runner[runner.index("function Assert-PreparedMainSha"):runner.index("function Get-TopicMentions")] + expected = "" if kind == "missing" else "invalid" if kind == "invalid" else target if kind == "drift" else baseline + if kind == "branch": + git(repo, "checkout", "-qb", "other") + code = 'Import-Module "' + str(ROOT / "automation/reddit_output_transaction.psm1") + '" -Force\n' + assertion + code += '\nSet-Location -LiteralPath "' + str(repo) + '"\n$PreparedMainSha="' + expected + '"\n' + result = subprocess.run([PS, "-NoProfile", "-NonInteractive", "-Command", + '$ErrorActionPreference="Stop"\n' + code + 'Assert-PreparedMainSha\nWrite-Output "BASELINE_REACHED=1"'], + capture_output=True, text=True, timeout=15) + assert result.returncode != 0 and "BASELINE_REACHED=1" not in result.stdout + assert git(repo, "rev-parse", "HEAD") == baseline + + +@pytest.mark.skipif(os.name != "nt" or PS is None, reason="Windows PowerShell runtime required") +@pytest.mark.parametrize("kind", ["failure", "dependency", "missing_python", "stderr", "interruption", "child", + "selectors", "child_selectors"]) +def test_guard_holds_mutex_and_preparation_failure_cannot_mark_success(tmp_path, kind): + automation = tmp_path / "automation" + automation.mkdir() + shutil.copyfile(ROOT / "automation/reddit_output_transaction.psm1", automation / "reddit_output_transaction.psm1") + marker = automation / "state/reddit_baseline_last_window.txt" + marker.parent.mkdir() + marker.write_bytes(b"2026-10-04") + name = "Global\\TTAP-preparation-" + uuid.uuid4().hex + guard = (ROOT / "automation/run_reddit_baseline_guarded.ps1").read_text(encoding="utf-8") + guard = guard.replace("Global\\TechnologyTrend-RedditBaselineWeekly", name).replace( + "$utcNow = (Get-Date).ToUniversalTime()", + "$utcNow = [datetime]::Parse('2026-10-12T02:00:00Z').ToUniversalTime()") + guard = guard.replace(" $popupFlags = @{", " return\n $popupFlags = @{") + if kind != "missing_python": + guard = guard.replace('& $py -I -B (Join-Path $repo "scripts\\prepare_reddit_producer.py") --root $repo', + '& powershell.exe -NoProfile -File (Join-Path $repo "preparation.ps1")') + if kind == "dependency": + probe_python = tmp_path / "dependency.py" + probe_python.write_text("import runpy\nm = runpy.run_path(" + + repr(str(ROOT / "scripts/prepare_reddit_producer.py")) + + ")\ntry: m['dependencies']('ttap-fixture-not-installed==1.0')\n" + "except RuntimeError as exc: print(exc); raise SystemExit(1)\n", encoding="utf-8") + guard = guard.replace('& powershell.exe -NoProfile -File (Join-Path $repo "preparation.ps1")', + '& "' + sys.executable + '" -I -B "' + str(probe_python) + '"') + probe = '$m=[Threading.Mutex]::new($false,"' + name + '");if($m.WaitOne(0)){throw "mutex not held"};$m.Dispose();' + (tmp_path / "preparation.ps1").write_text(probe + ("exit 7" if kind == "failure" else + '[Console]::Error.WriteLine("fixture native stderr");exit 7' if kind == "stderr" else + 'Write-Output "' + "a" * 40 + '"'), encoding="utf-8") + child = automation / "run_reddit_baseline.ps1" + child.write_text('param([string]$PreparedMainSha)\n' + probe + + 'if($PreparedMainSha -ne "' + "a" * 40 + '"){throw "wrong SHA"};Write-Output "CHILD_COMPLETED=1"', + encoding="utf-8") + if kind == "interruption": + guard = guard.replace('$preparedSha = [string]$preparation[0]', 'throw "fixture interruption"') + if kind == "child_selectors": + guard = guard.replace('$preparedSha = [string]$preparation[0]', + '$preparedSha = [string]$preparation[0]\n$env:GIT_DIR="' + str(tmp_path / "B/.git") + '"') + path = automation / "run_reddit_baseline_guarded.ps1" + path.write_text(guard, encoding="utf-8") + env = dict(os.environ) + if kind == "selectors": + env["GIT_DIR"] = str(tmp_path / "B/.git") + result = subprocess.run([PS, "-NoProfile", "-NonInteractive", "-File", str(path)], + env=env, capture_output=True, text=True, timeout=30) + assert (result.returncode == 0) is (kind == "child"), result.stderr + assert marker.read_text(encoding="ascii").strip() == ("2026-10-11" if kind == "child" else "2026-10-04") + assert ("CHILD_COMPLETED=1" in result.stdout) is (kind == "child") + if kind in {"failure", "dependency", "missing_python", "stderr", "selectors"}: + status = tmp_path / "automation/state/reddit_baseline_last_status.txt" + assert "outcome=preparation_failed" in status.read_text(encoding="utf-8-sig") + release = subprocess.run([PS, "-NoProfile", "-NonInteractive", "-Command", + '$m=[Threading.Mutex]::new($false,"' + name + '");if(-not $m.WaitOne(0)){exit 1};$m.ReleaseMutex();$m.Dispose()'], + check=False) + assert release.returncode == 0 + + +def test_revision_assertions_precede_baseline_and_publication(): + runner = (ROOT / "automation/run_reddit_baseline.ps1").read_text(encoding="utf-8") + runtime = runner.split("Set-Location $repo", 1)[1] + assert runtime.index("Assert-PreparedMainSha") < runtime.index("$baselineMentions =") + publication = runner.split("function Invoke-SourcePublication", 1)[1].split("Set-Location $repo", 1)[0] + assert publication.index("Assert-PreparedMainSha") < publication.index('Run-Step "git checkout branch"') + + +@pytest.mark.parametrize("selector", ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", "GIT_CONFIG_COUNT", "combined"]) +def test_selector_child_cannot_inspect_or_mutate_other_repository(producer, selector): + repo, origin, baseline, target = producer + other = repo.parent / "B" + subprocess.run(["git", "clone", "-q", origin, str(other)], check=True, capture_output=True) + git(other, "checkout", "-qB", "main", baseline) + git(other, "remote", "set-url", "origin", origin) + before, other_before = snapshot(repo), snapshot(other) + values = {"GIT_DIR": other / ".git", "GIT_WORK_TREE": other, "GIT_INDEX_FILE": other / ".git/index", + "GIT_COMMON_DIR": other / ".git", "GIT_OBJECT_DIRECTORY": other / ".git/objects"} + env = dict(os.environ, **({selector: str(values.get(selector, 1))} if selector != "combined" else {})) + if selector == "combined": + env.update(GIT_DIR=str(other / ".git"), GIT_WORK_TREE=str(other), GIT_INDEX_FILE=str(other / ".git/index")) + if selector == "GIT_CONFIG_COUNT": + env.update(GIT_CONFIG_KEY_0="core.worktree", GIT_CONFIG_VALUE_0=str(other)) + code = "import runpy,sys\nm=runpy.run_path(sys.argv[1])\nm['metadata'].version=lambda name:'1.0'\n" + code += "try: m['prepare'](sys.argv[2],sys.argv[3])\n" + code += "except RuntimeError as exc: print(exc); raise SystemExit(1)\n" + result = subprocess.run([sys.executable, "-I", "-B", "-c", code, + str(ROOT / "scripts/prepare_reddit_producer.py"), str(repo), origin], env=env, + capture_output=True, text=True, timeout=20) + assert result.returncode == 1 and "Preparation failed at preflight;" in result.stdout + expected_name = "GIT_DIR" if selector == "combined" else selector + assert "Rejected Git environment:" in result.stdout and expected_name in result.stdout + assert str(other) not in result.stdout + result.stderr + assert snapshot(repo) == before and snapshot(other) == other_before + assert prep.prepare(repo, origin) == target + assert snapshot(other) == other_before + + +def test_real_other_branch_fetch_cannot_replace_frozen_main(producer, monkeypatch): + repo, origin, baseline, target = producer + git(repo, "checkout", "-qb", "other", target) + (repo / "datos/reddit_temas_emergentes.csv").write_bytes(b"other\n") + git(repo, "commit", "-qam", "test: create other data branch") + other = git(repo, "rev-parse", "HEAD") + git(repo, "push", "-q", "origin", "other") + git(repo, "checkout", "-q", "main") + git(repo, "update-ref", "refs/ttap/preparation/existing", baseline) + before, original, injected = snapshot(repo), prep.git, [] + def command(root, *args): + if args[0] == "rev-parse" and args[1].startswith("refs/ttap/preparation/"): + original(root, "fetch", "--no-tags", "--no-recurse-submodules", "origin", "refs/heads/other") + injected.append(True) + return original(root, *args) + monkeypatch.setattr(prep, "git", command) + assert prep.prepare(repo, origin) == target and injected == [True] + assert git(repo, "rev-parse", "refs/remotes/origin/main") == target + assert git(repo, "rev-parse", "FETCH_HEAD^{commit}") == other + after = snapshot(repo) + assert after["HEAD"] == target and after["symbolic"] == before["symbolic"] and not after["status"] + assert after["refs"] == before["refs"].replace(baseline + " refs/heads/main", target + " refs/heads/main") + expected = dict(before["files"], **{"datos/reddit_temas_emergentes.csv": hashlib.sha256(b"fresh\n").hexdigest()}) + assert after["files"] == expected + + +@pytest.mark.skipif(PS is None, reason="PowerShell required") +@pytest.mark.parametrize("actor", ["Assert-CleanWorktree", "Assert-PreparedMainSha"]) +def test_actual_child_assertions_reject_repository_selectors(producer, actor): + repo, origin, baseline, target = producer + other = repo.parent / "B" + subprocess.run(["git", "clone", "-q", origin, str(other)], check=True, capture_output=True) + git(other, "checkout", "-qB", "main", target) + git(other, "remote", "set-url", "origin", origin) + before, other_before = snapshot(repo), snapshot(other) + runner = (ROOT / "automation/run_reddit_baseline.ps1").read_text(encoding="utf-8") + checks = runner[runner.index("function Assert-CleanWorktree"):runner.index("function Get-TopicMentions")] + code = '$ErrorActionPreference="Stop"\nImport-Module "' + str(ROOT / "automation/reddit_output_transaction.psm1") + code += '" -Force\n' + checks + '\nSet-Location "' + str(repo) + '"\n$PreparedMainSha="' + target + '"\n' + env = dict(os.environ, GIT_DIR=str(other / ".git"), GIT_WORK_TREE=str(other), + GIT_INDEX_FILE=str(other / ".git/index")) + result = subprocess.run([PS, "-NoProfile", "-NonInteractive", "-Command", code + actor + + '\nWrite-Output "ACTOR_REACHED=1"'], env=env, capture_output=True, text=True, timeout=20) + assert result.returncode != 0 and "Rejected Git environment:" in result.stderr + assert "ACTOR_REACHED=1" not in result.stdout and str(other) not in result.stderr + assert snapshot(repo) == before and snapshot(other) == other_before diff --git a/tests/test_reddit_source_package.py b/tests/test_reddit_source_package.py index 0e2df89..6f2817c 100644 --- a/tests/test_reddit_source_package.py +++ b/tests/test_reddit_source_package.py @@ -263,6 +263,7 @@ def test_late_publication_failure_restores_local_bytes_without_real_git(tmp_path param([string]$Label, [string[]]$Command) if ($Label -eq "__FAILING_STEP__" -or "__FAILING_STEP__" -eq "restore") { throw "fixture publication failure" } } +function Assert-PreparedMainSha {} function gh { if ("__FAILING_STEP__" -eq "gh pr create") { throw "fixture publication failure" } $global:LASTEXITCODE = 0 @@ -366,6 +367,7 @@ def git(*args, check=True): return "https://example.invalid/pr" } Set-Location $repo +$PreparedMainSha = (git rev-parse HEAD).Trim() Invoke-SourcePublication -Snapshot $outputSnapshot '''.replace("__FAILURE__", failure), encoding="utf-8",