Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/research-repeat-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- "research/corpus/v1/**"
- "scripts/research/run-repeat-v1.sh"
- "scripts/research/analyze-repeat-v1.py"
- "scripts/research/project-repeat-manifest-v1.py"
- "scripts/research/materialize-v1.sh"
- "scripts/research/verify-materialization-v1.sh"
- "scripts/research/verify-profile-lock-v1.sh"
Expand Down Expand Up @@ -37,27 +38,43 @@ jobs:
set -euo pipefail
bash -n scripts/research/run-repeat-v1.sh
python3 -m py_compile scripts/research/analyze-repeat-v1.py
python3 -m py_compile scripts/research/project-repeat-manifest-v1.py
python3 scripts/research/analyze-repeat-v1.py --self-test
python3 scripts/research/project-repeat-manifest-v1.py --self-test
jq -e . research/repeat/v1/stability-sample.json >/dev/null

- name: Verify sample bindings
run: |
set -euo pipefail
python3 - <<'PY'
import hashlib
import json
from pathlib import Path

def git_blob_sha1(data):
header=b"blob " + str(len(data)).encode() + bytes([0])
return hashlib.sha1(header + data).hexdigest()

sample=json.loads(Path("research/repeat/v1/stability-sample.json").read_text())
plan=json.loads(Path("research/corpus/v1/study-plan.json").read_text())
lock=json.loads(Path("research/corpus/v1/profile-identities.json").read_text())
cases={x["id"] for x in plan["cases"]}
case_map={x["id"]: x for x in plan["cases"]}
cases=set(case_map)
profiles={x["id"] for x in lock["profiles"]}
ids=set()
for row in sample["tuples"]:
assert row["id"] not in ids, f"duplicate tuple id: {row['id']}"
ids.add(row["id"])
assert row["case_id"] in cases, row
assert row["profile_id"] in profiles, row
case=case_map[row["case_id"]]
if case["mode"] in {"load_only", "load_attach"}:
assert case.get("manifest"), row
assert case.get("manifest_git_blob"), row
manifest=Path(case["manifest"])
data=manifest.read_bytes()
assert git_blob_sha1(data)==case["manifest_git_blob"], row
assert f" - {row['profile_id']}" in data.decode(), row
shard=Path("research/data/v1/processed/executions")/f"{row['case_id']}.jsonl"
matches=[json.loads(x) for x in shard.read_text().splitlines()
if x.strip() and json.loads(x)["logical_profile_id"]==row["profile_id"]]
Expand Down
12 changes: 11 additions & 1 deletion research/repeat/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ Each tuple is repeated three times in one workflow collection, for 21 planned
attempts. Frozen v1 artifacts, loaders, validation semantics, and profile
definitions are reused.

For libbpf-backed cases, the frozen v1 manifests list all ten study profiles in
`required_profiles`. The repeat study executes only one sampled profile per
tuple, so the runner creates a **repeat-only manifest projection** whose sole
change is narrowing `required_profiles` to that sampled profile. Before doing
so it verifies the frozen source manifest Git-blob identity from
`study-plan.json`. The projected manifest and a metadata record containing its
SHA-256, source Git blob, and projection rule are retained in the workflow
artifact. Program, attach, and validation semantics are not rewritten.

## Interpretation

