cbm-attach 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cbm_attach/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CBM attach evidence tooling (v0.1.1)."""
2
+
3
+ __version__ = "0.1.1"
cbm_attach/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,397 @@
1
+ """Build-once artifact handling and GitHub artifact download.
2
+
3
+ Hard rule: CBM never builds the package. It downloads the artifact produced by
4
+ the build-once job, verifies every hash in ``WHEEL_SHA256`` and
5
+ ``RELEASE_ASSETS.sha256``, and only then may the uploader see the file.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import zipfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path, PurePosixPath
18
+ from typing import Any
19
+
20
+ from .reasons import GateError, InfraError, InputError, Reason
21
+ from .reports import Report, find_report_file, load_report
22
+
23
+
24
+ HEX64_RE = re.compile(r"^[a-fA-F0-9]{64}$")
25
+ MAX_EXTRACT_BYTES = 512 * 1024 * 1024
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Artifact:
30
+ name: str
31
+ path: Path
32
+ sha256: str
33
+ assets: dict[str, str]
34
+
35
+
36
+ def _normalise_hash(value: str) -> str:
37
+ value = value.strip()
38
+ if value.startswith("sha256:"):
39
+ value = value[len("sha256:") :]
40
+ return value.lower()
41
+
42
+
43
+ def _safe_relative(raw: str) -> PurePosixPath:
44
+ text = raw.replace("\\", "/").strip()
45
+ if not text:
46
+ raise InputError("empty path in hash manifest", reason=Reason.SCHEMA_VIOLATION)
47
+ path = PurePosixPath(text)
48
+ if (
49
+ path.is_absolute()
50
+ or any(part in {"", ".", ".."} for part in path.parts)
51
+ or any(":" in part for part in path.parts)
52
+ ):
53
+ raise InputError(
54
+ f"unsafe path in hash manifest: {raw!r}",
55
+ reason=Reason.SCHEMA_VIOLATION,
56
+ )
57
+ return path
58
+
59
+
60
+ def _parse_hash_manifest(path: Path) -> dict[str, str]:
61
+ """Parse ``<sha256> <path>`` lines, allowing a ``sha256:`` prefix."""
62
+ if not path.is_file():
63
+ raise InputError(
64
+ f"{path}: hash manifest not found",
65
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
66
+ )
67
+ result: dict[str, str] = {}
68
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
69
+ text = line.strip()
70
+ if not text or text.startswith("#"):
71
+ continue
72
+ parts = text.split(None, 1)
73
+ digest = _normalise_hash(parts[0])
74
+ if not HEX64_RE.fullmatch(digest):
75
+ raise InputError(
76
+ f"{path}:{line_number}: invalid sha256 {parts[0]!r}",
77
+ reason=Reason.SCHEMA_VIOLATION,
78
+ )
79
+ if len(parts) == 1:
80
+ key = ""
81
+ else:
82
+ raw_path = parts[1].strip().lstrip("*").strip()
83
+ key = str(_safe_relative(raw_path))
84
+ if key in result and result[key] != digest:
85
+ raise InputError(
86
+ f"{path}:{line_number}: conflicting digests for {key!r}",
87
+ reason=Reason.REPORT_INCONSISTENT,
88
+ )
89
+ result[key] = digest
90
+ if not result:
91
+ raise InputError(
92
+ f"{path}: no hash entries",
93
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
94
+ )
95
+ return result
96
+
97
+
98
+ def _hash_file(path: Path) -> str:
99
+ digest = hashlib.sha256()
100
+ with path.open("rb") as handle:
101
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
102
+ digest.update(chunk)
103
+ return digest.hexdigest()
104
+
105
+
106
+ def _lookup(mapping: dict[str, str], wheel: Path, root: Path) -> str | None:
107
+ candidates = []
108
+ try:
109
+ candidates.append(str(wheel.resolve().relative_to(root.resolve())).replace("\\", "/"))
110
+ except ValueError:
111
+ pass
112
+ candidates.extend([wheel.name, f"dist/{wheel.name}", f"./{wheel.name}"])
113
+ for key in candidates:
114
+ if key in mapping:
115
+ return mapping[key]
116
+ return mapping.get("")
117
+
118
+
119
+ def locate_build_once_root(root: str | Path) -> Path:
120
+ """Find the directory containing ``WHEEL_SHA256`` inside an artifact zip."""
121
+ base = Path(root)
122
+ direct = base / "WHEEL_SHA256"
123
+ if direct.is_file():
124
+ return base
125
+ matches = sorted(base.rglob("WHEEL_SHA256"))
126
+ if len(matches) == 1:
127
+ return matches[0].parent
128
+ raise InputError(
129
+ f"{base}: expected exactly one WHEEL_SHA256, found {len(matches)}",
130
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
131
+ )
132
+
133
+
134
+ def verify_build_once(directory: str | Path) -> Artifact:
135
+ """Verify the build-once directory and return the single wheel."""
136
+ root = Path(directory)
137
+ if not root.is_dir():
138
+ raise InputError(
139
+ f"{root}: build-once artifact directory not found",
140
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
141
+ )
142
+
143
+ wheel_hashes = _parse_hash_manifest(root / "WHEEL_SHA256")
144
+ asset_hashes = _parse_hash_manifest(root / "RELEASE_ASSETS.sha256")
145
+
146
+ search_root = root / "dist" if (root / "dist").is_dir() else root
147
+ wheels = sorted(search_root.glob("*.whl"))
148
+ if len(wheels) != 1:
149
+ raise InputError(
150
+ f"{root}: expected exactly one .whl, found {len(wheels)}",
151
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
152
+ )
153
+ wheel = wheels[0]
154
+ if wheel.is_symlink():
155
+ raise InputError(f"{wheel}: symlink is not allowed", reason=Reason.SCHEMA_VIOLATION)
156
+
157
+ actual = _hash_file(wheel)
158
+ expected = _lookup(wheel_hashes, wheel, root)
159
+ if expected is None:
160
+ raise InputError(
161
+ f"{wheel.name}: not listed in WHEEL_SHA256",
162
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
163
+ )
164
+ if actual != expected:
165
+ raise GateError(
166
+ f"{wheel.name}: sha256 {actual} != WHEEL_SHA256 {expected}",
167
+ reason=Reason.ARTIFACT_HASH_MISMATCH,
168
+ )
169
+
170
+ release_expected = _lookup(asset_hashes, wheel, root)
171
+ if release_expected is None:
172
+ raise GateError(
173
+ f"{wheel.name}: not listed in RELEASE_ASSETS.sha256",
174
+ reason=Reason.ARTIFACT_HASH_MISMATCH,
175
+ )
176
+ if release_expected != actual:
177
+ raise GateError(
178
+ f"{wheel.name}: sha256 {actual} != RELEASE_ASSETS.sha256 {release_expected}",
179
+ reason=Reason.ARTIFACT_HASH_MISMATCH,
180
+ )
181
+
182
+ for relative, expected_hash in asset_hashes.items():
183
+ if not relative:
184
+ continue
185
+ # §11: the inventory lists published assets except itself. A manifest
186
+ # that still self-lists must not be self-hashed.
187
+ if relative == "RELEASE_ASSETS.sha256":
188
+ continue
189
+ parts = _safe_relative(relative).parts
190
+ candidate = root.joinpath(*parts)
191
+ if not candidate.is_file() and len(parts) == 1 and (root / "dist").is_dir():
192
+ # §11 uses bare basenames; the wheel may live under dist/.
193
+ candidate = root / "dist" / parts[0]
194
+ resolved_root = root.resolve()
195
+ resolved_candidate = candidate.resolve()
196
+ if (
197
+ resolved_root != resolved_candidate
198
+ and resolved_root not in resolved_candidate.parents
199
+ ):
200
+ raise InputError(
201
+ f"{candidate}: listed path escapes the build-once directory",
202
+ reason=Reason.SCHEMA_VIOLATION,
203
+ )
204
+ if not candidate.is_file():
205
+ raise InputError(
206
+ f"{candidate}: listed in RELEASE_ASSETS.sha256 but missing",
207
+ reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
208
+ )
209
+ if candidate.is_symlink():
210
+ raise InputError(
211
+ f"{candidate}: symlink is not allowed",
212
+ reason=Reason.SCHEMA_VIOLATION,
213
+ )
214
+ if _hash_file(candidate) != expected_hash:
215
+ raise GateError(
216
+ f"{candidate}: sha256 mismatch against RELEASE_ASSETS.sha256",
217
+ reason=Reason.ARTIFACT_HASH_MISMATCH,
218
+ )
219
+
220
+ return Artifact(
221
+ name=wheel.name,
222
+ path=wheel,
223
+ sha256=actual,
224
+ assets=asset_hashes,
225
+ )
226
+
227
+
228
+ def extract_zip(zip_path: str | Path, destination: str | Path) -> Path:
229
+ """Extract an artifact zip without path traversal or symlinks."""
230
+ archive = Path(zip_path)
231
+ root = Path(destination)
232
+ root.mkdir(parents=True, exist_ok=True)
233
+ if not zipfile.is_zipfile(archive):
234
+ raise InputError(
235
+ f"{archive}: not a zip archive",
236
+ reason=Reason.SCHEMA_VIOLATION,
237
+ )
238
+ total = 0
239
+ with zipfile.ZipFile(archive) as handle:
240
+ for info in handle.infolist():
241
+ name = info.filename.replace("\\", "/")
242
+ relative = _safe_relative(name)
243
+ mode = (info.external_attr >> 16) & 0o170000
244
+ if mode == 0o120000:
245
+ raise InputError(
246
+ f"{archive}: symlink member is not allowed: {name!r}",
247
+ reason=Reason.SCHEMA_VIOLATION,
248
+ )
249
+ total += max(0, info.file_size)
250
+ if total > MAX_EXTRACT_BYTES:
251
+ raise InputError(
252
+ f"{archive}: extracted size exceeds {MAX_EXTRACT_BYTES} bytes",
253
+ reason=Reason.SCHEMA_VIOLATION,
254
+ )
255
+ target = root.joinpath(*relative.parts)
256
+ resolved_root = root.resolve()
257
+ resolved_target = target.resolve()
258
+ if resolved_root != resolved_target and resolved_root not in resolved_target.parents:
259
+ raise InputError(
260
+ f"{archive}: path escapes destination: {name!r}",
261
+ reason=Reason.SCHEMA_VIOLATION,
262
+ )
263
+ if info.is_dir():
264
+ target.mkdir(parents=True, exist_ok=True)
265
+ continue
266
+ target.parent.mkdir(parents=True, exist_ok=True)
267
+ with handle.open(info) as source, target.open("wb") as sink:
268
+ shutil.copyfileobj(source, sink)
269
+ return root
270
+
271
+
272
+ def _classify_gh_error(stderr: str, *, missing_reason: Reason) -> Exception:
273
+ text = stderr.lower()
274
+ if "http 404" in text or "not found" in text:
275
+ return InputError(f"artifact not found: {stderr.strip()}", reason=missing_reason)
276
+ if "http 403" in text or "http 401" in text or "forbidden" in text:
277
+ return InputError(
278
+ f"artifact access forbidden: {stderr.strip()}",
279
+ reason=Reason.ARTIFACT_FORBIDDEN,
280
+ )
281
+ return InfraError(
282
+ f"artifact fetch failed: {stderr.strip()[:500]}",
283
+ reason=Reason.INFRA_FETCH_FAILED,
284
+ )
285
+
286
+
287
+ def fetch_artifact(
288
+ *,
289
+ repository: str,
290
+ run_id: str,
291
+ artifact_name: str,
292
+ destination: str | Path,
293
+ missing_reason: Reason,
294
+ gh_bin: str = "gh",
295
+ ) -> Path:
296
+ """Download one run artifact through ``gh api`` and extract it."""
297
+ if not re.fullmatch(r"[0-9]+", str(run_id)):
298
+ raise InputError(f"invalid run id: {run_id!r}", reason=Reason.MISSING_ARGUMENT)
299
+ if not artifact_name:
300
+ raise InputError("empty artifact name", reason=Reason.MISSING_ARGUMENT)
301
+
302
+ try:
303
+ listed = subprocess.run(
304
+ [
305
+ gh_bin,
306
+ "api",
307
+ f"repos/{repository}/actions/runs/{run_id}/artifacts?per_page=100",
308
+ ],
309
+ capture_output=True,
310
+ text=True,
311
+ timeout=120,
312
+ check=False,
313
+ )
314
+ except FileNotFoundError as exc:
315
+ raise InfraError(
316
+ f"{gh_bin}: command not found",
317
+ reason=Reason.INFRA_FETCH_FAILED,
318
+ ) from exc
319
+ except subprocess.TimeoutExpired as exc:
320
+ raise InfraError(
321
+ f"{gh_bin} timed out listing artifacts",
322
+ reason=Reason.INFRA_FETCH_FAILED,
323
+ ) from exc
324
+
325
+ if listed.returncode != 0:
326
+ raise _classify_gh_error(listed.stderr, missing_reason=missing_reason)
327
+
328
+ try:
329
+ payload: dict[str, Any] = json.loads(listed.stdout)
330
+ except json.JSONDecodeError as exc:
331
+ raise InfraError(
332
+ f"cannot parse artifact listing: {exc}",
333
+ reason=Reason.INFRA_FETCH_FAILED,
334
+ ) from exc
335
+ match = None
336
+ for item in payload.get("artifacts", []):
337
+ if isinstance(item, dict) and item.get("name") == artifact_name:
338
+ match = item
339
+ break
340
+ if match is None:
341
+ raise InputError(
342
+ f"run {run_id}: artifact {artifact_name!r} not found",
343
+ reason=missing_reason,
344
+ )
345
+ artifact_id = match.get("id")
346
+ if not isinstance(artifact_id, int):
347
+ raise InfraError(
348
+ f"run {run_id}: artifact {artifact_name!r} has no id",
349
+ reason=Reason.INFRA_FETCH_FAILED,
350
+ )
351
+
352
+ root = Path(destination)
353
+ root.mkdir(parents=True, exist_ok=True)
354
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as handle:
355
+ zip_path = Path(handle.name)
356
+ try:
357
+ with zip_path.open("wb") as sink:
358
+ download = subprocess.run(
359
+ [
360
+ gh_bin,
361
+ "api",
362
+ f"repos/{repository}/actions/artifacts/{artifact_id}/zip",
363
+ ],
364
+ stdout=sink,
365
+ stderr=subprocess.PIPE,
366
+ timeout=300,
367
+ check=False,
368
+ )
369
+ if download.returncode != 0:
370
+ stderr = download.stderr.decode("utf-8", "replace") if download.stderr else ""
371
+ raise _classify_gh_error(stderr, missing_reason=missing_reason)
372
+ return extract_zip(zip_path, root)
373
+ finally:
374
+ zip_path.unlink(missing_ok=True)
375
+
376
+
377
+ def fetch_report(
378
+ *,
379
+ repository: str,
380
+ run_id: str,
381
+ artifact_name: str,
382
+ kind: str,
383
+ destination: str | Path,
384
+ gh_bin: str = "gh",
385
+ ) -> Report:
386
+ """Fetch an artifact and load the single report JSON it contains."""
387
+ missing = Reason.MISSING_CBE_REPORT if kind == "cbe" else Reason.MISSING_CBP_REPORT
388
+ root = fetch_artifact(
389
+ repository=repository,
390
+ run_id=run_id,
391
+ artifact_name=artifact_name,
392
+ destination=destination,
393
+ missing_reason=missing,
394
+ gh_bin=gh_bin,
395
+ )
396
+ report_path = find_report_file(root, kind)
397
+ return load_report(report_path, kind)
cbm_attach/body.py ADDED
@@ -0,0 +1,173 @@
1
+ """Release-body fragments for CBM.
2
+
3
+ CBM never writes the whole CBS Release body. It emits one marker-delimited
4
+ fragment. The CBS body generator merges it after publish; an asynchronous CBM
5
+ run may replace exactly this marker block only after a verified upload, using a
6
+ read-modify-write against the latest body. Failure leaves the body untouched.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+
14
+ MARKER_START = "<!-- CBM:START -->"
15
+ MARKER_END = "<!-- CBM:END -->"
16
+ P2_04_PHRASE = "declared as attached but not uploaded"
17
+
18
+
19
+ def _text(value: Any, *, fallback: str = "not available") -> str:
20
+ if value is None:
21
+ return fallback
22
+ text = str(value).replace("`", "'").replace("\r", " ").replace("\n", " ").strip()
23
+ return text or fallback
24
+
25
+
26
+ def _hash(value: Any) -> str:
27
+ return _text(value, fallback="not available")
28
+
29
+
30
+ def _declared(report: dict[str, Any]) -> bool:
31
+ return report.get("declared_attached") is True
32
+
33
+
34
+ def render_not_uploaded(report: dict[str, Any]) -> str:
35
+ declared = _declared(report)
36
+ lines = [
37
+ "## NOT_UPLOADED",
38
+ "",
39
+ "- CBM: NOT_UPLOADED",
40
+ f" - cbm_gate_status: `{_text(report.get('cbm_gate_status'))}`",
41
+ " - upload_status: `NOT_UPLOADED`",
42
+ f" - reason: `{_text(report.get('reason'))}`",
43
+ f" - declared_attached: `{str(declared).lower()}`",
44
+ f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
45
+ f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
46
+ f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
47
+ f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
48
+ "- Skip accounting: `NOT_UPLOADED` is not counted in the SKIP ratio (F6).",
49
+ ]
50
+ if declared:
51
+ lines += [
52
+ "- Publish impact: `NOT_UPLOADED` does not block the CBS main package by itself (F6).",
53
+ "",
54
+ "CBM is declared as attached in this release train but was not uploaded:",
55
+ f"{P2_04_PHRASE}(声明附带但未上传)。",
56
+ ]
57
+ else:
58
+ lines += [
59
+ "- Publish impact: CBM is not claimed by this release train; the CBS main package is unaffected (F6).",
60
+ ]
61
+ detail = report.get("reason_detail")
62
+ if detail:
63
+ lines += ["", f"> {_text(detail)}"]
64
+ return "\n".join(lines) + "\n"
65
+
66
+
67
+ def render_infra(report: dict[str, Any]) -> str:
68
+ declared = _declared(report)
69
+ lines = [
70
+ "## Infrastructure failures",
71
+ "",
72
+ "- CBM: NOT_UPLOADED",
73
+ f" - cbm_gate_status: `{_text(report.get('cbm_gate_status'))}`",
74
+ " - upload_status: `NOT_UPLOADED`",
75
+ f" - reason: `{_text(report.get('reason'))}`",
76
+ f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
77
+ f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
78
+ f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
79
+ f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
80
+ "- Skip accounting: `SKIPPED_INFRA` is not counted in the SKIP ratio (F5).",
81
+ "- Publish impact: CBM upload is blocked; the CBS main package is not blocked by CBM (F6).",
82
+ ]
83
+ detail = report.get("reason_detail")
84
+ if detail:
85
+ lines += ["", f"> {_text(detail)}"]
86
+ if declared:
87
+ lines += [
88
+ "",
89
+ "CBM is declared as attached in this release train but was not uploaded:",
90
+ f"{P2_04_PHRASE}(声明附带但未上传)。",
91
+ ]
92
+ return "\n".join(lines) + "\n"
93
+
94
+
95
+ def render_unverified(report: dict[str, Any]) -> str:
96
+ lines = [
97
+ "## Upload verification failures",
98
+ "",
99
+ "- CBM: UPLOADED_UNVERIFIED",
100
+ f" - cbm_gate_status: `{_text(report.get('cbm_gate_status'))}`",
101
+ " - upload_status: `UPLOADED_UNVERIFIED`",
102
+ f" - reason: `{_text(report.get('reason'))}`",
103
+ f" - asset: `{_text(report.get('artifact_name'))}`",
104
+ f" - artifact_sha256: `{_hash(report.get('artifact_sha256'))}`",
105
+ f" - uploaded_asset_digest: `{_hash(report.get('uploaded_asset_digest'))}`",
106
+ f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
107
+ f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
108
+ f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
109
+ f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
110
+ "",
111
+ "This asset must not be treated as PASS. Re-run the upload or regenerate the release asset.",
112
+ ]
113
+ detail = report.get("reason_detail")
114
+ if detail:
115
+ lines += ["", f"> {_text(detail)}"]
116
+ return "\n".join(lines) + "\n"
117
+
118
+
119
+ def render_uploaded(report: dict[str, Any]) -> str:
120
+ lines = [
121
+ "## Optional component attachments",
122
+ "",
123
+ "- CBM: UPLOADED_VERIFIED",
124
+ " - cbm_gate_status: `PASS`",
125
+ " - upload_status: `UPLOADED_VERIFIED`",
126
+ f" - asset: `{_text(report.get('artifact_name'))}`",
127
+ f" - artifact_sha256: `{_hash(report.get('artifact_sha256'))}`",
128
+ f" - uploaded_asset_digest: `{_hash(report.get('uploaded_asset_digest'))}`",
129
+ f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
130
+ f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
131
+ f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
132
+ f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
133
+ ]
134
+ url = report.get("uploaded_asset_url")
135
+ if url:
136
+ lines.append(f" - uploaded_asset_url: {_text(url)}")
137
+ return "\n".join(lines) + "\n"
138
+
139
+
140
+ def render(report: dict[str, Any]) -> str:
141
+ """Render the body section for this report, without markers."""
142
+ status = report.get("upload_status")
143
+ if status == "UPLOADED_VERIFIED":
144
+ return render_uploaded(report)
145
+ if status == "UPLOADED_UNVERIFIED":
146
+ return render_unverified(report)
147
+ if report.get("cbm_gate_status") == "SKIPPED_INFRA":
148
+ return render_infra(report)
149
+ return render_not_uploaded(report)
150
+
151
+
152
+ def wrap_marker(text: str) -> str:
153
+ return f"{MARKER_START}\n{text.rstrip()}\n{MARKER_END}\n"
154
+
155
+
156
+ def render_marked(report: dict[str, Any]) -> str:
157
+ return wrap_marker(render(report))
158
+
159
+
160
+ def replace_marker_block(existing: str, block: str) -> str | None:
161
+ """Replace exactly one marker block. Return None when markers are absent.
162
+
163
+ The caller must not append a second section by guesswork. A missing marker
164
+ means the CBS body generator did not reserve the block; regenerate the body
165
+ instead of risking duplicate or stale evidence.
166
+ """
167
+ pattern = re.compile(
168
+ re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END),
169
+ re.DOTALL,
170
+ )
171
+ if not pattern.search(existing):
172
+ return None
173
+ return pattern.sub(lambda _match: wrap_marker(block).rstrip(), existing, count=1)