运维(08-28-production-arm-backup-transfer-incident): 固化只读调查合同

这个提交包含在:
2026-08-28 13:26:37 +08:00
父节点 a1c59e2a78
当前提交 573c46fbe7
共修改 10 个文件,包含 1153 行新增和 2 行删除
+505
查看文件
@@ -0,0 +1,505 @@
#!/usr/bin/env python3
"""Read-only, redacted inspection of an interrupted automatic-backup chain."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import stat
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
BATCH_RE = re.compile(r"^hyb-oa-backup-(auto-(\d{8}T\d{6}Z))$")
PARTIAL_RE = re.compile(r"^hyb-oa-backup-(auto-(\d{8}T\d{6}Z))\.partial$")
TEMP_RE = re.compile(r"^\.hyb-oa-backup-.+\.tmp\..+$")
EXPECTED_FILES = {"database.dump", "storage.tar.gz", "manifest.json"}
ARTIFACTS = {"database": "database.dump", "storage": "storage.tar.gz"}
RSYNC_CODE_RE = re.compile(r"rsync error:[^\r\n]*\(code (11|12|23|30|255)\)")
RSYNC_CATEGORIES = {
"11": "file_io_error",
"12": "protocol_stream_error",
"23": "partial_transfer_error",
"30": "transport_timeout",
"255": "ssh_session_error",
}
SIMILAR_RELATIVE_PPM = 50_000
SIMILAR_ABSOLUTE_BYTES = 64 * 1024 * 1024
SAFE_KEY_RE = re.compile(r"^[a-z0-9_]+$")
SAFE_VALUE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9_]*|-?[0-9]+)$")
class InspectionError(Exception):
"""Stable, non-sensitive inspection failure."""
def __init__(self, category: str):
super().__init__(category)
self.category = category
@dataclass(frozen=True)
class BatchResult:
valid: bool
total_bytes: int
created_epoch: int | None
expected_artifact_bytes: int | None
received_artifact_bytes: int
def emit(items: Iterable[tuple[str, object]]) -> None:
rendered_items: list[tuple[str, str]] = []
for key, value in items:
if isinstance(value, bool):
rendered = "yes" if value else "no"
else:
rendered = str(value)
if not SAFE_KEY_RE.fullmatch(key) or not SAFE_VALUE_RE.fullmatch(rendered):
print("probe_result=FAIL")
print("probe_category=output_allowlist_violation")
raise SystemExit(1)
rendered_items.append((key, rendered))
for key, rendered in rendered_items:
print(f"{key}={rendered}")
def safe_root(path: Path) -> Path:
try:
if not path.is_absolute() or path.is_symlink() or not path.is_dir():
raise InspectionError("root_invalid")
list(path.iterdir())
except PermissionError as error:
raise InspectionError("access_denied") from error
except OSError as error:
raise InspectionError("root_unreadable") from error
return path
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def tree_bytes(path: Path) -> int:
total = 0
try:
for root, directories, files in os.walk(path, followlinks=False):
directories[:] = [
name for name in directories if not (Path(root) / name).is_symlink()
]
for name in files:
item = Path(root) / name
if item.is_symlink():
continue
total += item.stat().st_size
except PermissionError as error:
raise InspectionError("access_denied") from error
except OSError as error:
raise InspectionError("entry_unreadable") from error
return total
def run_epoch(stamp: str) -> int | None:
try:
parsed = datetime.strptime(stamp, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
except ValueError:
return None
return int(parsed.timestamp())
def validate_batch(path: Path, expected_run_id: str, stamp: str) -> BatchResult:
total = tree_bytes(path)
received_artifact_bytes = 0
expected_artifact_bytes: int | None = None
created_epoch = run_epoch(stamp)
if created_epoch is None:
return BatchResult(False, total, None, None, 0)
try:
entries = list(path.iterdir())
regular_names = {
item.name
for item in entries
if not item.is_symlink() and stat.S_ISREG(item.stat().st_mode)
}
if len(entries) != 3 or regular_names != EXPECTED_FILES:
return BatchResult(False, total, created_epoch, None, 0)
manifest = json.loads((path / "manifest.json").read_text(encoding="utf-8"))
if (
manifest.get("schemaVersion") != 1
or manifest.get("status") != "complete"
or manifest.get("runId") != expected_run_id
or not (manifest.get("buildSha") or manifest.get("sourceImageDigest"))
):
return BatchResult(False, total, created_epoch, None, 0)
artifacts = manifest.get("artifacts") or {}
expected_artifact_bytes = 0
for key, expected_name in ARTIFACTS.items():
item = artifacts.get(key) or {}
target = path / expected_name
if item.get("file") != expected_name or isinstance(item.get("bytes"), bool):
return BatchResult(False, total, created_epoch, None, 0)
expected_size = item.get("bytes")
if not isinstance(expected_size, int) or expected_size < 0:
return BatchResult(False, total, created_epoch, None, 0)
expected_artifact_bytes += expected_size
actual_size = target.stat().st_size
received_artifact_bytes += actual_size
if actual_size != expected_size or file_sha256(target) != item.get("sha256"):
return BatchResult(
False,
total,
created_epoch,
expected_artifact_bytes,
received_artifact_bytes,
)
return BatchResult(
True,
total,
created_epoch,
expected_artifact_bytes,
received_artifact_bytes,
)
except PermissionError as error:
raise InspectionError("access_denied") from error
except (AttributeError, json.JSONDecodeError, OSError, TypeError, ValueError):
return BatchResult(False, total, created_epoch, expected_artifact_bytes, received_artifact_bytes)
def inspect_partial(path: Path, run_id: str, stamp: str) -> BatchResult:
"""Measure a partial without requiring the complete three-file contract."""
total = tree_bytes(path)
received = 0
expected: int | None = None
try:
manifest_path = path / "manifest.json"
if manifest_path.is_file() and not manifest_path.is_symlink():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("runId") == run_id and manifest.get("schemaVersion") == 1:
artifacts = manifest.get("artifacts") or {}
sizes: list[int] = []
for key, expected_name in ARTIFACTS.items():
item = artifacts.get(key) or {}
size = item.get("bytes")
if (
item.get("file") != expected_name
or isinstance(size, bool)
or not isinstance(size, int)
or size < 0
):
sizes = []
break
sizes.append(size)
target = path / expected_name
if target.is_file() and not target.is_symlink():
received += target.stat().st_size
if len(sizes) == 2:
expected = sum(sizes)
validated = validate_batch(path, run_id, stamp)
return BatchResult(validated.valid, total, run_epoch(stamp), expected, received)
except PermissionError as error:
raise InspectionError("access_denied") from error
except (AttributeError, json.JSONDecodeError, OSError, TypeError, ValueError):
return BatchResult(False, total, run_epoch(stamp), expected, received)
def filesystem_fields(prefix: str, root: Path) -> list[tuple[str, object]]:
values = os.statvfs(root)
total = values.f_blocks * values.f_frsize
available = values.f_bavail * values.f_frsize
used_percent = 0 if total == 0 else ((total - available) * 100) // total
return [
(f"{prefix}_disk_used_percent", used_percent),
(f"{prefix}_disk_free_gib", available // (1024**3)),
]
def source_contract(wrapper: Path, backup_script: Path) -> list[tuple[str, object]]:
try:
if (
wrapper.is_symlink()
or not wrapper.is_file()
or backup_script.is_symlink()
or not backup_script.is_file()
):
raise InspectionError("source_contract_invalid")
wrapper_text = wrapper.read_text(encoding="utf-8")
backup_text = backup_script.read_text(encoding="utf-8")
except PermissionError as error:
raise InspectionError("source_contract_access_denied") from error
except OSError as error:
raise InspectionError("source_contract_unreadable") from error
if "run_backup() {" not in wrapper_text or "\ncheck_stale() {" not in wrapper_text:
raise InspectionError("source_contract_invalid")
run_backup_body = wrapper_text.split("run_backup() {", 1)[1].split("\ncheck_stale() {", 1)[0]
markers = {
"local_publish": '[[ -d "$batch_dir" && -f "$batch_dir/manifest.json" ]]',
"transfer": "capture_command offhost_transfer rsync",
"finalize": "capture_command offhost_finalize ssh",
"success": 'write_success_state "$run_id" "$manifest_sha"',
"prune": 'prune_local_backups "$batch_name"',
}
positions = {key: run_backup_body.find(marker) for key, marker in markers.items()}
all_present = all(position >= 0 for position in positions.values())
retained = all_present and positions["local_publish"] < positions["transfer"] < positions["prune"]
pruned = all_present and positions["finalize"] < positions["success"] < positions["prune"]
temp_cleanup = all(
marker in backup_text
for marker in (
"trap cleanup EXIT",
'TEMP_DIR="$(mktemp -d',
'rm -rf -- "$TEMP_DIR"',
'mv "$TEMP_DIR" "$FINAL_DIR"',
'TEMP_DIR=""',
"trap - EXIT",
)
)
return [
("retained_after_transfer_failure", retained),
("pruned_after_offhost_success", pruned),
("generation_temp_cleanup_on_failure", temp_cleanup),
("cleanup_contract_source_verified", all_present and temp_cleanup),
]
def last_backup_phase(state_dir: Path) -> str:
state_file = state_dir / "last-run.json"
if not state_file.exists():
return "NOT_AVAILABLE"
try:
if state_file.is_symlink() or not state_file.is_file():
return "STATE_INVALID"
payload = json.loads(state_file.read_text(encoding="utf-8"))
findings = payload.get("findings") or []
detail = findings[0].get("detail") if len(findings) == 1 and isinstance(findings[0], dict) else None
match = re.fullmatch(r"phase_([a-z_]+)_failed", detail or "")
allowed = {
"runtime_identity",
"paired_backup",
"offhost_transfer",
"offhost_finalize",
"success_state",
"stale_check",
"quarterly_reminder",
}
return match.group(1) if match and match.group(1) in allowed else "NOT_AVAILABLE"
except PermissionError as error:
raise InspectionError("diagnostic_access_denied") from error
except (AttributeError, json.JSONDecodeError, OSError, TypeError):
return "STATE_INVALID"
def parse_transfer_diagnostic(state_dir: Path) -> list[tuple[str, object]]:
phase = last_backup_phase(state_dir)
diagnostic = state_dir / "last-error.log"
if phase != "offhost_transfer" or not diagnostic.exists():
code = "NOT_AVAILABLE"
else:
try:
if diagnostic.is_symlink() or not diagnostic.is_file():
raise InspectionError("diagnostic_invalid")
text = diagnostic.read_text(encoding="utf-8", errors="replace")
except PermissionError as error:
raise InspectionError("diagnostic_access_denied") from error
except OSError as error:
raise InspectionError("diagnostic_unreadable") from error
matches = RSYNC_CODE_RE.findall(text)
code = matches[-1] if matches else "NOT_AVAILABLE"
return [
("last_backup_failure_phase", phase),
("transfer_exit_code", code),
("transfer_exit_code_source", "last_error_log_regex" if code != "NOT_AVAILABLE" else "NOT_AVAILABLE"),
("transfer_failure_category", RSYNC_CATEGORIES.get(code, "not_available")),
("transfer_started_epoch", "NOT_AVAILABLE"),
("transfer_failed_epoch", "NOT_AVAILABLE"),
("transfer_duration_seconds", "NOT_AVAILABLE"),
("transfer_duration_reason", "wrapper_does_not_persist_transfer_clock"),
]
def production(args: argparse.Namespace) -> None:
backup_root = safe_root(args.backup_root)
state_dir = safe_root(args.state_dir)
now = args.now_epoch
valid: list[BatchResult] = []
invalid_count = invalid_bytes = temp_count = temp_bytes = other_count = 0
for entry in backup_root.iterdir():
match = BATCH_RE.fullmatch(entry.name)
if match and entry.is_dir() and not entry.is_symlink():
result = validate_batch(entry, match.group(1), match.group(2))
if result.valid and result.created_epoch is not None and result.created_epoch <= now:
valid.append(result)
else:
invalid_count += 1
invalid_bytes += result.total_bytes
elif TEMP_RE.fullmatch(entry.name) and entry.is_dir() and not entry.is_symlink():
temp_count += 1
temp_bytes += tree_bytes(entry)
else:
other_count += 1
valid.sort(key=lambda item: item.created_epoch or -1)
latest = valid[-1] if valid else None
output: list[tuple[str, object]] = [
("production_probe_result", "PASS"),
("complete_local_recovery_point", bool(valid)),
("backup_risk_classification", "production_single_point" if valid else "no_proven_recovery_point"),
("complete_local_batch_count", len(valid)),
("complete_local_batch_total_bytes", sum(item.total_bytes for item in valid)),
(
"latest_complete_local_batch_age_seconds",
now - latest.created_epoch
if latest and latest.created_epoch is not None
else "NOT_AVAILABLE",
),
("latest_complete_local_batch_bytes", latest.total_bytes if latest else "NOT_AVAILABLE"),
("invalid_local_batch_count", invalid_count),
("invalid_local_batch_total_bytes", invalid_bytes),
("generation_temp_count", temp_count),
("generation_temp_total_bytes", temp_bytes),
("unclassified_local_entry_count", other_count),
("transition_recovery_point_action", "wait_natural" if valid else "request_manual_run"),
]
output.extend(filesystem_fields("production_backup", backup_root))
output.extend(source_contract(args.wrapper_script, args.backup_script))
output.extend(parse_transfer_diagnostic(state_dir))
emit(output)
def stopping_class(samples: list[BatchResult]) -> str:
if len(samples) < 2:
return "insufficient_samples"
sizes = sorted(item.total_bytes for item in samples[-2:])
difference = sizes[1] - sizes[0]
relative_limit = sizes[1] * SIMILAR_RELATIVE_PPM // 1_000_000
return "similar" if difference <= max(SIMILAR_ABSOLUTE_BYTES, relative_limit) else "different"
def arm(args: argparse.Namespace) -> None:
inbox_root = safe_root(args.inbox_root)
archive_root = safe_root(args.archive_root)
now = args.now_epoch
partials: list[BatchResult] = []
other_count = 0
for entry in inbox_root.iterdir():
match = PARTIAL_RE.fullmatch(entry.name)
if match and entry.is_dir() and not entry.is_symlink():
partials.append(inspect_partial(entry, match.group(1), match.group(2)))
else:
other_count += 1
partials.sort(key=lambda item: item.created_epoch or -1)
archives: list[BatchResult] = []
invalid_archive_count = 0
for entry in archive_root.iterdir():
match = BATCH_RE.fullmatch(entry.name)
if match and entry.is_dir() and not entry.is_symlink():
result = validate_batch(entry, match.group(1), match.group(2))
if result.valid and result.created_epoch is not None and result.created_epoch <= now:
archives.append(result)
else:
invalid_archive_count += 1
archives.sort(key=lambda item: item.created_epoch or -1)
output: list[tuple[str, object]] = [
("arm_probe_result", "PASS"),
("arm_partial_count", len(partials)),
("arm_partial_total_bytes", sum(item.total_bytes for item in partials)),
("arm_partial_complete_contract_count", sum(1 for item in partials if item.valid)),
("arm_partial_incomplete_contract_count", sum(1 for item in partials if not item.valid)),
("arm_partial_stop_offset_class", stopping_class(partials)),
("arm_partial_similarity_relative_ppm", SIMILAR_RELATIVE_PPM),
("arm_partial_similarity_absolute_bytes", SIMILAR_ABSOLUTE_BYTES),
("arm_inbox_other_entry_count", other_count),
("arm_valid_atomic_archive_count", len(archives)),
("arm_invalid_atomic_archive_count", invalid_archive_count),
(
"arm_latest_atomic_archive_age_seconds",
now - archives[-1].created_epoch if archives else "NOT_AVAILABLE",
),
]
for index, sample in enumerate(partials, 1):
ratio = (
min(sample.received_artifact_bytes, sample.expected_artifact_bytes)
* 1_000_000
// sample.expected_artifact_bytes
if sample.expected_artifact_bytes and sample.expected_artifact_bytes > 0
else "NOT_AVAILABLE"
)
output.extend(
[
(f"arm_partial_sample_{index}_total_bytes", sample.total_bytes),
(
f"arm_partial_sample_{index}_expected_artifact_bytes",
sample.expected_artifact_bytes
if sample.expected_artifact_bytes is not None
else "NOT_AVAILABLE",
),
(f"arm_partial_sample_{index}_received_ratio_ppm", ratio),
(
f"arm_partial_sample_{index}_age_seconds",
now - sample.created_epoch
if sample.created_epoch is not None
else "NOT_AVAILABLE",
),
(f"arm_partial_sample_{index}_complete_contract", sample.valid),
]
)
output.extend(filesystem_fields("arm_inbox", inbox_root))
output.extend(filesystem_fields("arm_archive", archive_root))
emit(output)
def absolute_path(value: str) -> Path:
path = Path(value)
if not path.is_absolute():
raise argparse.ArgumentTypeError("an explicit absolute path is required")
return path
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Emit redacted backup-chain incident fields")
parser.add_argument("--now-epoch", type=int, default=int(datetime.now(timezone.utc).timestamp()))
subparsers = parser.add_subparsers(dest="mode", required=True)
production_parser = subparsers.add_parser("production")
production_parser.add_argument("--backup-root", type=absolute_path, required=True)
production_parser.add_argument("--state-dir", type=absolute_path, required=True)
production_parser.add_argument("--wrapper-script", type=absolute_path, required=True)
production_parser.add_argument("--backup-script", type=absolute_path, required=True)
production_parser.set_defaults(handler=production)
arm_parser = subparsers.add_parser("arm")
arm_parser.add_argument("--inbox-root", type=absolute_path, required=True)
arm_parser.add_argument("--archive-root", type=absolute_path, required=True)
arm_parser.set_defaults(handler=arm)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
args.handler(args)
except InspectionError as error:
emit([("probe_result", "FAIL"), ("probe_category", error.category)])
return 1
except PermissionError:
emit([("probe_result", "FAIL"), ("probe_category", "access_denied")])
return 1
except (OSError, TypeError, ValueError):
emit([("probe_result", "FAIL"), ("probe_category", "inspection_io_error")])
return 1
except Exception:
emit([("probe_result", "FAIL"), ("probe_category", "inspection_internal_error")])
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+287
查看文件
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -eEuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
INSPECTOR="$ROOT_DIR/scripts/qa/inspect-backup-transfer-incident.py"
TEST_ROOT="$(mktemp -d)"
PASS_COUNT=0
cleanup() {
if [[ -n "${TEST_ROOT:-}" && -d "$TEST_ROOT" && "$TEST_ROOT" == /tmp/* ]]; then
rm -rf -- "$TEST_ROOT"
fi
}
trap cleanup EXIT
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf '[PASS] %s\n' "$1"
}
assert_line() {
local file="$1"
local expected="$2"
grep -Fxq -- "$expected" "$file" || {
printf '[FAIL] missing field: %s\n' "$expected" >&2
exit 1
}
}
make_batch() {
local target="$1"
local run_id="$2"
local db_bytes="${3:-1024}"
local storage_bytes="${4:-2048}"
mkdir -p "$target"
head -c "$db_bytes" /dev/zero >"$target/database.dump"
head -c "$storage_bytes" /dev/zero >"$target/storage.tar.gz"
python3 - "$target" "$run_id" <<'PY'
import hashlib
import json
import sys
from pathlib import Path
target = Path(sys.argv[1])
run_id = sys.argv[2]
def item(name):
data = (target / name).read_bytes()
return {"file": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
payload = {
"schemaVersion": 1,
"runId": run_id,
"createdAt": "2026-08-28T00:00:00Z",
"buildSha": "a" * 40,
"sourceImageDigest": None,
"status": "complete",
"artifacts": {"database": item("database.dump"), "storage": item("storage.tar.gz")},
}
(target / "manifest.json").write_text(json.dumps(payload) + "\n", encoding="utf-8")
PY
}
run_production() {
local fixture="$1"
local output="$2"
python3 -B "$INSPECTOR" --now-epoch 1787875200 production \
--backup-root "$fixture/backups" \
--state-dir "$fixture/state" \
--wrapper-script "$ROOT_DIR/scripts/run-automatic-backup.sh" \
--backup-script "$ROOT_DIR/scripts/backup-all.sh" >"$output"
}
make_production_layout() {
local fixture="$1"
mkdir -p "$fixture/backups" "$fixture/state"
}
write_transfer_failure_state() {
local fixture="$1"
printf '%s\n' '{"findings":[{"detail":"phase_offhost_transfer_failed"}]}' >"$fixture/state/last-run.json"
}
PRODUCTION_VALID="$TEST_ROOT/production-valid"
make_production_layout "$PRODUCTION_VALID"
make_batch "$PRODUCTION_VALID/backups/hyb-oa-backup-auto-20260828T000000Z" "auto-20260828T000000Z"
write_transfer_failure_state "$PRODUCTION_VALID"
printf 'rsync error: synthetic redacted fixture (code 23) at fixture.c(1)\n' >"$PRODUCTION_VALID/state/last-error.log"
run_production "$PRODUCTION_VALID" "$TEST_ROOT/production-valid.out"
assert_line "$TEST_ROOT/production-valid.out" "complete_local_recovery_point=yes"
assert_line "$TEST_ROOT/production-valid.out" "backup_risk_classification=production_single_point"
assert_line "$TEST_ROOT/production-valid.out" "transition_recovery_point_action=wait_natural"
assert_line "$TEST_ROOT/production-valid.out" "transfer_exit_code=23"
assert_line "$TEST_ROOT/production-valid.out" "transfer_failure_category=partial_transfer_error"
assert_line "$TEST_ROOT/production-valid.out" "transfer_duration_seconds=NOT_AVAILABLE"
pass "生产存在完整本地恢复点时识别为单点风险并保留未测时长"
PRODUCTION_EMPTY="$TEST_ROOT/production-empty"
make_production_layout "$PRODUCTION_EMPTY"
run_production "$PRODUCTION_EMPTY" "$TEST_ROOT/production-empty.out"
assert_line "$TEST_ROOT/production-empty.out" "complete_local_recovery_point=no"
assert_line "$TEST_ROOT/production-empty.out" "backup_risk_classification=no_proven_recovery_point"
assert_line "$TEST_ROOT/production-empty.out" "transition_recovery_point_action=request_manual_run"
pass "生产无完整本地恢复点时升级风险与过渡恢复点建议"
PRODUCTION_CORRUPT="$TEST_ROOT/production-corrupt"
make_production_layout "$PRODUCTION_CORRUPT"
make_batch "$PRODUCTION_CORRUPT/backups/hyb-oa-backup-auto-20260828T000000Z" "auto-20260828T000000Z"
printf 'tamper' >>"$PRODUCTION_CORRUPT/backups/hyb-oa-backup-auto-20260828T000000Z/database.dump"
run_production "$PRODUCTION_CORRUPT" "$TEST_ROOT/production-corrupt.out"
assert_line "$TEST_ROOT/production-corrupt.out" "complete_local_recovery_point=no"
assert_line "$TEST_ROOT/production-corrupt.out" "invalid_local_batch_count=1"
pass "manifest 大小或 SHA 篡改不得冒充完整本地恢复点"
PRODUCTION_MALFORMED="$TEST_ROOT/production-malformed"
make_production_layout "$PRODUCTION_MALFORMED"
make_batch "$PRODUCTION_MALFORMED/backups/hyb-oa-backup-auto-20260828T000000Z" "auto-20260828T000000Z"
printf '%s\n' '[]' >"$PRODUCTION_MALFORMED/backups/hyb-oa-backup-auto-20260828T000000Z/manifest.json"
run_production "$PRODUCTION_MALFORMED" "$TEST_ROOT/production-malformed.out"
assert_line "$TEST_ROOT/production-malformed.out" "complete_local_recovery_point=no"
assert_line "$TEST_ROOT/production-malformed.out" "invalid_local_batch_count=1"
pass "畸形 manifest 安全归类为无效批次且不输出 traceback"
PRODUCTION_UNREADABLE="$TEST_ROOT/production-unreadable"
make_production_layout "$PRODUCTION_UNREADABLE"
make_batch "$PRODUCTION_UNREADABLE/backups/hyb-oa-backup-auto-20260828T000000Z" "auto-20260828T000000Z"
chmod 000 "$PRODUCTION_UNREADABLE/backups/hyb-oa-backup-auto-20260828T000000Z/manifest.json"
if [[ "$(id -u)" == 0 ]] && command -v runuser >/dev/null 2>&1 && id nobody >/dev/null 2>&1; then
chmod 0755 "$TEST_ROOT" "$PRODUCTION_UNREADABLE" "$PRODUCTION_UNREADABLE/backups" "$PRODUCTION_UNREADABLE/state" \
"$PRODUCTION_UNREADABLE/backups/hyb-oa-backup-auto-20260828T000000Z"
if runuser -u nobody -- python3 -B "$INSPECTOR" --now-epoch 1787875200 production \
--backup-root "$PRODUCTION_UNREADABLE/backups" \
--state-dir "$PRODUCTION_UNREADABLE/state" \
--wrapper-script "$ROOT_DIR/scripts/run-automatic-backup.sh" \
--backup-script "$ROOT_DIR/scripts/backup-all.sh" >"$TEST_ROOT/production-unreadable.out"; then
printf '[FAIL] unreadable manifest unexpectedly passed\n' >&2
exit 1
fi
else
if run_production "$PRODUCTION_UNREADABLE" "$TEST_ROOT/production-unreadable.out"; then
printf '[FAIL] unreadable manifest unexpectedly passed\n' >&2
exit 1
fi
fi
chmod 0600 "$PRODUCTION_UNREADABLE/backups/hyb-oa-backup-auto-20260828T000000Z/manifest.json"
assert_line "$TEST_ROOT/production-unreadable.out" "probe_category=access_denied"
pass "访问拒绝保持为 unknown/ACCESS_DENIED 而不是伪装成无有效备份"
assert_line "$TEST_ROOT/production-valid.out" "retained_after_transfer_failure=yes"
assert_line "$TEST_ROOT/production-valid.out" "pruned_after_offhost_success=yes"
assert_line "$TEST_ROOT/production-valid.out" "generation_temp_cleanup_on_failure=yes"
assert_line "$TEST_ROOT/production-valid.out" "cleanup_contract_source_verified=yes"
pass "生产脚本顺序机械证明失败保留、成功后 prune 与生成临时清理"
SOURCE_MUTATION="$TEST_ROOT/source-mutation"
cp "$ROOT_DIR/scripts/run-automatic-backup.sh" "$SOURCE_MUTATION"
python3 - "$SOURCE_MUTATION" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
transfer = text.index("capture_command offhost_transfer rsync")
text = text[:transfer] + 'prune_local_backups "$batch_name"\n ' + text[transfer:]
path.write_text(text, encoding="utf-8")
PY
python3 -B "$INSPECTOR" --now-epoch 1787875200 production \
--backup-root "$PRODUCTION_VALID/backups" \
--state-dir "$PRODUCTION_VALID/state" \
--wrapper-script "$SOURCE_MUTATION" \
--backup-script "$ROOT_DIR/scripts/backup-all.sh" >"$TEST_ROOT/source-mutation.out"
assert_line "$TEST_ROOT/source-mutation.out" "retained_after_transfer_failure=no"
assert_line "$TEST_ROOT/source-mutation.out" "pruned_after_offhost_success=no"
pass "源码顺序漂移会关闭失败保留与成功后 prune 结论"
for code_category in \
23:partial_transfer_error \
30:transport_timeout \
12:protocol_stream_error \
11:file_io_error \
255:ssh_session_error; do
code="${code_category%%:*}"
category="${code_category#*:}"
fixture="$TEST_ROOT/rsync-$code"
make_production_layout "$fixture"
write_transfer_failure_state "$fixture"
printf 'rsync error: synthetic redacted fixture (code %s) at fixture.c(1)\n' "$code" >"$fixture/state/last-error.log"
run_production "$fixture" "$TEST_ROOT/rsync-$code.out"
assert_line "$TEST_ROOT/rsync-$code.out" "transfer_exit_code=$code"
assert_line "$TEST_ROOT/rsync-$code.out" "transfer_failure_category=$category"
done
pass "rsync 23/30/12/11/255 均映射为稳定脱敏类别"
PRODUCTION_UNKNOWN="$TEST_ROOT/production-unknown"
make_production_layout "$PRODUCTION_UNKNOWN"
write_transfer_failure_state "$PRODUCTION_UNKNOWN"
printf 'unstructured synthetic transport failure\n' >"$PRODUCTION_UNKNOWN/state/last-error.log"
run_production "$PRODUCTION_UNKNOWN" "$TEST_ROOT/production-unknown.out"
assert_line "$TEST_ROOT/production-unknown.out" "transfer_exit_code=NOT_AVAILABLE"
assert_line "$TEST_ROOT/production-unknown.out" "transfer_failure_category=not_available"
pass "原始 rsync 码不存在时安全失败为 NOT_AVAILABLE"
PRODUCTION_PHASE_MISMATCH="$TEST_ROOT/production-phase-mismatch"
make_production_layout "$PRODUCTION_PHASE_MISMATCH"
printf '%s\n' '{"findings":[{"detail":"phase_offhost_finalize_failed"}]}' >"$PRODUCTION_PHASE_MISMATCH/state/last-run.json"
printf 'rsync error: stale synthetic fixture (code 23) at fixture.c(1)\n' >"$PRODUCTION_PHASE_MISMATCH/state/last-error.log"
run_production "$PRODUCTION_PHASE_MISMATCH" "$TEST_ROOT/production-phase-mismatch.out"
assert_line "$TEST_ROOT/production-phase-mismatch.out" "last_backup_failure_phase=offhost_finalize"
assert_line "$TEST_ROOT/production-phase-mismatch.out" "transfer_exit_code=NOT_AVAILABLE"
assert_line "$TEST_ROOT/production-phase-mismatch.out" "transfer_exit_code_source=NOT_AVAILABLE"
pass "私有诊断只在安全摘要明确为 offhost_transfer 时绑定 rsync 原始码"
ARM_SIMILAR="$TEST_ROOT/arm-similar"
mkdir -p "$ARM_SIMILAR/inbox" "$ARM_SIMILAR/archive"
make_batch "$ARM_SIMILAR/inbox/hyb-oa-backup-auto-20260827T000000Z.partial" "auto-20260827T000000Z" 1000 1000
make_batch "$ARM_SIMILAR/inbox/hyb-oa-backup-auto-20260828T000000Z.partial" "auto-20260828T000000Z" 1100 1000
rm "$ARM_SIMILAR/inbox/hyb-oa-backup-auto-20260827T000000Z.partial/storage.tar.gz"
rm "$ARM_SIMILAR/inbox/hyb-oa-backup-auto-20260828T000000Z.partial/storage.tar.gz"
python3 -B "$INSPECTOR" --now-epoch 1787875200 arm --inbox-root "$ARM_SIMILAR/inbox" --archive-root "$ARM_SIMILAR/archive" >"$TEST_ROOT/arm-similar.out"
assert_line "$TEST_ROOT/arm-similar.out" "arm_partial_count=2"
assert_line "$TEST_ROOT/arm-similar.out" "arm_partial_stop_offset_class=similar"
assert_line "$TEST_ROOT/arm-similar.out" "arm_partial_incomplete_contract_count=2"
pass "两个 partial 接近停止偏移按预先冻结阈值分类为 similar"
ARM_DIFFERENT="$TEST_ROOT/arm-different"
mkdir -p "$ARM_DIFFERENT/inbox" "$ARM_DIFFERENT/archive"
make_batch "$ARM_DIFFERENT/inbox/hyb-oa-backup-auto-20260827T000000Z.partial" "auto-20260827T000000Z" 1000 1000
make_batch "$ARM_DIFFERENT/inbox/hyb-oa-backup-auto-20260828T000000Z.partial" "auto-20260828T000000Z" 70000000 1000
rm "$ARM_DIFFERENT/inbox/hyb-oa-backup-auto-20260827T000000Z.partial/storage.tar.gz"
rm "$ARM_DIFFERENT/inbox/hyb-oa-backup-auto-20260828T000000Z.partial/storage.tar.gz"
python3 -B "$INSPECTOR" --now-epoch 1787875200 arm --inbox-root "$ARM_DIFFERENT/inbox" --archive-root "$ARM_DIFFERENT/archive" >"$TEST_ROOT/arm-different.out"
assert_line "$TEST_ROOT/arm-different.out" "arm_partial_stop_offset_class=different"
pass "两个 partial 明显不同停止偏移分类为 different"
ARM_COMPLETE="$TEST_ROOT/arm-complete"
mkdir -p "$ARM_COMPLETE/inbox" "$ARM_COMPLETE/archive"
make_batch "$ARM_COMPLETE/inbox/hyb-oa-backup-auto-20260828T000000Z.partial" "auto-20260828T000000Z"
make_batch "$ARM_COMPLETE/archive/hyb-oa-backup-auto-20260827T000000Z" "auto-20260827T000000Z"
python3 -B "$INSPECTOR" --now-epoch 1787875200 arm --inbox-root "$ARM_COMPLETE/inbox" --archive-root "$ARM_COMPLETE/archive" >"$TEST_ROOT/arm-complete.out"
assert_line "$TEST_ROOT/arm-complete.out" "arm_partial_complete_contract_count=1"
assert_line "$TEST_ROOT/arm-complete.out" "arm_valid_atomic_archive_count=1"
pass "完整 partial 与正式原子归档分别按同一三文件身份和哈希合同验证"
PERMISSION_FIXTURE="$TEST_ROOT/permission"
mkdir -p "$PERMISSION_FIXTURE/inbox" "$PERMISSION_FIXTURE/archive"
chmod 000 "$PERMISSION_FIXTURE/inbox"
if command -v runuser >/dev/null 2>&1 && id nobody >/dev/null 2>&1 && [[ "$(id -u)" == 0 ]]; then
if runuser -u nobody -- python3 -B "$INSPECTOR" arm --inbox-root "$PERMISSION_FIXTURE/inbox" --archive-root "$PERMISSION_FIXTURE/archive" >"$TEST_ROOT/permission.out"; then
printf '[FAIL] permission denial unexpectedly passed\n' >&2
exit 1
fi
assert_line "$TEST_ROOT/permission.out" "probe_category=access_denied"
else
chmod 0700 "$PERMISSION_FIXTURE/inbox"
ln -s "$PERMISSION_FIXTURE/inbox" "$PERMISSION_FIXTURE/inbox-link"
if python3 -B "$INSPECTOR" arm --inbox-root "$PERMISSION_FIXTURE/inbox-link" --archive-root "$PERMISSION_FIXTURE/archive" >"$TEST_ROOT/permission.out"; then
printf '[FAIL] invalid root unexpectedly passed\n' >&2
exit 1
fi
assert_line "$TEST_ROOT/permission.out" "probe_category=root_invalid"
fi
chmod 0700 "$PERMISSION_FIXTURE/inbox"
pass "不可读或不安全根目录明确拒绝且不伪装为不存在"
if rg -n --fixed-strings \
-e "$TEST_ROOT" \
-e "auto-20260828T000000Z" \
-e "database.dump" \
-e "storage.tar.gz" \
-e "unstructured synthetic transport failure" \
"$TEST_ROOT"/*.out >/dev/null; then
printf '[FAIL] redacted output leaked fixture identity, path, artifact name, or diagnostic text\n' >&2
exit 1
fi
pass "stdout allowlist 不含路径、run id、产物名或原始诊断"
PYTHONPYCACHEPREFIX="$TEST_ROOT/pycache" python3 -m py_compile "$INSPECTOR"
bash -n "$ROOT_DIR/scripts/qa/run-backup-transfer-incident-contract.sh"
pass "Python 与 Bash 语法通过"
cleanup
[[ ! -e "$TEST_ROOT" ]]
TEST_ROOT=""
trap - EXIT
pass "合成 fixture 与 Python cache 零残留"
printf '[PASS] backup transfer incident contract: %s checks\n' "$PASS_COUNT"