The repeat analyzer distinguishes two failure modes:
Expand All @@ -41,6 +50,7 @@ After this protocol lands on `main`:
gh workflow run research-repeat-v1.yml --repo Kernel-Guard/bpfcompat --ref main
```

The workflow publishes raw repeat reports, logs, a repeat provenance record,
The workflow publishes raw repeat reports, logs, repeat-only manifest
projections and their provenance metadata, a repeat provenance record,
`repeat-executions.jsonl`, `stability-summary.json`, and generated
`RESULTS.md` as a staging Actions artifact.
230 changes: 230 additions & 0 deletions scripts/research/project-repeat-manifest-v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Create a single-profile repeat-execution projection of a frozen manifest.

The frozen research manifest remains the source of truth. This helper changes
only its top-level required_profiles list so BPFCompat can execute one sampled
profile without requiring the other nine profiles to be present in the repeat
matrix. The source Git blob is verified before projection and projection
metadata is emitted for archival provenance.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import tempfile
from pathlib import Path

PROFILE_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
PROFILE_ITEM_RE = re.compile(r"^ - ([A-Za-z0-9._-]+)\s*$")


def sha256_bytes(data: bytes) -> str:
return "sha256:" + hashlib.sha256(data).hexdigest()


def git_blob_sha1(data: bytes) -> str:
header = f"blob {len(data)}\0".encode()
return hashlib.sha1(header + data).hexdigest()


def locate_required_profiles(text: str) -> tuple[list[str], int, int, list[str], str]:
lines = text.splitlines(keepends=True)
key_indexes = [
i for i, line in enumerate(lines)
if line.rstrip("\r\n") == "required_profiles:"
]
if len(key_indexes) != 1:
raise ValueError(
f"expected exactly one top-level required_profiles block, found {len(key_indexes)}"
)

key_index = key_indexes[0]
newline = "\n"
if lines[key_index].endswith("\r\n"):
newline = "\r\n"

profiles: list[str] = []
end = key_index + 1
while end < len(lines):
raw = lines[end].rstrip("\r\n")
match = PROFILE_ITEM_RE.fullmatch(raw)
if match:
profiles.append(match.group(1))
end += 1
continue

# A blank/comment or the next top-level key belongs to the suffix.
if raw.strip() == "" or raw.startswith("#") or not raw.startswith((" ", "\t")):
break

# Any other indented content means the frozen block is not in the
# simple list form this fail-closed projection understands.
raise ValueError(
f"unsupported content inside required_profiles block at line {end + 1}: {raw!r}"
)

if not profiles:
raise ValueError("required_profiles block is empty")

if len(set(profiles)) != len(profiles):
raise ValueError("required_profiles contains duplicate profile IDs")

return lines, key_index, end, profiles, newline


def project_manifest(
source: Path,
profile_id: str,
expected_git_blob: str,
out: Path,
metadata_out: Path,
) -> dict[str, object]:
if not PROFILE_ID_RE.fullmatch(profile_id):
raise ValueError(f"unsafe profile id: {profile_id!r}")

data = source.read_bytes()
actual_git_blob = git_blob_sha1(data)
expected = expected_git_blob.removeprefix("git-blob:")
if actual_git_blob != expected:
raise ValueError(
"source manifest Git blob mismatch: "
f"expected {expected}, got {actual_git_blob}"
)

text = data.decode("utf-8")
lines, key_index, end, profiles, newline = locate_required_profiles(text)
if profile_id not in profiles:
raise ValueError(
f"profile {profile_id!r} is not required by frozen manifest"
)

projected_lines = (
lines[: key_index + 1]
+ [f" - {profile_id}{newline}"]
+ lines[end:]
)
projected = "".join(projected_lines).encode("utf-8")

# Re-parse the projection to prove the transformation did exactly what it
# intended at the required_profiles boundary.
_, _, _, projected_profiles, _ = locate_required_profiles(
projected.decode("utf-8")
)
if projected_profiles != [profile_id]:
raise ValueError("projected manifest did not produce a singleton profile list")

out.parent.mkdir(parents=True, exist_ok=True)
metadata_out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(projected)

metadata: dict[str, object] = {
"schema_version": "bpfcompat.research.repeat-manifest-projection.v1",
"projection": "required_profiles_singleton",
"source_path": source.as_posix(),
"source_git_blob": actual_git_blob,
"source_sha256": sha256_bytes(data),
"profile_id": profile_id,
"source_required_profiles": profiles,
"projected_required_profiles": [profile_id],
"projected_path": out.as_posix(),
"projected_sha256": sha256_bytes(projected),
}
metadata_out.write_text(
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return metadata


def self_test() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "source.yaml"
out = root / "projected.yaml"
meta = root / "projection.json"
source.write_text(
"name: demo\n"
"programs:\n"
" - name: p\n"
"required_profiles:\n"
" - ubuntu-20.04-5.4\n"
" - ubuntu-22.04-5.15\n"
"metadata:\n"
" owner: demo\n",
encoding="utf-8",
)
blob = git_blob_sha1(source.read_bytes())
metadata = project_manifest(
source,
"ubuntu-22.04-5.15",
blob,
out,
meta,
)
projected = out.read_text(encoding="utf-8")
assert " - ubuntu-22.04-5.15\n" in projected
assert " - ubuntu-20.04-5.4\n" not in projected
assert "metadata:\n owner: demo\n" in projected
assert metadata["source_git_blob"] == blob
assert metadata["projected_required_profiles"] == ["ubuntu-22.04-5.15"]

try:
project_manifest(source, "debian-12-6.1", blob, out, meta)
except ValueError as exc:
assert "not required by frozen manifest" in str(exc)
else:
raise AssertionError("missing-profile projection unexpectedly succeeded")

try:
project_manifest(source, "ubuntu-22.04-5.15", "0" * 40, out, meta)
except ValueError as exc:
assert "Git blob mismatch" in str(exc)
else:
raise AssertionError("wrong source blob unexpectedly succeeded")

print("[project-repeat-manifest-v1] self-test PASS")
return 0


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--source")
ap.add_argument("--profile-id")
ap.add_argument("--expected-git-blob")
ap.add_argument("--out")
ap.add_argument("--metadata-out")
args = ap.parse_args()

if args.self_test:
return self_test()

required = {
"--source": args.source,
"--profile-id": args.profile_id,
"--expected-git-blob": args.expected_git_blob,
"--out": args.out,
"--metadata-out": args.metadata_out,
}
missing = [flag for flag, value in required.items() if not value]
if missing:
ap.error("missing required arguments: " + ", ".join(missing))

try:
project_manifest(
Path(args.source),
args.profile_id,
args.expected_git_blob,
Path(args.out),
Path(args.metadata_out),
)
except (OSError, UnicodeError, ValueError) as exc:
raise SystemExit(f"repeat manifest projection failed: {exc}") from exc
return 0


if __name__ == "__main__":
raise SystemExit(main())
23 changes: 21 additions & 2 deletions scripts/research/run-repeat-v1.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ BUNDLE="${1:-dist/research-corpus-v1}"
REPORTS="${2:-reports/research-repeat-v1}"
SAMPLE="${3:-research/repeat/v1/stability-sample.json}"
PLAN="research/corpus/v1/study-plan.json"
PROJECTION_SCRIPT="scripts/research/project-repeat-manifest-v1.py"

fail() {
echo "[research-repeat-v1] $*" >&2
Expand All @@ -14,6 +15,7 @@ fail() {
[[ -x "$BUNDLE/bin/bpfcompat-linux-amd64" ]] || fail "missing materialized BPFCompat CLI"
[[ -x "$BUNDLE/bin/bpfcompat-validator-static-linux-amd64" ]] || fail "missing materialized validator"
[[ -s "$SAMPLE" && -s "$PLAN" ]] || fail "missing repeat sample or study plan"
[[ -s "$PROJECTION_SCRIPT" ]] || fail "missing repeat manifest projection helper"

bash scripts/research/verify-materialization-v1.sh "$BUNDLE"
bash scripts/research/verify-profile-lock-v1.sh
Expand All @@ -22,14 +24,15 @@ export BPFCOMPAT_VALIDATOR_BIN="$PWD/$BUNDLE/bin/bpfcompat-validator-static-linu
export BPFCOMPAT_VALIDATOR_SHA256="4ae1d5b838be07e6e7c304d753389a239c19eb92f6ba3bd77657e5c9583b9d04"

BPF="$PWD/$BUNDLE/bin/bpfcompat-linux-amd64"
mkdir -p "$REPORTS/raw" "$REPORTS/logs" "$REPORTS/matrices" "$REPORTS/normalized"
mkdir -p "$REPORTS/raw" "$REPORTS/logs" "$REPORTS/matrices" "$REPORTS/manifests" "$REPORTS/normalized"

jq -n \
--arg workflow_source_commit "$(git rev-parse HEAD)" \
--arg sample_sha256 "sha256:$(sha256sum "$SAMPLE" | awk '{print $1}')" \
--arg materialization_sha256 "sha256:$(sha256sum "$BUNDLE/materialization.json" | awk '{print $1}')" \
--arg study_plan_sha256 "sha256:$(sha256sum "$PLAN" | awk '{print $1}')" \
--arg profile_lock_sha256 "sha256:$(sha256sum research/corpus/v1/profile-identities.json | awk '{print $1}')" \
--arg projection_script_sha256 "sha256:$(sha256sum "$PROJECTION_SCRIPT" | awk '{print $1}')" \
--arg cli_sha256 "sha256:$(sha256sum "$BUNDLE/bin/bpfcompat-linux-amd64" | awk '{print $1}')" \
--arg validator_sha256 "sha256:$(sha256sum "$BUNDLE/bin/bpfcompat-validator-static-linux-amd64" | awk '{print $1}')" \
--arg cilium_loader_sha256 "sha256:$(sha256sum "$BUNDLE/loaders/ebpf-go-loader" | awk '{print $1}')" \
Expand All @@ -42,6 +45,7 @@ jq -n \
materialization_sha256:$materialization_sha256,
study_plan_sha256:$study_plan_sha256,
profile_lock_sha256:$profile_lock_sha256,
manifest_projection_script_sha256:$projection_script_sha256,
bpfcompat_cli_sha256:$cli_sha256,
validator_sha256:$validator_sha256,
loaders:{
Expand Down Expand Up @@ -102,6 +106,7 @@ while IFS= read -r tuple_json; do
manifest="$(jq -r '.manifest // empty' <<<"$case_json")"
command_binary="$(jq -r '.command_binary // empty' <<<"$case_json")"
command="$(jq -r '.command // empty' <<<"$case_json")"
manifest_git_blob="$(jq -r '.manifest_git_blob // empty' <<<"$case_json")"

matrix="$REPORTS/matrices/$tuple_id.yaml"
cat > "$matrix" <<EOF
Expand All @@ -111,6 +116,20 @@ profiles:
required: false
EOF

projected_manifest=""
if [[ "$mode" == "load_only" || "$mode" == "load_attach" ]]; then
[[ -n "$manifest" && -n "$manifest_git_blob" ]] ||
fail "$tuple_id: libbpf case requires frozen manifest identity"
projected_manifest="$REPORTS/manifests/$tuple_id.yaml"
projection_metadata="$REPORTS/manifests/$tuple_id.json"
python3 "$PROJECTION_SCRIPT" \
--source "$manifest" \
--profile-id "$profile_id" \
--expected-git-blob "$manifest_git_blob" \
--out "$projected_manifest" \
--metadata-out "$projection_metadata"
fi

for repeat in $(seq 1 "$repeats"); do
common=(
--matrix "$matrix"
Expand All @@ -129,7 +148,7 @@ EOF
run_once "$tuple_id" "$repeat" \
"$BPF" test \
--artifact "$BUNDLE/$artifact_path" \
--manifest "$manifest" \
--manifest "$projected_manifest" \
--validation-mode "$mode" \
"${common[@]}"
;;
Expand Down
Loading