diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39a7147..26ac6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Test run: make test TEST_FLAGS="-v -race -count=1" + - name: Coverage gate + run: make cover-check + - name: Setup Bats id: setup-bats uses: bats-core/bats-action@5b1e60c2ee94cb1b44a616ea4b1f466f9d6e38ef # v4.0.0 diff --git a/.github/workflows/refresh-kernel-versions.yml b/.github/workflows/refresh-kernel-versions.yml new file mode 100644 index 0000000..100332a --- /dev/null +++ b/.github/workflows/refresh-kernel-versions.yml @@ -0,0 +1,120 @@ +name: refresh-kernel-versions + +# Re-runs kvgen against the latest iovisor/bcc and torvalds/linux HEAD +# commits and opens a PR if the snapshot under internal/kernelversions +# drifted. Kept on its own schedule so the noise of upstream churn does +# not pollute the regular CI signal on main. + +on: + schedule: + # Weekly, Monday 06:00 UTC. Cheap enough; UAPI helpers and BCC docs + # change rarely, so most runs will be a no-op. + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + bcc-commit: + description: "Override iovisor/bcc commit SHA (defaults to HEAD)" + required: false + default: "" + kernel-commit: + description: "Override torvalds/linux commit SHA (defaults to HEAD)" + required: false + default: "" + +permissions: + contents: write + pull-requests: write + +jobs: + refresh: + name: regenerate kernel-version snapshot + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + with: + go-version: "1.24" + + - name: Resolve upstream HEAD commits + id: pins + run: | + set -eu + bcc="${{ inputs.bcc-commit }}" + if [ -z "$bcc" ]; then + bcc=$(git ls-remote https://github.com/iovisor/bcc HEAD | awk '{print $1}') + fi + kernel="${{ inputs.kernel-commit }}" + if [ -z "$kernel" ]; then + kernel=$(git ls-remote https://github.com/torvalds/linux HEAD | awk '{print $1}') + fi + echo "bcc=$bcc" >> "$GITHUB_OUTPUT" + echo "kernel=$kernel" >> "$GITHUB_OUTPUT" + echo "Resolved BCC -> $bcc" + echo "Resolved kernel -> $kernel" + + - name: Bump pinned default commits in kvgen + run: | + set -eu + file=internal/kernelversions/cmd/kvgen/main.go + sed -i \ + -e 's|^\(\s*defaultBCCCommit\s*=\s*\)"[0-9a-f]\{40\}"|\1"${{ steps.pins.outputs.bcc }}"|' \ + -e 's|^\(\s*defaultKernelCommit\s*=\s*\)"[0-9a-f]\{40\}"|\1"${{ steps.pins.outputs.kernel }}"|' \ + "$file" + grep -E 'defaultBCCCommit|defaultKernelCommit' "$file" + + - name: Regenerate snapshot + run: | + go generate ./internal/kernelversions/... + + - name: Verify build still passes + run: | + go vet ./... + go test ./internal/kernelversions/... + + - name: Detect drift + id: drift + run: | + if git diff --quiet -- internal/kernelversions; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No snapshot drift; nothing to do." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git --no-pager diff --stat -- internal/kernelversions + fi + + - name: Open PR + if: steps.drift.outputs.changed == 'true' + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 + with: + branch: chore/refresh-kernel-versions + delete-branch: true + # Conventional-commit prefix `deps(kernelversions):` lets + # .github/workflows/labeler.yml route the PR into the + # `dependencies` bucket of release notes. + title: "deps(kernelversions): refresh BCC + UAPI snapshot" + commit-message: | + deps(kernelversions): refresh BCC + UAPI snapshot + + BCC pin -> ${{ steps.pins.outputs.bcc }} + kernel pin -> ${{ steps.pins.outputs.kernel }} + body: | + Automated refresh of `internal/kernelversions` against the + latest upstream sources. + + - **iovisor/bcc**: `${{ steps.pins.outputs.bcc }}` + - **torvalds/linux**: `${{ steps.pins.outputs.kernel }}` + + Regenerated via `go generate ./internal/kernelversions/...`. + Verified with `go vet ./...` and the kernelversions test + package; full CI runs on this PR. + + Review the diff in `internal/kernelversions/tables.go`. + New helpers / program types / map types appearing in + upstream UAPI but not yet in BCC's table will surface as + cross-validation failures here; in that case, decide + between waiting for BCC to catch up and adding the symbol + to the audited allow-list in + `internal/kernelversions/cmd/kvgen/known_gaps.go`. + labels: | + dependencies diff --git a/AGENTS.md b/AGENTS.md index 2b355dc..a868d8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,26 @@ When you change CLI behaviour, the corresponding bats file must be updated in th Run locally with `bats test/`. The full suite must stay green before committing. +## Coverage gate (`make cover-check`) + +`make cover-check` runs the test suite, captures a profile, and refuses to pass if any file listed in `COVER_FILES` (in the makefile) falls below 90%. Wire any new public/library file into `COVER_FILES` in the same PR that introduces it. + +To skip a function from the gate's denominator, put a single line `// coverage:ignore` in its doc comment (works for both `func` declarations and `var foo = func(...)`). Skip only code that genuinely cannot be reached from the unit-test environment; never paper over a real coverage gap with a marker. Always pair the marker with a one-line justification on the line above it. + +The checker source lives in `internal/tools/covercheck/main.go`. Do not bypass it by editing `COVER_THRESHOLD` per-file; raise it through tests instead. + +## Kernel-version snapshot (`internal/kernelversions`) + +The tables under `internal/kernelversions` (helpers, program types, map types → minimum kernel version) are **generated**. Do not hand-edit `source.json` or `tables.go`. + +When you need to update them: + +- Routine refresh: `.github/workflows/refresh-kernel-versions.yml` runs weekly and opens a PR if upstream BCC / Linux drift produces a different snapshot. Let the bot do it. +- Manual refresh during development: `go generate ./internal/kernelversions/...` (network-bound; fetches at the pinned commits in `internal/kernelversions/cmd/kvgen/main.go`). Override the pins with `--bcc-commit=...` / `--kernel-commit=...` only when reproducing a specific drift. +- Cross-validation failures (a UAPI symbol without a BCC row, or vice versa) are intentional: the generator refuses to emit a partial snapshot. Resolve by either (a) waiting for BCC to catch up, or (b) adding the symbol to `internal/kernelversions/cmd/kvgen/known_gaps.go` with a one-line rationale. Never widen the validator. + +The library reads the snapshot through `internal/kernelversions.HelperKernelVersion`, `MapTypeKernelVersion`, `ProgramTypeKernelVersion`. New consumers (e.g. follow-up `Require*` constructors) should go through these helpers; do not import `tables.go` directly. + ## Commits and PRs - Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `chore:`, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac9307..85da687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `ProbeELF(path)` and `ProbeELFWith(path, opts...)`: parse a compiled eBPF object and return an `*ELFProbes` snapshot describing the program/map types, helper-per-program requirements, and (with `WithCOREChecks()`) a per-program memory-access classification distinguishing context loads, map-value loads, CO-RE-protected loads, and unprotected kernel-direct loads. Useful for verifying an object will load on a target kernel before shipping. `Requirements()` projects the snapshot to a `FeatureGroup` consumable by `Check(...)` (superset of what `FromELF` returns; `FromELF` remains the frozen helper). +- `RequireMinKernel(major, minor)`: parameterized requirement that gates on `uname -r` reporting at least the given kernel version. Composes with the snapshot of helper / program-type / map-type kernel-version metadata derived from BCC's `kernel-versions.md`, so `kfeatures.Check(reqs.Requirements()...)` automatically rejects an object whose helpers were introduced after the running kernel. +- Kernel-version snapshot under `internal/kernelversions`: pure-Go tables of the minimum kernel version that introduced each BPF helper, program type, and map type. Generated by `internal/kernelversions/cmd/kvgen` against pinned `iovisor/bcc` and `torvalds/linux` commits, regenerated automatically on a weekly schedule (`.github/workflows/refresh-kernel-versions.yml`) and re-validated against UAPI on every CI run. +- Superseded-helper warnings: `ELFProbes.Warnings` flags calls to `bpf_probe_read` / `bpf_probe_read_str` (split into `bpf_probe_read_kernel*` / `bpf_probe_read_user*` since 5.5) and `bpf_get_current_task` (subsumed by `bpf_get_current_task_btf` since 5.11). Advisory only; no effect on `Check(...)` verdicts. +- CLI: `kfeatures probe host` is the new canonical name for the live-kernel probe (`kfeatures probe` continues to work as an alias). `kfeatures probe bpf ` runs the ELF probe on a compiled object and prints the snapshot; `--with-core` enables CO-RE memory-access classification, `--requirements` prints only the requirement projection, `--json` switches both to JSON. Exposed as MCP tools `probe-host` and `probe-bpf`. +- CLI: `kfeatures check --from-elf ` accepts an ELF object as a requirement source (composes with `--require`); both flags are now optional and at least one must be supplied. - Releases: every artifact (per-platform tarballs and `checksums.txt`) is now signed with [cosign](https://github.com/sigstore/cosign) keyless signing backed by GitHub's OIDC token. Each artifact has a sibling `.sigstore.json` bundle containing the signature, certificate (with the workflow identity baked in), and Rekor transparency-log inclusion proof. Verifying a download is a single `cosign verify-blob --bundle ...` invocation; see the new [Verifying releases](README.md#verifying-releases) section in the README for the exact commands. Requires cosign v2.0+ on the verifier side. - `NOTICE` file at repo root carrying the `Copyright 2026 Leonardo Di Donato` attribution. Apache 2.0 distinguishes the license text (canonical, verbatim, in `LICENSE`) from project-level attribution (in a `NOTICE` file that downstream consumers must propagate). The previous setup folded the copyright line into `LICENSE` itself; that conflated the two and is one of the deviations that caused licensecheck to mis-classify the file (see corresponding `### Fixed` entry). - README License section: "Why Apache 2.0" paragraph. Documents the kernel-uABI posture (no kernel source, no cgo, no GPL deps; `/proc` and `/sys` reads fall under the kernel `COPYING` "normal syscalls" carve-out) and the Apache-2.0-over-MIT rationale (patent grant for security-adjacent probing; same-license adopter base of Cilium, Tetragon, Falco, etc.). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28cd658..8db43f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,10 +22,11 @@ Requirement items consumed by `Check(...)`: - `FeatureGroup`: reusable preset of requirements (also returned by `FromELF`) - `RequireProgramType(...)`, `RequireMapType(...)`, `RequireProgramHelper(...)`: parameterized workload requirements - `RequireMount(path, magic)`: parameterized filesystem-mount gate; magic comes from `golang.org/x/sys/unix` (e.g. `unix.BPF_FS_MAGIC`) +- `RequireMinKernel(major, minor)`: parameterized minimum-kernel-version gate; composes with the helper/prog-type/map-type kernel-version snapshot maintained under `internal/kernelversions` - `FromELF(path)`: producer of requirement items in the same model (program/map types + helper-per-program requirements) +- `ProbeELF(path)` / `ProbeELFWith(path, opts...)`: full ELF snapshot (`*ELFProbes`) with optional CO-RE classification; `Requirements()` projects to a `FeatureGroup` -`FromELF` is parser-only and available cross-platform; runtime probing/checking -remains Linux-specific. +`FromELF`, `ProbeELF`, and `ProbeELFWith` are parser-only and available cross-platform; runtime probing/checking remains Linux-specific. ## Feature-addition review checklist @@ -54,6 +55,20 @@ The `FromELF` API is frozen against the following contract: Changes to any of these points require explicit discussion in the PR and a CHANGELOG entry under a new minor version. +`ProbeELF` is the strict superset: it returns a richer `*ELFProbes` snapshot (warnings, memory-access summaries, CO-RE classification when opted in) and lets callers project to the same `FeatureGroup` shape via `Requirements()`. New extraction surface (additional warning rules, additional CO-RE classifications) belongs on `ProbeELF`; `FromELF` stays frozen against the four contract points above. + +## Kernel-version snapshot (`internal/kernelversions`) + +The helper / program-type / map-type minimum-kernel-version tables are generated, not hand-edited. The generator (`internal/kernelversions/cmd/kvgen`) parses BCC's `docs/kernel-versions.md` and Linux UAPI `include/uapi/linux/bpf.h` at pinned commits, cross-validates that every `BPF_FUNC_*` / `BPF_PROG_TYPE_*` / `BPF_MAP_TYPE_*` enum value in UAPI has a corresponding row in the BCC table, and emits `tables.go`. + +Workflow: + +- **Routine refresh**: `.github/workflows/refresh-kernel-versions.yml` runs weekly, resolves upstream HEAD for both repos, rewrites the `defaultBCCCommit` / `defaultKernelCommit` constants in `cmd/kvgen/main.go`, regenerates the snapshot, and opens a PR labelled `dependencies` if the output drifted. +- **Manual refresh**: `go generate ./internal/kernelversions/...` from a clean checkout. +- **Cross-validation failure**: when UAPI ships a new symbol before BCC documents it (or vice versa), the generator returns an error. Decide between waiting for BCC to catch up and adding the symbol to the audited allow-list in `internal/kernelversions/cmd/kvgen/known_gaps.go` with a one-line rationale; never silence the validator wholesale. + +Do not commit hand-edited changes to `tables.go`. The auto-refresh PR is the only sanctioned path. + ## CLI conventions The `cmd/kfeatures` binary is built on [structcli](https://github.com/leodido/structcli) (>= v0.17.0). The patterns below are invariants: PRs that break them need explicit discussion in the description. @@ -107,11 +122,21 @@ When introducing a new subcommand, default to exposing it. Only exclude after th ## Development workflow ```bash -make test # unit tests -make lint # go vet + golangci-lint -make build # build the CLI +make test # unit tests +make lint # go vet + golangci-lint +make build # build the CLI +make cover # produce coverage.out +make cover-check # enforce per-file coverage threshold ``` +### Coverage gate + +`make cover-check` runs the test suite with `-coverprofile=coverage.out` and then runs `internal/tools/covercheck` against that profile, failing if any gated source file falls below `COVER_THRESHOLD` (default `90`). The list of gated files lives in the `COVER_FILES` makefile variable. + +The checker honours a single-line `// coverage:ignore` marker placed in the doc comment of a function declaration (or of a `var foo = func(...)` declaration). The marker excludes every statement attributed to that function from both the numerator and the denominator. Use it sparingly and only for code that is genuinely impossible to cover from the unit-test environment (network-bound `main` entrypoints, disk-bound wrappers like `ProbeELFWith` whose branches are exercised through programmatic fixtures against the inner helper). Every marker should carry a one-line justification immediately above it. + +When you add a new feature file, append it to `COVER_FILES` in the makefile and bring it up to threshold in the same PR. Internal tools (`internal/tools/covercheck`, `internal/kernelversions/cmd/kvgen`) are intentionally excluded from the gate: their happy paths are exercised by the scheduled refresh workflow against live network data. + Integration tests (real `unix.Statfs` / `unix.Mount`) are gated behind a build tag and a dedicated CI job: diff --git a/README.md b/README.md index 1c2340a..1c78465 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Neither tells you whether your tool can **actually run**. For example, BPF LSM r | **Unprivileged BPF status** | ✗ | ✓ | ✓ | | **Mount-state gates** (bpffs/tracefs/custom paths via superblock magic) | ✗ | ✗ | ✓ | | **ELF requirement extraction** (parse `.o`, derive requirements) | ✗ | ✗ | ✓ | +| **ELF static analysis** (superseded-helper warnings, CO-RE memory-access classification) | ✗ | ✗ | ✓ | +| **Min-kernel-version derivation** (helper / prog-type / map-type → minimum kernel) | ✗ | partial | ✓ | | **Composite feature validation** | ✗ | ✗ | ✓ | | **Actionable diagnostics** (remediation steps) | ✗ | ✗ | ✓ | | Selective probing (minimize overhead) | ✓ ‡ | ✗ § | ✓ | @@ -172,6 +174,38 @@ if err := kfeatures.Check(reqs); err != nil { Output is deterministic (deduplicated, stably ordered). Unknown ELF kinds fail closed. +### Full ELF snapshot (`ProbeELF`) + +`FromELF` returns only what `Check(...)` consumes. `ProbeELF` is the strict superset: a `*ELFProbes` snapshot with per-program metadata, map declarations, helper-per-program requirements, advisory warnings (e.g. uses of helpers superseded by safer variants), and (with `WithCOREChecks()`) a per-program memory-access classification distinguishing context loads, map-value loads, CO-RE-protected loads, and unprotected kernel-direct loads. + +```go +probes, err := kfeatures.ProbeELFWith("./bpf/probe.o", kfeatures.WithCOREChecks()) +if err != nil { + log.Fatal(err) +} +for _, w := range probes.Warnings { + fmt.Println("warn:", w.Message) +} +if err := kfeatures.Check(probes.Requirements()...); err != nil { + log.Fatalf("kernel cannot run probe.o: %v", err) +} +``` + +`Requirements()` projects to the same `FeatureGroup` shape `FromELF` returns, with the addition of a `RequireMinKernel(...)` derived from the highest-version helper / program type / map type the object touches. `FromELF` stays the frozen helper for callers that only need the `Check`-compatible projection. + +### Minimum kernel version (`RequireMinKernel`) + +Gate on the running kernel's version (parsed from `uname -r`): + +```go +err := kfeatures.Check( + kfeatures.RequireMinKernel(5, 11), // bpf_get_current_task_btf + kfeatures.RequireProgramHelper(ebpf.Kprobe, asm.FnGetCurrentTaskBtf), +) +``` + +`Probes.Requirements()` adds this automatically based on the kernel-version snapshot under `internal/kernelversions` (regenerated weekly from BCC and Linux UAPI; see [CONTRIBUTING.md](CONTRIBUTING.md#kernel-version-snapshot-internalkernelversions)). + ### Render remediation (`Diagnose`) `Check` returns the diagnosis for the first failing feature. To inspect any feature against a single probe snapshot, call `Diagnose` directly: @@ -246,9 +280,15 @@ go install github.com/leodido/kfeatures/cmd/kfeatures@latest ``` ```bash -kfeatures probe # probe all features +kfeatures probe # probe live kernel (alias for `probe host`) +kfeatures probe host # probe live kernel +kfeatures probe bpf ./probe.o # probe a compiled eBPF object +kfeatures probe bpf ./probe.o --with-core # add CO-RE memory-access classification +kfeatures probe bpf ./probe.o --requirements # print only the Check-compatible requirement projection kfeatures check --require bpf-lsm,btf,cap-bpf # exit 0 if met, 1 otherwise -kfeatures probe --json # JSON output +kfeatures check --from-elf ./probe.o # gate on requirements derived from a compiled object +kfeatures check --from-elf ./probe.o --require btf # combine ELF-derived and explicit requirements +kfeatures probe --json # JSON output (any subcommand) kfeatures config # display kernel config ``` @@ -327,7 +367,7 @@ $ kfeatures --jsonschema=tree | jq 'map(.title) | map(select(test("^kfeatures( p } ``` -Tools exposed: `probe`, `check`, `config`. The server stays alive across business-outcome errors (a failing `check` does not terminate the session), and invocation errors flow through the same structured envelope as the CLI. Pure stdlib JSON-RPC inside [structcli](https://github.com/leodido/structcli/tree/main/mcp); no extra heavy SDK dependency. +Tools exposed: `probe-host`, `probe-bpf`, `check`, `config`. The server stays alive across business-outcome errors (a failing `check` does not terminate the session), and invocation errors flow through the same structured envelope as the CLI. Pure stdlib JSON-RPC inside [structcli](https://github.com/leodido/structcli/tree/main/mcp); no extra heavy SDK dependency. ## What it detects @@ -342,8 +382,8 @@ Tools exposed: `probe`, `check`, `config`. The server stays alive across busines | Filesystems | tracefs, debugfs, securityfs, bpffs (gated `tracefs`/`bpffs` checks verify the filesystem is mounted with the expected superblock magic) | | Custom mount gates | any path + superblock magic via `RequireMount` | | Namespaces | initial user namespace, initial PID namespace | -| Parameterized workload requirements | program type, map type, helper-per-program-type via requirement items | -| ELF-derived requirements | program/map types and helper-per-program requirements via `FromELF` | +| Parameterized workload requirements | program type, map type, helper-per-program-type, minimum kernel version via requirement items | +| ELF-derived requirements | program/map types and helper-per-program requirements via `FromELF`; full snapshot (warnings, CO-RE memory-access classification, derived `RequireMinKernel`) via `ProbeELF` / `ProbeELFWith(WithCOREChecks())` | | Mitigation context | Spectre v1/v2 vulnerability status | | Kernel config | `CONFIG_BPF_LSM`, `CONFIG_IMA`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_FPROBE`, any `CONFIG_*` | diff --git a/check.go b/check.go index 043ab00..eaa9467 100644 --- a/check.go +++ b/check.go @@ -93,6 +93,16 @@ func Check(required ...Requirement) error { } } + for _, mk := range rs.minKernels { + if err := mk.satisfiedBy(sf.KernelVersion); err != nil { + return &FeatureError{ + Feature: mk.String(), + Reason: err.Error(), + Err: err, + } + } + } + return nil } diff --git a/cmd/kfeatures/check_fromelf_test.go b/cmd/kfeatures/check_fromelf_test.go new file mode 100644 index 0000000..ca2c06e --- /dev/null +++ b/cmd/kfeatures/check_fromelf_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "strings" + "testing" + + "github.com/leodido/kfeatures" +) + +func TestAssembleCheckRequirementsEmpty(t *testing.T) { + _, err := assembleCheckRequirements(&CheckOptions{}) + if err == nil { + t.Fatal("expected error when neither --require nor --from-elf is set") + } + if !strings.Contains(err.Error(), "no features specified") { + t.Errorf("error = %v", err) + } +} + +func TestAssembleCheckRequirementsRequireOnly(t *testing.T) { + got, err := assembleCheckRequirements(&CheckOptions{ + Require: featureRequirements{kfeatures.FeatureBPFSyscall}, + }) + if err != nil { + t.Fatalf("assembleCheckRequirements: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } +} + +func TestAssembleCheckRequirementsFromELFParseError(t *testing.T) { + _, err := assembleCheckRequirements(&CheckOptions{ + FromELF: "/nonexistent/path/missing.bpf.o", + }) + if err == nil { + t.Fatal("expected error on missing ELF") + } + if !strings.Contains(err.Error(), "from-elf") { + t.Errorf("error = %v, want from-elf wrapper", err) + } +} diff --git a/cmd/kfeatures/main.go b/cmd/kfeatures/main.go index 9bfe0df..90a410d 100644 --- a/cmd/kfeatures/main.go +++ b/cmd/kfeatures/main.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "reflect" "strings" @@ -102,41 +103,238 @@ CI/CD gating, or container runtime validation.`, structcli.ExecuteOrExit(root) } -// ProbeOptions defines flags for the probe subcommand. -type ProbeOptions struct { +// ProbeHostOptions defines flags for `probe host` (and the bare `probe`). +type ProbeHostOptions struct { JSON bool `flag:"json" flagshort:"j" flagdescr:"Output in JSON format"` } +// probeCmd is the `probe` parent command. Bare `kfeatures probe` +// preserves v0.5.x behaviour (live-kernel host probe) by reusing the +// `probe host` leaf's RunE. The parent itself binds no flags (so +// structcli's "shared flags + subcommands" check stays silent) and is +// auto-excluded from the MCP tool registry because it has subcommands. +// +// Bare-invocation flag forwarding works because `probeHostLeaf` carries +// the flag bindings; cobra's executor invokes the leaf's RunE directly +// when the user typed `kfeatures probe --json`, treating `probe` as the +// terminal command. (Without subcommands matching the next token, cobra +// runs the parent's RunE; we re-issue it through the leaf to keep flag +// definitions in one place.) +// +// MCP exposure: structcli only registers runnable leaves. The parent +// `probe` is filtered (cobra subcommands present), so MCP sees +// `probe-host` and `probe-bpf` only — no need for an explicit Exclude +// entry. func probeCmd() *cobra.Command { - opts := &ProbeOptions{} - + hostLeaf := probeHostCmd() cmd := &cobra.Command{ Use: "probe", - Short: "Probe all kernel features and display results", + Short: "Probe a system or an eBPF ELF object for features", + Long: `Probe groups two read-only diagnostic surfaces: + + probe host Probe the running kernel (default; bare 'kfeatures probe' for back-compat). + probe bpf Probe a compiled eBPF ELF object. + +Bare 'kfeatures probe' is equivalent to 'kfeatures probe host' and +preserves the v0.5.x behaviour byte-for-byte.`, + RunE: hostLeaf.RunE, + } + // Mirror the host leaf's flag set on the parent so that + // `kfeatures probe --json` still parses; the leaf-level binding is + // the source of truth for unmarshalling. + cmd.Flags().AddFlagSet(hostLeaf.Flags()) + cmd.AddCommand(hostLeaf) + cmd.AddCommand(probeBpfCmd()) + return cmd +} + +// runProbeHost is the shared body for `probe host` and bare `probe`. +// Both call sites pass identical opts so the output is bit-for-bit +// identical regardless of how the user invoked it. +func runProbeHost(c *cobra.Command, opts *ProbeHostOptions) error { + sf, err := kfeatures.ProbeNoCache() + if err != nil { + return err + } + if opts.JSON { + return printJSON(c, sf) + } + fmt.Fprint(c.OutOrStdout(), sf) + return nil +} + +// probeHostOpts is the shared options pointer used by both the explicit +// `probe host` leaf and the bare `probe` parent. Sharing the same struct +// lets `--json` (declared on either invocation surface) populate the same +// memory, so the parent's RunE delegating to the host leaf's RunE always +// sees the parsed value. +// +// Lifetime is process-global (the var is created at init time when +// probeHostCmd is first invoked from main()). This is safe because cobra +// runs sequentially and main() returns after Execute completes. +var probeHostOpts = &ProbeHostOptions{} + +// probeHostCmd is the explicit `probe host` leaf. Functionally identical +// to bare `kfeatures probe` and `kfeatures probe host`. +func probeHostCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "host", + Short: "Probe the running kernel", RunE: func(c *cobra.Command, args []string) error { - sf, err := kfeatures.ProbeNoCache() + return runProbeHost(c, probeHostOpts) + }, + } + if err := structcli.Bind(cmd, probeHostOpts); err != nil { + panic(err) + } + return cmd +} + +// ProbeBpfOptions defines flags for `probe bpf`. +type ProbeBpfOptions struct { + JSON bool `flag:"json" flagshort:"j" flagdescr:"Output in JSON format"` + WithCORE bool `flag:"with-core" flagdescr:"Run the heuristic CO-RE register-state classifier (off by default)"` + WithFromELF bool `flag:"requirements" flagdescr:"Emit only the Check-compatible FeatureGroup this parse derives"` +} + +// probeBpfCmd is the `probe bpf ` leaf. Reads an ELF file +// from disk and emits the descriptive ELFProbes view. +func probeBpfCmd() *cobra.Command { + opts := &ProbeBpfOptions{} + cmd := &cobra.Command{ + Use: "bpf ", + Short: "Probe a compiled eBPF ELF object", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + path := args[0] + var probeOpts []kfeatures.ELFProbeOption + if opts.WithCORE { + probeOpts = append(probeOpts, kfeatures.WithCOREChecks()) + } + probes, err := kfeatures.ProbeELFWith(path, probeOpts...) if err != nil { return err } - if opts.JSON { - return printJSON(c, sf) + if opts.WithFromELF { + return printJSON(c, probes.Requirements()) + } + return printJSON(c, probes) } - - fmt.Fprint(c.OutOrStdout(), sf) + renderELFProbesText(c, probes, opts.WithFromELF) return nil }, } - if err := structcli.Bind(cmd, opts); err != nil { panic(err) } return cmd } +// renderELFProbesText writes a human-readable summary of probes to the +// command's stdout. Mirrors the SystemFeatures.String() shape so users +// see a familiar layout. +func renderELFProbesText(c *cobra.Command, probes *kfeatures.ELFProbes, withFromELF bool) { + out := c.OutOrStdout() + if withFromELF { + renderELFRequirementsText(out, probes) + return + } + + fmt.Fprintf(out, "ELF: %s\n", probes.Path) + if probes.License != "" { + fmt.Fprintf(out, "License: %s\n", probes.License) + } + fmt.Fprintf(out, "BTF: %t\n", probes.HasBTF) + fmt.Fprintf(out, "CO-RE relocations: %d\n", probes.CORERelocations) + if !probes.MinKernel.IsZero() { + fmt.Fprintf(out, "Min kernel: %s\n", probes.MinKernel) + } + if len(probes.Transport) > 0 { + fmt.Fprintf(out, "Transport: %s\n", strings.Join(probes.Transport, ", ")) + } + if len(probes.Programs) > 0 { + fmt.Fprintln(out, "Programs:") + for _, p := range probes.Programs { + fmt.Fprintf(out, " - %s (%s, %d insns, %d CO-RE relocs)\n", p.Name, p.Type, p.NumInsns, p.CORERelocs) + } + } + if len(probes.Maps) > 0 { + fmt.Fprintln(out, "Maps:") + for _, m := range probes.Maps { + fmt.Fprintf(out, " - %s (%s, key=%d val=%d max=%d, since %s)\n", m.Name, m.Type, m.KeySize, m.ValueSize, m.MaxEntries, m.Version) + } + } + if len(probes.Helpers) > 0 { + fmt.Fprintln(out, "Helpers:") + for _, h := range probes.Helpers { + fmt.Fprintf(out, " - %s (since %s)\n", h.Name, h.Version) + } + } + if len(probes.Warnings) > 0 { + fmt.Fprintln(out, "Warnings:") + for _, w := range probes.Warnings { + loc := "" + if w.Program != "" { + loc = w.Program + } + if w.File != "" { + loc = fmt.Sprintf("%s @ %s:%d", loc, w.File, w.Line) + } + if loc != "" { + fmt.Fprintf(out, " [%s] %s: %s\n", w.Severity, loc, w.Message) + } else { + fmt.Fprintf(out, " [%s] %s\n", w.Severity, w.Message) + } + if w.Detail != "" { + fmt.Fprintf(out, " %s\n", w.Detail) + } + } + } +} + +func renderELFRequirementsText(out io.Writer, probes *kfeatures.ELFProbes) { + fmt.Fprintln(out, "Requirements:") + for _, r := range probes.Requirements() { + fmt.Fprintf(out, " - %T %+v\n", r, r) + } +} + +// assembleCheckRequirements turns CheckOptions into the flat slice of +// kfeatures.Requirement values that gets handed to kfeatures.Check. +// +// The caller must have set at least one of opts.Require or opts.FromELF; +// otherwise an error is returned. When --from-elf is set the function +// reads the ELF eagerly so any parse error is surfaced before Check. +func assembleCheckRequirements(opts *CheckOptions) ([]kfeatures.Requirement, error) { + if len(opts.Require) == 0 && opts.FromELF == "" { + return nil, fmt.Errorf("no features specified: pass --require and/or --from-elf") + } + out := make([]kfeatures.Requirement, 0, len(opts.Require)) + for _, f := range opts.Require { + out = append(out, f) + } + if opts.FromELF != "" { + group, err := kfeatures.FromELF(opts.FromELF) + if err != nil { + return nil, fmt.Errorf("from-elf %q: %w", opts.FromELF, err) + } + for _, r := range group { + out = append(out, r) + } + } + return out, nil +} + // CheckOptions defines flags for the check subcommand. +// +// Require and FromELF are alternative requirement sources: at least one +// must be set, and they may be combined (the union of both is gated). The +// `flagrequired:"true"` tag on Require is removed in main()'s probe-of- +// arguments path because --from-elf alone is sufficient. type CheckOptions struct { - Require featureRequirements `flag:"require" flagshort:"r" flagdescr:"Required features (see available features above)" flagrequired:"true" flagcustom:"true"` + Require featureRequirements `flag:"require" flagshort:"r" flagdescr:"Required features (see available features above)" flagcustom:"true"` + FromELF string `flag:"from-elf" flagdescr:"Path to a compiled eBPF ELF object; gates on the FeatureGroup derived from it"` JSON bool `flag:"json" flagshort:"j" flagdescr:"Output in JSON format"` } @@ -204,16 +402,12 @@ func checkCmd() *cobra.Command { Short: "Check specific kernel feature requirements", Long: checkLongDescription(), RunE: func(c *cobra.Command, args []string) error { - if len(opts.Require) == 0 { - return fmt.Errorf("no features specified") - } - - requirements := make([]kfeatures.Requirement, 0, len(opts.Require)) - for _, f := range opts.Require { - requirements = append(requirements, f) + requirements, err := assembleCheckRequirements(opts) + if err != nil { + return err } - err := kfeatures.Check(requirements...) + err = kfeatures.Check(requirements...) if err != nil { // FeatureError is a business outcome, not an invocation // error: --json emits the documented {ok,feature,reason} diff --git a/cmd/kfeatures/main_test.go b/cmd/kfeatures/main_test.go index 8e6a174..71a2121 100644 --- a/cmd/kfeatures/main_test.go +++ b/cmd/kfeatures/main_test.go @@ -1,10 +1,16 @@ package main import ( + "bytes" + "encoding/json" + "os/exec" + "path/filepath" "strings" "testing" + "github.com/cilium/ebpf" "github.com/leodido/kfeatures" + "github.com/leodido/structcli" "github.com/spf13/cobra" ) @@ -111,3 +117,65 @@ func TestCheckOptionsCompleteRequire(t *testing.T) { } }) } + +func TestRenderELFProbesTextRequirementsModeOnlyPrintsRequirements(t *testing.T) { + probes := &kfeatures.ELFProbes{ + Path: "probe.bpf.o", + ProgramTypes: []kfeatures.ELFProgramTypeRequirement{ + {Name: ebpf.Kprobe.String(), Type: ebpf.Kprobe}, + }, + } + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + + renderELFProbesText(cmd, probes, true) + + got := out.String() + if !strings.Contains(got, "Requirements:\n") { + t.Fatalf("requirements output missing header: %q", got) + } + if strings.Contains(got, "ELF:") || strings.Contains(got, "BTF:") || strings.Contains(got, "Programs:") { + t.Fatalf("requirements mode should not include probe summary, got:\n%s", got) + } +} + +func TestProbeBpfRequirementsJSONOnlyPrintsRequirements(t *testing.T) { + path := filepath.Join(ciliumModuleDir(t), "testdata", "manyprogs-el.elf") + var out bytes.Buffer + cmd := probeBpfCmd() + cmd.SetOut(&out) + cmd.SetArgs([]string{"--json", "--requirements", path}) + + if _, err := structcli.ExecuteC(cmd); err != nil { + t.Fatalf("probe bpf --json --requirements: %v", err) + } + + var decoded any + if err := json.Unmarshal(out.Bytes(), &decoded); err != nil { + t.Fatalf("decode JSON output: %v\n%s", err, out.String()) + } + if _, ok := decoded.(map[string]any); ok { + t.Fatalf("requirements JSON should not include an enclosing probe object: %s", out.String()) + } + requirements, ok := decoded.([]any) + if !ok { + t.Fatalf("requirements JSON = %T, want array: %s", decoded, out.String()) + } + if len(requirements) == 0 { + t.Fatal("requirements JSON should contain at least one requirement") + } +} + +func ciliumModuleDir(t *testing.T) string { + t.Helper() + out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/cilium/ebpf").Output() + if err != nil { + t.Fatalf("locate cilium/ebpf module: %v", err) + } + dir := strings.TrimSpace(string(out)) + if dir == "" { + t.Fatal("go list returned empty cilium/ebpf module dir") + } + return dir +} diff --git a/go.mod b/go.mod index 1877969..2095e5d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 golang.org/x/sys v0.38.0 + golang.org/x/tools v0.38.0 ) require ( @@ -51,7 +52,6 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect golang.org/x/tools/cmd/cover v0.1.0-deprecated // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/kernelversions/cmd/kvgen/cilium_names.go b/internal/kernelversions/cmd/kvgen/cilium_names.go new file mode 100644 index 0000000..4241ff9 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/cilium_names.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sync" +) + +// The generator emits Go references like `asm.FnBind` and `ebpf.Hash`. +// Not every identifier present in the BCC table or UAPI header has a +// matching constant in cilium/ebpf — sometimes cilium hasn't caught up +// yet, sometimes the cilium constant is shaped differently (e.g. +// UnspecifiedMap vs BPF_MAP_TYPE_UNSPEC). We discover the actual cilium +// constants by reading the cilium source files in the module cache, so +// that the snapshot only references symbols that exist. +// +// We deliberately avoid a Go AST walk: a regex over the raw source is +// sufficient because cilium/ebpf's helper / map-type / program-type +// declarations are all single-line constant definitions in well-known +// files. + +var ( + ciliumLoadOnce sync.Once + ciliumHelpers map[string]struct{} + ciliumProgTypes map[string]struct{} + ciliumMapTypes map[string]struct{} + ciliumLoadErr error +) + +func loadCiliumNames() { + ciliumLoadOnce.Do(func() { + dir, err := ciliumModuleDir() + if err != nil { + ciliumLoadErr = err + return + } + ciliumHelpers, err = harvestIdents(filepath.Join(dir, "asm", "func_lin.go"), + regexp.MustCompile(`(?m)^\s+(Fn[A-Z][A-Za-z0-9]*)\s*=`)) + if err != nil { + ciliumLoadErr = fmt.Errorf("harvest helpers: %w", err) + return + } + ciliumMapTypes, err = harvestMapTypes(filepath.Join(dir, "types.go")) + if err != nil { + ciliumLoadErr = fmt.Errorf("harvest map types: %w", err) + return + } + ciliumProgTypes, err = harvestProgTypes(filepath.Join(dir, "types.go")) + if err != nil { + ciliumLoadErr = fmt.Errorf("harvest program types: %w", err) + return + } + }) +} + +func ciliumModuleDir() (string, error) { + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/cilium/ebpf") + cmd.Env = os.Environ() + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("go list cilium/ebpf: %w", err) + } + dir := string(bytes.TrimSpace(out)) + if dir == "" { + return "", fmt.Errorf("cilium/ebpf module dir not found") + } + return dir, nil +} + +func harvestIdents(path string, re *regexp.Regexp) (map[string]struct{}, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + out := map[string]struct{}{} + for _, m := range re.FindAllSubmatch(body, -1) { + out[string(m[1])] = struct{}{} + } + return out, nil +} + +// harvestMapTypes finds the iota-driven const block in cilium/ebpf/types.go +// that starts with `UnspecifiedMap MapType = MapType(platform.LinuxTag | iota)` +// and returns every bare identifier inside it. +func harvestMapTypes(path string) (map[string]struct{}, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + idx := bytes.Index(body, []byte("UnspecifiedMap MapType")) + if idx < 0 { + return nil, fmt.Errorf("UnspecifiedMap declaration not found in %s", path) + } + end := bytes.Index(body[idx:], []byte("\n)")) + if end < 0 { + return nil, fmt.Errorf("closing paren after UnspecifiedMap not found in %s", path) + } + block := body[idx : idx+end] + identRE := regexp.MustCompile(`(?m)^\s*([A-Z][A-Za-z0-9_]*)\s*$`) + out := map[string]struct{}{ + "UnspecifiedMap": {}, + } + for _, m := range identRE.FindAllSubmatch(block, -1) { + out[string(m[1])] = struct{}{} + } + return out, nil +} + +// harvestProgTypes finds the explicit ` = ProgramType(sys.BPF_PROG_TYPE_*)` +// block in cilium/ebpf/types.go and returns every left-hand identifier. +func harvestProgTypes(path string) (map[string]struct{}, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + re := regexp.MustCompile(`(?m)^\s+([A-Z][A-Za-z0-9_]*)\s*=\s*ProgramType\(`) + out := map[string]struct{}{} + for _, m := range re.FindAllSubmatch(body, -1) { + out[string(m[1])] = struct{}{} + } + return out, nil +} + +func ciliumHasHelper(goConst string) bool { + loadCiliumNames() + if ciliumLoadErr != nil { + fmt.Fprintln(os.Stderr, "kvgen warning: cilium name lookup failed:", ciliumLoadErr) + return false + } + _, ok := ciliumHelpers[goConst] + return ok +} + +func ciliumHasProgType(goConst string) bool { + loadCiliumNames() + if ciliumLoadErr != nil { + return false + } + _, ok := ciliumProgTypes[goConst] + return ok +} + +func ciliumHasMapType(goConst string) bool { + loadCiliumNames() + if ciliumLoadErr != nil { + return false + } + _, ok := ciliumMapTypes[goConst] + return ok +} diff --git a/internal/kernelversions/cmd/kvgen/cilium_names_test.go b/internal/kernelversions/cmd/kvgen/cilium_names_test.go new file mode 100644 index 0000000..f905bf0 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/cilium_names_test.go @@ -0,0 +1,36 @@ +package main + +import "testing" + +func TestCiliumHasHelper(t *testing.T) { + if !ciliumHasHelper("FnBind") { + t.Errorf("FnBind should be present in cilium/ebpf") + } + if ciliumHasHelper("FnDoesNotExist") { + t.Errorf("FnDoesNotExist should not be present") + } +} + +func TestCiliumHasProgType(t *testing.T) { + if !ciliumHasProgType("Kprobe") { + t.Errorf("Kprobe should be present in cilium/ebpf") + } + if !ciliumHasProgType("XDP") { + t.Errorf("XDP should be present in cilium/ebpf") + } + if ciliumHasProgType("DoesNotExist") { + t.Errorf("DoesNotExist should not be present") + } +} + +func TestCiliumHasMapType(t *testing.T) { + if !ciliumHasMapType("Hash") { + t.Errorf("Hash should be present in cilium/ebpf") + } + if !ciliumHasMapType("RingBuf") { + t.Errorf("RingBuf should be present in cilium/ebpf") + } + if ciliumHasMapType("DoesNotExist") { + t.Errorf("DoesNotExist should not be present") + } +} diff --git a/internal/kernelversions/cmd/kvgen/emit.go b/internal/kernelversions/cmd/kvgen/emit.go new file mode 100644 index 0000000..093a66b --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/emit.go @@ -0,0 +1,161 @@ +package main + +import ( + "fmt" + "go/format" + "os" + "sort" + "strings" +) + +// source is the normalized input used to render the generated Go snapshot. +type source struct { + BCCCommit string + KernelCommit string + Helpers []helperRow + ProgramTypes []enumRow + MapTypes []enumRow +} + +type helperRow struct { + UAPI string // e.g. "BPF_FUNC_bind" + GoConst string // e.g. "FnBind" + Version kernelVersion // major.minor introduced +} + +type enumRow struct { + UAPI string // e.g. "BPF_PROG_TYPE_KPROBE" + GoConst string // e.g. "Kprobe" + Version kernelVersion +} + +func buildSource(bcc *bccTables, bccCommit, kernelCommit string) *source { + src := &source{ + BCCCommit: bccCommit, + KernelCommit: kernelCommit, + } + + // Helpers: UAPI key is "BPF_FUNC_bind", Go const is camelCase("Fn"+name). + helperKeys := make([]string, 0, len(bcc.Helpers)) + for k := range bcc.Helpers { + helperKeys = append(helperKeys, k) + } + sort.Strings(helperKeys) + for _, name := range helperKeys { + src.Helpers = append(src.Helpers, helperRow{ + UAPI: "BPF_FUNC_" + name, + GoConst: "Fn" + camelize(name), + Version: bcc.Helpers[name], + }) + } + + progKeys := make([]string, 0, len(bcc.ProgramTypes)) + for k := range bcc.ProgramTypes { + progKeys = append(progKeys, k) + } + sort.Strings(progKeys) + for _, k := range progKeys { + src.ProgramTypes = append(src.ProgramTypes, enumRow{ + UAPI: k, + GoConst: ciliumProgTypeName(k), + Version: bcc.ProgramTypes[k], + }) + } + + mapKeys := make([]string, 0, len(bcc.MapTypes)) + for k := range bcc.MapTypes { + mapKeys = append(mapKeys, k) + } + sort.Strings(mapKeys) + for _, k := range mapKeys { + src.MapTypes = append(src.MapTypes, enumRow{ + UAPI: k, + GoConst: ciliumMapTypeName(k), + Version: bcc.MapTypes[k], + }) + } + + return src +} + +func writeTablesGo(path string, src *source) error { + // Fail closed: without cilium names, filtering would silently emit partial tables. + if err := ensureCiliumNames(); err != nil { + return err + } + + var b strings.Builder + fmt.Fprintln(&b, "// Code generated by internal/kernelversions/cmd/kvgen; DO NOT EDIT.") + fmt.Fprintln(&b, "//") + fmt.Fprintf(&b, "// BCC commit: %s\n", src.BCCCommit) + fmt.Fprintf(&b, "// Kernel commit: %s\n", src.KernelCommit) + fmt.Fprintln(&b) + fmt.Fprintln(&b, "package kernelversions") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "import (") + fmt.Fprintln(&b, "\t\"github.com/cilium/ebpf\"") + fmt.Fprintln(&b, "\t\"github.com/cilium/ebpf/asm\"") + fmt.Fprintln(&b, ")") + fmt.Fprintln(&b) + + fmt.Fprintln(&b, "// HelperVersion maps each known eBPF helper to the kernel version that introduced it.") + fmt.Fprintln(&b, "var HelperVersion = map[asm.BuiltinFunc]KernelVersion{") + for _, h := range src.Helpers { + // Skip helpers cilium/ebpf doesn't expose (the snapshot is the + // intersection of BCC ⨯ cilium so unknown identifiers do not + // produce uncompilable references). + if !ciliumHasHelper(h.GoConst) { + continue + } + fmt.Fprintf(&b, "\tasm.%s: {Major: %d, Minor: %d},\n", h.GoConst, h.Version.Major, h.Version.Minor) + } + fmt.Fprintln(&b, "}") + fmt.Fprintln(&b) + + fmt.Fprintln(&b, "// ProgTypeVersion maps each known eBPF program type to the kernel version that introduced it.") + fmt.Fprintln(&b, "var ProgTypeVersion = map[ebpf.ProgramType]KernelVersion{") + for _, e := range src.ProgramTypes { + if e.GoConst == "" || !ciliumHasProgType(e.GoConst) { + continue + } + fmt.Fprintf(&b, "\tebpf.%s: {Major: %d, Minor: %d},\n", e.GoConst, e.Version.Major, e.Version.Minor) + } + fmt.Fprintln(&b, "}") + fmt.Fprintln(&b) + + fmt.Fprintln(&b, "// MapTypeVersion maps each known eBPF map type to the kernel version that introduced it.") + fmt.Fprintln(&b, "var MapTypeVersion = map[ebpf.MapType]KernelVersion{") + for _, e := range src.MapTypes { + if e.GoConst == "" || !ciliumHasMapType(e.GoConst) { + continue + } + fmt.Fprintf(&b, "\tebpf.%s: {Major: %d, Minor: %d},\n", e.GoConst, e.Version.Major, e.Version.Minor) + } + fmt.Fprintln(&b, "}") + + formatted, err := format.Source([]byte(b.String())) + if err != nil { + return fmt.Errorf("gofmt: %w", err) + } + return os.WriteFile(path, formatted, 0o644) +} + +func ensureCiliumNames() error { + loadCiliumNames() + if ciliumLoadErr != nil { + return fmt.Errorf("cilium name lookup failed: %w", ciliumLoadErr) + } + return nil +} + +// camelize turns "ktime_get_ns" into "KtimeGetNs". +func camelize(snake string) string { + parts := strings.Split(snake, "_") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, "") +} diff --git a/internal/kernelversions/cmd/kvgen/emit_test.go b/internal/kernelversions/cmd/kvgen/emit_test.go new file mode 100644 index 0000000..30b62e5 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/emit_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestBuildSource(t *testing.T) { + bcc := &bccTables{ + Helpers: map[string]kernelVersion{"bind": {4, 17}, "ktime_get_ns": {3, 18}}, + ProgramTypes: map[string]kernelVersion{"BPF_PROG_TYPE_KPROBE": {4, 1}, "BPF_PROG_TYPE_XDP": {4, 8}}, + MapTypes: map[string]kernelVersion{"BPF_MAP_TYPE_HASH": {3, 19}, "BPF_MAP_TYPE_RINGBUF": {5, 8}}, + } + src := buildSource(bcc, "bcc-sha", "kernel-sha") + if src.BCCCommit != "bcc-sha" || src.KernelCommit != "kernel-sha" { + t.Fatalf("commits not propagated: %+v", src) + } + // Helpers must be sorted by UAPI name. + if len(src.Helpers) != 2 || src.Helpers[0].UAPI != "BPF_FUNC_bind" || src.Helpers[1].UAPI != "BPF_FUNC_ktime_get_ns" { + t.Fatalf("helpers not sorted: %+v", src.Helpers) + } + if src.Helpers[0].GoConst != "FnBind" { + t.Errorf("helper Go const = %q, want FnBind", src.Helpers[0].GoConst) + } + if src.Helpers[1].GoConst != "FnKtimeGetNs" { + t.Errorf("helper Go const = %q, want FnKtimeGetNs", src.Helpers[1].GoConst) + } + // ProgramTypes sorted by UAPI; mapping uses ciliumProgTypeName. + if src.ProgramTypes[0].GoConst != "Kprobe" || src.ProgramTypes[1].GoConst != "XDP" { + t.Errorf("prog type names = %+v", src.ProgramTypes) + } + if src.MapTypes[0].GoConst != "Hash" || src.MapTypes[1].GoConst != "RingBuf" { + t.Errorf("map type names = %+v", src.MapTypes) + } +} + +func TestWriteTablesGoFormatsAndCompiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tables.go") + src := &source{ + BCCCommit: "abc", + KernelCommit: "def", + Helpers: []helperRow{{UAPI: "BPF_FUNC_bind", GoConst: "FnBind", Version: kernelVersion{4, 17}}}, + ProgramTypes: []enumRow{{UAPI: "BPF_PROG_TYPE_KPROBE", GoConst: "Kprobe", Version: kernelVersion{4, 1}}}, + MapTypes: []enumRow{{UAPI: "BPF_MAP_TYPE_HASH", GoConst: "Hash", Version: kernelVersion{3, 19}}}, + } + if err := writeTablesGo(path, src); err != nil { + t.Fatalf("writeTablesGo: %v", err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + got := string(body) + for _, want := range []string{"package kernelversions", "asm.FnBind", "ebpf.Kprobe", "ebpf.Hash", "BCC commit: abc", "Kernel commit: def"} { + if !strings.Contains(got, want) { + t.Errorf("tables.go missing %q\n%s", want, got) + } + } +} + +func TestWriteTablesGoFailsWhenCiliumNamesUnavailable(t *testing.T) { + resetCiliumNameCache(t) + ciliumLoadOnce.Do(func() { + ciliumLoadErr = errors.New("boom") + }) + + dir := t.TempDir() + path := filepath.Join(dir, "tables.go") + src := &source{ + BCCCommit: "abc", + KernelCommit: "def", + Helpers: []helperRow{{UAPI: "BPF_FUNC_bind", GoConst: "FnBind", Version: kernelVersion{4, 17}}}, + ProgramTypes: []enumRow{{UAPI: "BPF_PROG_TYPE_KPROBE", GoConst: "Kprobe", Version: kernelVersion{4, 1}}}, + MapTypes: []enumRow{{UAPI: "BPF_MAP_TYPE_HASH", GoConst: "Hash", Version: kernelVersion{3, 19}}}, + } + + err := writeTablesGo(path, src) + if err == nil { + t.Fatal("writeTablesGo succeeded, want cilium name lookup error") + } + if !strings.Contains(err.Error(), "cilium name lookup failed") || !strings.Contains(err.Error(), "boom") { + t.Fatalf("writeTablesGo error = %v", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("tables.go should not be emitted on cilium name lookup failure, stat err = %v", statErr) + } +} + +func resetCiliumNameCache(t *testing.T) { + t.Helper() + ciliumLoadOnce = sync.Once{} + ciliumHelpers = nil + ciliumProgTypes = nil + ciliumMapTypes = nil + ciliumLoadErr = nil + t.Cleanup(func() { + ciliumLoadOnce = sync.Once{} + ciliumHelpers = nil + ciliumProgTypes = nil + ciliumMapTypes = nil + ciliumLoadErr = nil + }) +} + +func TestCiliumProgTypeNameAllCases(t *testing.T) { + cases := map[string]string{ + "BPF_PROG_TYPE_SOCKET_FILTER": "SocketFilter", + "BPF_PROG_TYPE_KPROBE": "Kprobe", + "BPF_PROG_TYPE_SCHED_CLS": "SchedCLS", + "BPF_PROG_TYPE_SCHED_ACT": "SchedACT", + "BPF_PROG_TYPE_TRACEPOINT": "TracePoint", + "BPF_PROG_TYPE_XDP": "XDP", + "BPF_PROG_TYPE_PERF_EVENT": "PerfEvent", + "BPF_PROG_TYPE_CGROUP_SKB": "CGroupSKB", + "BPF_PROG_TYPE_CGROUP_SOCK": "CGroupSock", + "BPF_PROG_TYPE_LWT_IN": "LWTIn", + "BPF_PROG_TYPE_LWT_OUT": "LWTOut", + "BPF_PROG_TYPE_LWT_XMIT": "LWTXmit", + "BPF_PROG_TYPE_SOCK_OPS": "SockOps", + "BPF_PROG_TYPE_SK_SKB": "SkSKB", + "BPF_PROG_TYPE_CGROUP_DEVICE": "CGroupDevice", + "BPF_PROG_TYPE_SK_MSG": "SkMsg", + "BPF_PROG_TYPE_RAW_TRACEPOINT": "RawTracepoint", + "BPF_PROG_TYPE_CGROUP_SOCK_ADDR": "CGroupSockAddr", + "BPF_PROG_TYPE_LWT_SEG6LOCAL": "LWTSeg6Local", + "BPF_PROG_TYPE_LIRC_MODE2": "LircMode2", + "BPF_PROG_TYPE_SK_REUSEPORT": "SkReuseport", + "BPF_PROG_TYPE_FLOW_DISSECTOR": "FlowDissector", + "BPF_PROG_TYPE_CGROUP_SYSCTL": "CGroupSysctl", + "BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE": "RawTracepointWritable", + "BPF_PROG_TYPE_CGROUP_SOCKOPT": "CGroupSockopt", + "BPF_PROG_TYPE_TRACING": "Tracing", + "BPF_PROG_TYPE_STRUCT_OPS": "StructOps", + "BPF_PROG_TYPE_EXT": "Extension", + "BPF_PROG_TYPE_LSM": "LSM", + "BPF_PROG_TYPE_SK_LOOKUP": "SkLookup", + "BPF_PROG_TYPE_SYSCALL": "Syscall", + "BPF_PROG_TYPE_NETFILTER": "Netfilter", + "BPF_PROG_TYPE_DOES_NOT_EXIST": "", + } + for in, want := range cases { + if got := ciliumProgTypeName(in); got != want { + t.Errorf("ciliumProgTypeName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCiliumMapTypeNameAllCases(t *testing.T) { + cases := map[string]string{ + "BPF_MAP_TYPE_HASH": "Hash", + "BPF_MAP_TYPE_ARRAY": "Array", + "BPF_MAP_TYPE_PROG_ARRAY": "ProgramArray", + "BPF_MAP_TYPE_PERF_EVENT_ARRAY": "PerfEventArray", + "BPF_MAP_TYPE_PERCPU_HASH": "PerCPUHash", + "BPF_MAP_TYPE_PERCPU_ARRAY": "PerCPUArray", + "BPF_MAP_TYPE_STACK_TRACE": "StackTrace", + "BPF_MAP_TYPE_CGROUP_ARRAY": "CGroupArray", + "BPF_MAP_TYPE_LRU_HASH": "LRUHash", + "BPF_MAP_TYPE_LRU_PERCPU_HASH": "LRUCPUHash", + "BPF_MAP_TYPE_LPM_TRIE": "LPMTrie", + "BPF_MAP_TYPE_ARRAY_OF_MAPS": "ArrayOfMaps", + "BPF_MAP_TYPE_HASH_OF_MAPS": "HashOfMaps", + "BPF_MAP_TYPE_DEVMAP": "DevMap", + "BPF_MAP_TYPE_SOCKMAP": "SockMap", + "BPF_MAP_TYPE_CPUMAP": "CPUMap", + "BPF_MAP_TYPE_XSKMAP": "XSKMap", + "BPF_MAP_TYPE_SOCKHASH": "SockHash", + "BPF_MAP_TYPE_CGROUP_STORAGE": "CGroupStorage", + "BPF_MAP_TYPE_REUSEPORT_SOCKARRAY": "ReusePortSockArray", + "BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE": "PerCPUCGroupStorage", + "BPF_MAP_TYPE_QUEUE": "Queue", + "BPF_MAP_TYPE_STACK": "Stack", + "BPF_MAP_TYPE_SK_STORAGE": "SkStorage", + "BPF_MAP_TYPE_DEVMAP_HASH": "DevMapHash", + "BPF_MAP_TYPE_STRUCT_OPS": "StructOpsMap", + "BPF_MAP_TYPE_RINGBUF": "RingBuf", + "BPF_MAP_TYPE_INODE_STORAGE": "InodeStorage", + "BPF_MAP_TYPE_TASK_STORAGE": "TaskStorage", + "BPF_MAP_TYPE_BLOOM_FILTER": "BloomFilter", + "BPF_MAP_TYPE_USER_RINGBUF": "UserRingbuf", + "BPF_MAP_TYPE_CGRP_STORAGE": "CgroupStorage", + "BPF_MAP_TYPE_ARENA": "Arena", + "BPF_MAP_TYPE_DOES_NOT_EXIST": "", + } + for in, want := range cases { + if got := ciliumMapTypeName(in); got != want { + t.Errorf("ciliumMapTypeName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/kernelversions/cmd/kvgen/known_gaps.go b/internal/kernelversions/cmd/kvgen/known_gaps.go new file mode 100644 index 0000000..63c7238 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/known_gaps.go @@ -0,0 +1,38 @@ +package main + +// Known gaps where the upstream UAPI header documents an enum value that +// BCC's kernel-versions.md hasn't picked up yet (or the BCC document has +// chosen not to list — e.g. the synthetic *_UNSPEC entries). Each entry +// suppresses a single cross-validation diagnostic. +// +// New entries here require a maintainer review note explaining why the +// value is allowed to be absent. The intent is to keep the audit trail +// inside the generator rather than letting unknown values silently slip +// through. +// +// When BCC catches up, the entry can be removed; the generator will +// then have a row in its emitted table for the symbol, gated by what +// cilium/ebpf actually exposes. + +// allowedMissingHelpers lists BPF_FUNC_ identifiers the BCC +// table is allowed to omit. +var allowedMissingHelpers = map[string]string{ + // BCC table snapshot lags upstream; helper introduced in v6.x. + "skc_to_mptcp_sock": "BCC table not yet refreshed past v6.x; helper exists in UAPI", +} + +// allowedMissingProgTypes lists BPF_PROG_TYPE_ identifiers the BCC +// table is allowed to omit. +var allowedMissingProgTypes = map[string]string{ + "BPF_PROG_TYPE_UNSPEC": "synthetic enum sentinel; never appears in BCC table", + "BPF_PROG_TYPE_NETFILTER": "BCC table not yet refreshed past v6.x; type exists in UAPI", +} + +// allowedMissingMapTypes lists BPF_MAP_TYPE_ identifiers the BCC +// table is allowed to omit. +var allowedMissingMapTypes = map[string]string{ + "BPF_MAP_TYPE_UNSPEC": "synthetic enum sentinel; never appears in BCC table", + "BPF_MAP_TYPE_CGRP_STORAGE": "BCC table not yet refreshed past v6.x; type exists in UAPI", + "BPF_MAP_TYPE_ARENA": "BCC table not yet refreshed past v6.x; type exists in UAPI", + "BPF_MAP_TYPE_INSN_ARRAY": "BCC table not yet refreshed past v6.x; type exists in UAPI", +} diff --git a/internal/kernelversions/cmd/kvgen/main.go b/internal/kernelversions/cmd/kvgen/main.go new file mode 100644 index 0000000..1150793 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/main.go @@ -0,0 +1,104 @@ +// kvgen regenerates the kernel-version snapshot consumed by package +// kernelversions. +// +// It fetches the BCC kernel-versions.md document and the libbpf UAPI bpf.h +// header at pinned commits, parses the helper / program-type / map-type +// tables, cross-validates that every BPF_FUNC_* / BPF_PROG_TYPE_* / +// BPF_MAP_TYPE_* enum value present in the UAPI header has a corresponding +// row in the BCC table, then emits tables.go in the parent package directory. +// +// Usage: +// +// go run ./internal/kernelversions/cmd/kvgen \ +// --bcc-commit= --kernel-commit= \ +// --output-dir=internal/kernelversions +// +// Defaults are the pinned commits embedded at the top of this file. Run +// without flags to refresh against those pins; override flags only when +// preparing a snapshot bump. +package main + +import ( + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +// Default pins. Bumped by the scheduled refresh workflow when upstream +// changes; do not edit by hand. +const ( + defaultBCCCommit = "91c1e8ee5f5a5b85d3bfe8e35d11fa0a6d3b5e52" + defaultKernelCommit = "c7e4e4d5f7dc2daa439303d1b5bf6bdfaa249f49" +) + +// main is the CLI entrypoint. Network-bound; not exercised by unit tests. +// coverage:ignore +func main() { + bccCommit := flag.String("bcc-commit", defaultBCCCommit, "iovisor/bcc commit SHA to fetch kernel-versions.md from") + kernelCommit := flag.String("kernel-commit", defaultKernelCommit, "torvalds/linux commit SHA to fetch include/uapi/linux/bpf.h from") + outputDir := flag.String("output-dir", "internal/kernelversions", "directory to write tables.go into") + flag.Parse() + + if err := run(*bccCommit, *kernelCommit, *outputDir); err != nil { + fmt.Fprintln(os.Stderr, "kvgen:", err) + os.Exit(1) + } +} + +// run drives the generator end to end. The two HTTP fetches make this +// network-bound, so it is excluded from per-file coverage gating; the +// individual parser/validator/emitter steps are exercised separately. +// coverage:ignore +func run(bccCommit, kernelCommit, outputDir string) error { + bccURL := fmt.Sprintf("https://raw.githubusercontent.com/iovisor/bcc/%s/docs/kernel-versions.md", bccCommit) + bccBody, err := fetch(bccURL) + if err != nil { + return fmt.Errorf("fetch BCC kernel-versions.md: %w", err) + } + kernelURL := fmt.Sprintf("https://raw.githubusercontent.com/torvalds/linux/%s/include/uapi/linux/bpf.h", kernelCommit) + kernelBody, err := fetch(kernelURL) + if err != nil { + return fmt.Errorf("fetch UAPI bpf.h: %w", err) + } + + bcc, err := parseBCC(bccBody) + if err != nil { + return fmt.Errorf("parse BCC markdown: %w", err) + } + uapi, err := parseUAPI(kernelBody) + if err != nil { + return fmt.Errorf("parse UAPI header: %w", err) + } + if err := validate(bcc, uapi); err != nil { + return fmt.Errorf("cross-validate UAPI vs BCC: %w", err) + } + + src := buildSource(bcc, bccCommit, kernelCommit) + if err := writeTablesGo(filepath.Join(outputDir, "tables.go"), src); err != nil { + return fmt.Errorf("write tables.go: %w", err) + } + return nil +} + +// fetch performs an HTTP GET. Network-bound; coverage gated by run. +// coverage:ignore +func fetch(url string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode) + } + return io.ReadAll(resp.Body) +} diff --git a/internal/kernelversions/cmd/kvgen/main_test.go b/internal/kernelversions/cmd/kvgen/main_test.go new file mode 100644 index 0000000..1da06ce --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/main_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunEmitsOnlyTablesGo(t *testing.T) { + oldTransport := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + var body string + switch { + case strings.Contains(req.URL.Path, "/iovisor/bcc/"): + body = minimalBCCMarkdown() + case strings.Contains(req.URL.Path, "/torvalds/linux/"): + body = minimalUAPIHeader() + default: + t.Fatalf("unexpected fetch URL: %s", req.URL) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + Request: req, + }, nil + }) + t.Cleanup(func() { + http.DefaultTransport = oldTransport + }) + + resetCiliumNameCache(t) + ciliumLoadOnce.Do(func() { + ciliumHelpers = map[string]struct{}{"FnBind": {}} + ciliumProgTypes = map[string]struct{}{"Kprobe": {}} + ciliumMapTypes = map[string]struct{}{"Hash": {}} + }) + + dir := t.TempDir() + if err := run("bcc-sha", "kernel-sha", dir); err != nil { + t.Fatalf("run: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "tables.go")); err != nil { + t.Fatalf("tables.go not emitted: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "source.json")); !os.IsNotExist(err) { + t.Fatalf("source.json should not be emitted, stat err = %v", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func minimalBCCMarkdown() string { + return `# header + +## Helpers + +Helper | Kernel version | License | Commit | +-------|----------------|---------|--------| +` + "`BPF_FUNC_bind()`" + ` | 4.17 | | [` + "`d74bad4e74ee`" + `] + +## Maps + +### Map types + + Map type | Kernel version | Commit | Enum +----------|----------------|--------|------ +Hash | 3.19 | [` + "`xx`" + `] | BPF_MAP_TYPE_HASH + +## Main features + +### Program types + +Program type | Kernel version | Commit | Enum +-------------|----------------|--------|----- +Kprobe | 4.1 | [` + "`zz`" + `] | BPF_PROG_TYPE_KPROBE +` +} + +func minimalUAPIHeader() string { + return ` +#define ___BPF_FUNC_MAPPER(FN, ctx...)\ + FN(unspec, 0, ##ctx) \ + FN(bind, 2, ##ctx) + +enum bpf_map_type { + BPF_MAP_TYPE_HASH, + __MAX_BPF_MAP_TYPE +}; + +enum bpf_prog_type { + BPF_PROG_TYPE_KPROBE, + __MAX_BPF_PROG_TYPE +}; +` +} diff --git a/internal/kernelversions/cmd/kvgen/mappings.go b/internal/kernelversions/cmd/kvgen/mappings.go new file mode 100644 index 0000000..7d508e1 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/mappings.go @@ -0,0 +1,155 @@ +package main + +// ciliumProgTypeName maps a UAPI BPF_PROG_TYPE_* identifier to the +// corresponding exported constant in package github.com/cilium/ebpf. +// +// cilium/ebpf uses domain-friendly names (e.g. Kprobe, SchedCLS) instead +// of the verbose BPF_PROG_TYPE_KPROBE form. The mapping is intentionally +// explicit: a mechanical transformation would silently miscompile when +// cilium chooses a non-canonical Go name for a new type. +// +// Entries are added when a new program type lands in cilium/ebpf. Until +// then the snapshot omits the row (the lookup returns false). +func ciliumProgTypeName(uapi string) string { + switch uapi { + case "BPF_PROG_TYPE_SOCKET_FILTER": + return "SocketFilter" + case "BPF_PROG_TYPE_KPROBE": + return "Kprobe" + case "BPF_PROG_TYPE_SCHED_CLS": + return "SchedCLS" + case "BPF_PROG_TYPE_SCHED_ACT": + return "SchedACT" + case "BPF_PROG_TYPE_TRACEPOINT": + return "TracePoint" + case "BPF_PROG_TYPE_XDP": + return "XDP" + case "BPF_PROG_TYPE_PERF_EVENT": + return "PerfEvent" + case "BPF_PROG_TYPE_CGROUP_SKB": + return "CGroupSKB" + case "BPF_PROG_TYPE_CGROUP_SOCK": + return "CGroupSock" + case "BPF_PROG_TYPE_LWT_IN": + return "LWTIn" + case "BPF_PROG_TYPE_LWT_OUT": + return "LWTOut" + case "BPF_PROG_TYPE_LWT_XMIT": + return "LWTXmit" + case "BPF_PROG_TYPE_SOCK_OPS": + return "SockOps" + case "BPF_PROG_TYPE_SK_SKB": + return "SkSKB" + case "BPF_PROG_TYPE_CGROUP_DEVICE": + return "CGroupDevice" + case "BPF_PROG_TYPE_SK_MSG": + return "SkMsg" + case "BPF_PROG_TYPE_RAW_TRACEPOINT": + return "RawTracepoint" + case "BPF_PROG_TYPE_CGROUP_SOCK_ADDR": + return "CGroupSockAddr" + case "BPF_PROG_TYPE_LWT_SEG6LOCAL": + return "LWTSeg6Local" + case "BPF_PROG_TYPE_LIRC_MODE2": + return "LircMode2" + case "BPF_PROG_TYPE_SK_REUSEPORT": + return "SkReuseport" + case "BPF_PROG_TYPE_FLOW_DISSECTOR": + return "FlowDissector" + case "BPF_PROG_TYPE_CGROUP_SYSCTL": + return "CGroupSysctl" + case "BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE": + return "RawTracepointWritable" + case "BPF_PROG_TYPE_CGROUP_SOCKOPT": + return "CGroupSockopt" + case "BPF_PROG_TYPE_TRACING": + return "Tracing" + case "BPF_PROG_TYPE_STRUCT_OPS": + return "StructOps" + case "BPF_PROG_TYPE_EXT": + return "Extension" + case "BPF_PROG_TYPE_LSM": + return "LSM" + case "BPF_PROG_TYPE_SK_LOOKUP": + return "SkLookup" + case "BPF_PROG_TYPE_SYSCALL": + return "Syscall" + case "BPF_PROG_TYPE_NETFILTER": + return "Netfilter" + } + return "" +} + +// ciliumMapTypeName maps a UAPI BPF_MAP_TYPE_* identifier to the +// corresponding exported constant in package github.com/cilium/ebpf. +func ciliumMapTypeName(uapi string) string { + switch uapi { + case "BPF_MAP_TYPE_HASH": + return "Hash" + case "BPF_MAP_TYPE_ARRAY": + return "Array" + case "BPF_MAP_TYPE_PROG_ARRAY": + return "ProgramArray" + case "BPF_MAP_TYPE_PERF_EVENT_ARRAY": + return "PerfEventArray" + case "BPF_MAP_TYPE_PERCPU_HASH": + return "PerCPUHash" + case "BPF_MAP_TYPE_PERCPU_ARRAY": + return "PerCPUArray" + case "BPF_MAP_TYPE_STACK_TRACE": + return "StackTrace" + case "BPF_MAP_TYPE_CGROUP_ARRAY": + return "CGroupArray" + case "BPF_MAP_TYPE_LRU_HASH": + return "LRUHash" + case "BPF_MAP_TYPE_LRU_PERCPU_HASH": + return "LRUCPUHash" + case "BPF_MAP_TYPE_LPM_TRIE": + return "LPMTrie" + case "BPF_MAP_TYPE_ARRAY_OF_MAPS": + return "ArrayOfMaps" + case "BPF_MAP_TYPE_HASH_OF_MAPS": + return "HashOfMaps" + case "BPF_MAP_TYPE_DEVMAP": + return "DevMap" + case "BPF_MAP_TYPE_SOCKMAP": + return "SockMap" + case "BPF_MAP_TYPE_CPUMAP": + return "CPUMap" + case "BPF_MAP_TYPE_XSKMAP": + return "XSKMap" + case "BPF_MAP_TYPE_SOCKHASH": + return "SockHash" + case "BPF_MAP_TYPE_CGROUP_STORAGE": + return "CGroupStorage" + case "BPF_MAP_TYPE_REUSEPORT_SOCKARRAY": + return "ReusePortSockArray" + case "BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE": + return "PerCPUCGroupStorage" + case "BPF_MAP_TYPE_QUEUE": + return "Queue" + case "BPF_MAP_TYPE_STACK": + return "Stack" + case "BPF_MAP_TYPE_SK_STORAGE": + return "SkStorage" + case "BPF_MAP_TYPE_DEVMAP_HASH": + return "DevMapHash" + case "BPF_MAP_TYPE_STRUCT_OPS": + return "StructOpsMap" + case "BPF_MAP_TYPE_RINGBUF": + return "RingBuf" + case "BPF_MAP_TYPE_INODE_STORAGE": + return "InodeStorage" + case "BPF_MAP_TYPE_TASK_STORAGE": + return "TaskStorage" + case "BPF_MAP_TYPE_BLOOM_FILTER": + return "BloomFilter" + case "BPF_MAP_TYPE_USER_RINGBUF": + return "UserRingbuf" + case "BPF_MAP_TYPE_CGRP_STORAGE": + return "CgroupStorage" + case "BPF_MAP_TYPE_ARENA": + return "Arena" + } + return "" +} diff --git a/internal/kernelversions/cmd/kvgen/parser.go b/internal/kernelversions/cmd/kvgen/parser.go new file mode 100644 index 0000000..13d1f20 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/parser.go @@ -0,0 +1,235 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "regexp" + "strconv" + "strings" +) + +// kernelVersion mirrors kernelversions.KernelVersion. It is duplicated here +// to keep the generator self-contained (the generator emits the package's +// types.go; importing it would create a chicken-and-egg coupling on every +// rebuild). +type kernelVersion struct { + Major int + Minor int +} + +// bccTables holds the parsed contents of BCC's kernel-versions.md, keyed +// by the upstream identifier the markdown table provides. +type bccTables struct { + // Helpers maps the BCC helper enum name (e.g. "BPF_FUNC_bind") to the + // kernel version that introduced the helper. + Helpers map[string]kernelVersion + // ProgramTypes maps the BCC program-type enum (e.g. "BPF_PROG_TYPE_KPROBE") + // to the kernel version that introduced the program type. + ProgramTypes map[string]kernelVersion + // MapTypes maps the BCC map-type enum (e.g. "BPF_MAP_TYPE_HASH") to the + // kernel version that introduced the map type. + MapTypes map[string]kernelVersion +} + +// uapiSets holds the enum value sets discovered in include/uapi/linux/bpf.h. +type uapiSets struct { + // Helpers is the set of BPF_FUNC_ identifiers. Entries are stored + // in lowercase form (without the BPF_FUNC_ prefix) to match BCC's table + // rows after normalization. + Helpers map[string]struct{} + // ProgramTypes is the set of BPF_PROG_TYPE_ identifiers. + ProgramTypes map[string]struct{} + // MapTypes is the set of BPF_MAP_TYPE_ identifiers. + MapTypes map[string]struct{} +} + +var ( + // reHelperRow matches a row in BCC's "Helpers" markdown table. Each row + // looks like: `BPF_FUNC_bind()` | 4.17 | … | … + reHelperRow = regexp.MustCompile("`BPF_FUNC_([A-Za-z0-9_]+)\\(\\)`\\s*\\|\\s*([0-9]+\\.[0-9]+)") + + // reEnumRow matches a row in BCC's program-type / map-type tables. The + // trailing | | | column carries the canonical + // identifier we care about. + reEnumRow = regexp.MustCompile(`\|\s*([0-9]+\.[0-9]+)\s*\|.*\|\s*(BPF_(?:PROG|MAP)_TYPE_[A-Z0-9_]+)\s*$`) + + // reUAPIFn matches FN(, , …) macro invocations inside the + // FN-list block. + reUAPIFn = regexp.MustCompile(`^\s+FN\(([a-z0-9_]+),`) +) + +// parseBCC walks BCC's kernel-versions.md and extracts the helper / +// program-type / map-type tables. +func parseBCC(body []byte) (*bccTables, error) { + t := &bccTables{ + Helpers: map[string]kernelVersion{}, + ProgramTypes: map[string]kernelVersion{}, + MapTypes: map[string]kernelVersion{}, + } + section := "" + scanner := bufio.NewScanner(bytes.NewReader(body)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "## ") || strings.HasPrefix(line, "### "): + section = strings.TrimSpace(strings.TrimLeft(line, "# ")) + continue + } + switch section { + case "Helpers": + if m := reHelperRow.FindStringSubmatch(line); m != nil { + v, err := parseVersion(m[2]) + if err != nil { + return nil, fmt.Errorf("helper %q: %w", m[1], err) + } + t.Helpers[m[1]] = v + } + case "Program types": + if m := reEnumRow.FindStringSubmatch(line); m != nil && strings.HasPrefix(m[2], "BPF_PROG_TYPE_") { + v, err := parseVersion(m[1]) + if err != nil { + return nil, fmt.Errorf("program type %q: %w", m[2], err) + } + t.ProgramTypes[m[2]] = v + } + case "Map types": + if m := reEnumRow.FindStringSubmatch(line); m != nil && strings.HasPrefix(m[2], "BPF_MAP_TYPE_") { + v, err := parseVersion(m[1]) + if err != nil { + return nil, fmt.Errorf("map type %q: %w", m[2], err) + } + t.MapTypes[m[2]] = v + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(t.Helpers) == 0 { + return nil, fmt.Errorf("no helpers parsed (markdown layout changed?)") + } + if len(t.ProgramTypes) == 0 { + return nil, fmt.Errorf("no program types parsed (markdown layout changed?)") + } + if len(t.MapTypes) == 0 { + return nil, fmt.Errorf("no map types parsed (markdown layout changed?)") + } + return t, nil +} + +// parseUAPI walks include/uapi/linux/bpf.h and harvests the helper FN-list +// plus the bpf_prog_type and bpf_map_type enum bodies. +func parseUAPI(body []byte) (*uapiSets, error) { + u := &uapiSets{ + Helpers: map[string]struct{}{}, + ProgramTypes: map[string]struct{}{}, + MapTypes: map[string]struct{}{}, + } + scanner := bufio.NewScanner(bytes.NewReader(body)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + enumState := "" // "", "prog", "map" + for scanner.Scan() { + line := scanner.Text() + // FN(, , …) macros for helpers. The sentinel `FN(x, ...)` + // inside the macro template is the only single-character entry; we + // drop it. + if m := reUAPIFn.FindStringSubmatch(line); m != nil { + name := m[1] + if name == "x" || name == "unspec" { + continue + } + u.Helpers[name] = struct{}{} + continue + } + switch { + case strings.HasPrefix(line, "enum bpf_prog_type"): + enumState = "prog" + continue + case strings.HasPrefix(line, "enum bpf_map_type"): + enumState = "map" + continue + } + if enumState != "" { + trimmed := strings.TrimSpace(line) + if trimmed == "};" { + enumState = "" + continue + } + // Match a leading identifier ending with `,` or `=` so we don't + // confuse comments / blank lines. + id := leadingEnumIdent(trimmed) + if id == "" { + continue + } + switch enumState { + case "prog": + if strings.HasPrefix(id, "BPF_PROG_TYPE_") && !strings.HasSuffix(id, "_DEPRECATED") && id != "__MAX_BPF_PROG_TYPE" { + u.ProgramTypes[id] = struct{}{} + } + case "map": + if strings.HasPrefix(id, "BPF_MAP_TYPE_") && !strings.HasSuffix(id, "_DEPRECATED") && id != "__MAX_BPF_MAP_TYPE" { + u.MapTypes[id] = struct{}{} + } + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(u.Helpers) == 0 { + return nil, fmt.Errorf("no helpers parsed (UAPI header layout changed?)") + } + if len(u.ProgramTypes) == 0 { + return nil, fmt.Errorf("no program types parsed (UAPI header layout changed?)") + } + if len(u.MapTypes) == 0 { + return nil, fmt.Errorf("no map types parsed (UAPI header layout changed?)") + } + return u, nil +} + +// leadingEnumIdent extracts the C identifier that starts an enum value +// declaration. Returns "" if the line doesn't look like an enum value. +func leadingEnumIdent(s string) string { + end := 0 + for end < len(s) { + c := s[end] + if c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + end++ + continue + } + break + } + if end == 0 { + return "" + } + id := s[:end] + rest := strings.TrimSpace(s[end:]) + if rest == "" { + return "" + } + switch rest[0] { + case ',', '=': + return id + } + return "" +} + +func parseVersion(s string) (kernelVersion, error) { + parts := strings.SplitN(s, ".", 2) + if len(parts) != 2 { + return kernelVersion{}, fmt.Errorf("invalid version %q", s) + } + maj, err := strconv.Atoi(parts[0]) + if err != nil { + return kernelVersion{}, fmt.Errorf("invalid major in %q: %w", s, err) + } + min, err := strconv.Atoi(parts[1]) + if err != nil { + return kernelVersion{}, fmt.Errorf("invalid minor in %q: %w", s, err) + } + return kernelVersion{Major: maj, Minor: min}, nil +} diff --git a/internal/kernelversions/cmd/kvgen/parser_test.go b/internal/kernelversions/cmd/kvgen/parser_test.go new file mode 100644 index 0000000..1af3df2 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/parser_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseBCC(t *testing.T) { + md := `# header + +## Helpers + +Helper | Kernel version | License | Commit | +-------|----------------|---------|--------| +` + "`BPF_FUNC_bind()`" + ` | 4.17 | | [` + "`d74bad4e74ee`" + `] +` + "`BPF_FUNC_ktime_get_ns()`" + ` | 3.18 | | [` + "`abc`" + `] + +## Maps + +### Map types + + Map type | Kernel version | Commit | Enum +----------|----------------|--------|------ +Hash | 3.19 | [` + "`xx`" + `] | BPF_MAP_TYPE_HASH +Array | 3.19 | [` + "`yy`" + `] | BPF_MAP_TYPE_ARRAY + +## Main features + +### Program types + +Program type | Kernel version | Commit | Enum +-------------|----------------|--------|----- +Kprobe | 4.1 | [` + "`zz`" + `] | BPF_PROG_TYPE_KPROBE +XDP | 4.8 | [` + "`zz`" + `] | BPF_PROG_TYPE_XDP +` + got, err := parseBCC([]byte(md)) + if err != nil { + t.Fatalf("parseBCC: %v", err) + } + if v, ok := got.Helpers["bind"]; !ok || v != (kernelVersion{4, 17}) { + t.Fatalf("Helpers[bind]=%v ok=%v want {4 17}", v, ok) + } + if v, ok := got.Helpers["ktime_get_ns"]; !ok || v != (kernelVersion{3, 18}) { + t.Fatalf("Helpers[ktime_get_ns]=%v ok=%v want {3 18}", v, ok) + } + if v, ok := got.MapTypes["BPF_MAP_TYPE_HASH"]; !ok || v != (kernelVersion{3, 19}) { + t.Fatalf("MapTypes[BPF_MAP_TYPE_HASH]=%v ok=%v want {3 19}", v, ok) + } + if v, ok := got.ProgramTypes["BPF_PROG_TYPE_KPROBE"]; !ok || v != (kernelVersion{4, 1}) { + t.Fatalf("ProgramTypes[BPF_PROG_TYPE_KPROBE]=%v ok=%v want {4 1}", v, ok) + } + if len(got.MapTypes) != 2 || len(got.ProgramTypes) != 2 || len(got.Helpers) != 2 { + t.Fatalf("counts wrong: helpers=%d progs=%d maps=%d", len(got.Helpers), len(got.ProgramTypes), len(got.MapTypes)) + } +} + +func TestParseBCC_LayoutChange(t *testing.T) { + // Empty input should fail loudly. + _, err := parseBCC([]byte("# nothing\n")) + if err == nil { + t.Fatal("parseBCC: expected error on empty input") + } + if !strings.Contains(err.Error(), "no helpers parsed") { + t.Fatalf("parseBCC: error %q does not mention helpers", err) + } +} + +func TestParseUAPI(t *testing.T) { + header := ` +#define ___BPF_FUNC_MAPPER(FN, ctx...)\ + FN(unspec, 0, ##ctx) \ + FN(map_lookup_elem, 1, ##ctx) \ + FN(bind, 2, ##ctx) \ + FN(x, 1000, ##ctx) + +enum bpf_map_type { + BPF_MAP_TYPE_UNSPEC, + BPF_MAP_TYPE_HASH, + BPF_MAP_TYPE_ARRAY, + BPF_MAP_TYPE_CGROUP_STORAGE_DEPRECATED, + BPF_MAP_TYPE_CGROUP_STORAGE = BPF_MAP_TYPE_CGROUP_STORAGE_DEPRECATED, + __MAX_BPF_MAP_TYPE +}; + +enum bpf_prog_type { + BPF_PROG_TYPE_UNSPEC, + BPF_PROG_TYPE_KPROBE, + __MAX_BPF_PROG_TYPE +}; +` + got, err := parseUAPI([]byte(header)) + if err != nil { + t.Fatalf("parseUAPI: %v", err) + } + for _, want := range []string{"map_lookup_elem", "bind"} { + if _, ok := got.Helpers[want]; !ok { + t.Errorf("Helpers missing %q", want) + } + } + if _, ok := got.Helpers["x"]; ok { + t.Errorf("Helpers should not contain sentinel 'x'") + } + if _, ok := got.Helpers["unspec"]; ok { + t.Errorf("Helpers should not contain 'unspec'") + } + for _, want := range []string{"BPF_MAP_TYPE_HASH", "BPF_MAP_TYPE_ARRAY", "BPF_MAP_TYPE_CGROUP_STORAGE", "BPF_MAP_TYPE_UNSPEC"} { + if _, ok := got.MapTypes[want]; !ok { + t.Errorf("MapTypes missing %q", want) + } + } + if _, ok := got.MapTypes["BPF_MAP_TYPE_CGROUP_STORAGE_DEPRECATED"]; ok { + t.Errorf("MapTypes should drop _DEPRECATED entries") + } + if _, ok := got.MapTypes["__MAX_BPF_MAP_TYPE"]; ok { + t.Errorf("MapTypes should drop __MAX sentinel") + } + for _, want := range []string{"BPF_PROG_TYPE_UNSPEC", "BPF_PROG_TYPE_KPROBE"} { + if _, ok := got.ProgramTypes[want]; !ok { + t.Errorf("ProgramTypes missing %q", want) + } + } +} + +func TestValidateMissing(t *testing.T) { + bcc := &bccTables{ + Helpers: map[string]kernelVersion{"bind": {4, 17}}, + ProgramTypes: map[string]kernelVersion{"BPF_PROG_TYPE_KPROBE": {4, 1}}, + MapTypes: map[string]kernelVersion{"BPF_MAP_TYPE_HASH": {3, 19}}, + } + uapi := &uapiSets{ + Helpers: map[string]struct{}{"bind": {}, "new_helper": {}}, + ProgramTypes: map[string]struct{}{"BPF_PROG_TYPE_KPROBE": {}, "BPF_PROG_TYPE_NEW": {}}, + MapTypes: map[string]struct{}{"BPF_MAP_TYPE_HASH": {}, "BPF_MAP_TYPE_NEW": {}}, + } + err := validate(bcc, uapi) + if err == nil { + t.Fatal("validate: expected error") + } + for _, want := range []string{"BPF_FUNC_new_helper", "BPF_PROG_TYPE_NEW", "BPF_MAP_TYPE_NEW"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("validate error missing %q; got: %s", want, err) + } + } +} + +func TestValidateAllowedMissing(t *testing.T) { + bcc := &bccTables{ + Helpers: map[string]kernelVersion{}, + ProgramTypes: map[string]kernelVersion{}, + MapTypes: map[string]kernelVersion{}, + } + uapi := &uapiSets{ + Helpers: map[string]struct{}{"skc_to_mptcp_sock": {}}, + ProgramTypes: map[string]struct{}{"BPF_PROG_TYPE_UNSPEC": {}, "BPF_PROG_TYPE_NETFILTER": {}}, + MapTypes: map[string]struct{}{"BPF_MAP_TYPE_UNSPEC": {}, "BPF_MAP_TYPE_ARENA": {}}, + } + if err := validate(bcc, uapi); err != nil { + t.Fatalf("validate: known gaps should pass, got: %v", err) + } +} + +func TestParseVersion(t *testing.T) { + cases := []struct { + in string + want kernelVersion + ok bool + }{ + {"5.8", kernelVersion{5, 8}, true}, + {"6.1", kernelVersion{6, 1}, true}, + {"3.19", kernelVersion{3, 19}, true}, + {"bad", kernelVersion{}, false}, + {"5", kernelVersion{}, false}, + {"a.b", kernelVersion{}, false}, + } + for _, tc := range cases { + got, err := parseVersion(tc.in) + if tc.ok && err != nil { + t.Errorf("parseVersion(%q) unexpected err: %v", tc.in, err) + } + if !tc.ok && err == nil { + t.Errorf("parseVersion(%q) expected err, got %v", tc.in, got) + } + if tc.ok && got != tc.want { + t.Errorf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestLeadingEnumIdent(t *testing.T) { + cases := map[string]string{ + "BPF_MAP_TYPE_HASH,": "BPF_MAP_TYPE_HASH", + "BPF_MAP_TYPE_HASH = BPF_MAP_TYPE_OTHER": "BPF_MAP_TYPE_HASH", + "\tBPF_MAP_TYPE_HASH,": "", + "// a comment": "", + "": "", + "};": "", + "BPF_MAP_TYPE_INSN_ARRAY,": "BPF_MAP_TYPE_INSN_ARRAY", + } + for in, want := range cases { + if got := leadingEnumIdent(in); got != want { + t.Errorf("leadingEnumIdent(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCamelize(t *testing.T) { + cases := map[string]string{ + "map_lookup_elem": "MapLookupElem", + "ktime_get_ns": "KtimeGetNs", + "x": "X", + "": "", + "already_caps": "AlreadyCaps", + } + for in, want := range cases { + if got := camelize(in); got != want { + t.Errorf("camelize(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/kernelversions/cmd/kvgen/validator.go b/internal/kernelversions/cmd/kvgen/validator.go new file mode 100644 index 0000000..417eef6 --- /dev/null +++ b/internal/kernelversions/cmd/kvgen/validator.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "sort" + "strings" +) + +// validate asserts that every UAPI helper / program-type / map-type enum +// value present in the kernel header has a matching row in the BCC table. +// +// Missing entries fail generation with a clear list so that maintainers +// notice when upstream adds a new enum value before BCC documents it. +// +// Extra entries in BCC (with no matching UAPI symbol) are silently +// tolerated: BCC sometimes documents in-kernel-only types or aliases that +// never made it to UAPI. Those rows do not break a build, only the missing +// direction does. +func validate(bcc *bccTables, uapi *uapiSets) error { + var problems []string + + // Helpers: BCC keys are lowercase suffixes (without BPF_FUNC_). + missingHelpers := []string{} + for fn := range uapi.Helpers { + if _, ok := bcc.Helpers[fn]; !ok { + if _, allowed := allowedMissingHelpers[fn]; allowed { + continue + } + missingHelpers = append(missingHelpers, fn) + } + } + if len(missingHelpers) > 0 { + sort.Strings(missingHelpers) + problems = append(problems, + fmt.Sprintf("UAPI helpers absent from BCC table:\n - BPF_FUNC_%s", + strings.Join(missingHelpers, "\n - BPF_FUNC_"))) + } + + missingProgTypes := []string{} + for pt := range uapi.ProgramTypes { + if _, ok := bcc.ProgramTypes[pt]; !ok { + if _, allowed := allowedMissingProgTypes[pt]; allowed { + continue + } + missingProgTypes = append(missingProgTypes, pt) + } + } + if len(missingProgTypes) > 0 { + sort.Strings(missingProgTypes) + problems = append(problems, + fmt.Sprintf("UAPI program types absent from BCC table:\n - %s", + strings.Join(missingProgTypes, "\n - "))) + } + + missingMapTypes := []string{} + for mt := range uapi.MapTypes { + if _, ok := bcc.MapTypes[mt]; !ok { + if _, allowed := allowedMissingMapTypes[mt]; allowed { + continue + } + missingMapTypes = append(missingMapTypes, mt) + } + } + if len(missingMapTypes) > 0 { + sort.Strings(missingMapTypes) + problems = append(problems, + fmt.Sprintf("UAPI map types absent from BCC table:\n - %s", + strings.Join(missingMapTypes, "\n - "))) + } + + if len(problems) > 0 { + return fmt.Errorf("cross-validation failed:\n\n%s", strings.Join(problems, "\n\n")) + } + return nil +} diff --git a/internal/kernelversions/generate.go b/internal/kernelversions/generate.go new file mode 100644 index 0000000..b0e1461 --- /dev/null +++ b/internal/kernelversions/generate.go @@ -0,0 +1,3 @@ +package kernelversions + +//go:generate go run ./cmd/kvgen --output-dir=. diff --git a/internal/kernelversions/kernelversions.go b/internal/kernelversions/kernelversions.go new file mode 100644 index 0000000..20a8390 --- /dev/null +++ b/internal/kernelversions/kernelversions.go @@ -0,0 +1,90 @@ +// Package kernelversions exposes the minimum kernel version at which each +// eBPF helper, program type and map type was introduced. +// +// The lookup tables in this package are generated from the BCC project's +// kernel-versions.md document at a pinned commit, cross-validated against +// the libbpf UAPI enum in include/uapi/linux/bpf.h at a pinned commit. The +// generator lives in cmd/kvgen. +// +// Consumers must treat the data as a snapshot, not as a real-time source of +// truth. The repository's scheduled CI workflow refreshes the snapshot by +// opening a PR when upstream changes. +package kernelversions + +import ( + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" +) + +// KernelVersion is a major.minor Linux kernel version. +// +// Patch level is intentionally omitted: BPF feature introductions are +// always pinned at major.minor in BCC's table and in the kernel commit +// log, and surfacing patch-level would imply false precision. +type KernelVersion struct { + Major int `json:"major"` + Minor int `json:"minor"` +} + +// Less reports whether v is older than other. +func (v KernelVersion) Less(other KernelVersion) bool { + if v.Major != other.Major { + return v.Major < other.Major + } + return v.Minor < other.Minor +} + +// IsZero reports whether v is the zero value. +func (v KernelVersion) IsZero() bool { + return v.Major == 0 && v.Minor == 0 +} + +// String renders v as "major.minor". +func (v KernelVersion) String() string { + return itoa(v.Major) + "." + itoa(v.Minor) +} + +// Max returns the maximum of two KernelVersion values. +func Max(a, b KernelVersion) KernelVersion { + if a.Less(b) { + return b + } + return a +} + +// HelperKernelVersion returns the kernel version that introduced the +// helper, and a boolean reporting whether the lookup succeeded. +func HelperKernelVersion(fn asm.BuiltinFunc) (KernelVersion, bool) { + v, ok := HelperVersion[fn] + return v, ok +} + +// MapTypeKernelVersion returns the kernel version that introduced the +// map type, and a boolean reporting whether the lookup succeeded. +func MapTypeKernelVersion(mt ebpf.MapType) (KernelVersion, bool) { + v, ok := MapTypeVersion[mt] + return v, ok +} + +// ProgramTypeKernelVersion returns the kernel version that introduced +// the program type, and a boolean reporting whether the lookup succeeded. +func ProgramTypeKernelVersion(pt ebpf.ProgramType) (KernelVersion, bool) { + v, ok := ProgTypeVersion[pt] + return v, ok +} + +// itoa is a tiny strconv.Itoa replacement to keep this file dependency-free +// at package load time. Inputs are always small non-negative integers. +func itoa(i int) string { + if i == 0 { + return "0" + } + var buf [12]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + return string(buf[pos:]) +} diff --git a/internal/kernelversions/kernelversions_test.go b/internal/kernelversions/kernelversions_test.go new file mode 100644 index 0000000..09f1b55 --- /dev/null +++ b/internal/kernelversions/kernelversions_test.go @@ -0,0 +1,86 @@ +package kernelversions + +import ( + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" +) + +func TestKernelVersion(t *testing.T) { + a := KernelVersion{Major: 5, Minor: 8} + b := KernelVersion{Major: 5, Minor: 10} + c := KernelVersion{Major: 6, Minor: 0} + if !a.Less(b) { + t.Errorf("5.8 should be less than 5.10") + } + if !b.Less(c) { + t.Errorf("5.10 should be less than 6.0") + } + if a.Less(a) { + t.Errorf("5.8 should not be less than itself") + } + if c.Less(b) { + t.Errorf("6.0 should not be less than 5.10") + } + if !(KernelVersion{}).IsZero() { + t.Errorf("zero value should be IsZero") + } + if a.IsZero() { + t.Errorf("5.8 should not be IsZero") + } + if got := a.String(); got != "5.8" { + t.Errorf("String() = %q, want %q", got, "5.8") + } + if got := (KernelVersion{Major: 5, Minor: 19}).String(); got != "5.19" { + t.Errorf("String() = %q, want %q", got, "5.19") + } + if got := (KernelVersion{}).String(); got != "0.0" { + t.Errorf("zero String() = %q, want %q", got, "0.0") + } +} + +func TestMax(t *testing.T) { + a := KernelVersion{Major: 5, Minor: 8} + b := KernelVersion{Major: 4, Minor: 10} + if got := Max(a, b); got != a { + t.Errorf("Max(5.8, 4.10) = %v, want %v", got, a) + } + if got := Max(b, a); got != a { + t.Errorf("Max(4.10, 5.8) = %v, want %v", got, a) + } + if got := Max(a, a); got != a { + t.Errorf("Max(a, a) = %v, want %v", got, a) + } +} + +func TestHelperKernelVersion(t *testing.T) { + // Bind helper introduced in 4.17 per the snapshot. + v, ok := HelperKernelVersion(asm.FnBind) + if !ok { + t.Fatalf("FnBind not in HelperVersion table") + } + if v != (KernelVersion{Major: 4, Minor: 17}) { + t.Errorf("FnBind version = %v, want 4.17", v) + } +} + +func TestMapTypeKernelVersion(t *testing.T) { + v, ok := MapTypeKernelVersion(ebpf.RingBuf) + if !ok { + t.Fatalf("RingBuf not in MapTypeVersion table") + } + if v != (KernelVersion{Major: 5, Minor: 8}) { + t.Errorf("RingBuf version = %v, want 5.8", v) + } +} + +func TestProgramTypeKernelVersion(t *testing.T) { + v, ok := ProgramTypeKernelVersion(ebpf.Kprobe) + if !ok { + t.Fatalf("Kprobe not in ProgTypeVersion table") + } + if v != (KernelVersion{Major: 4, Minor: 1}) { + t.Errorf("Kprobe version = %v, want 4.1", v) + } +} diff --git a/internal/kernelversions/tables.go b/internal/kernelversions/tables.go new file mode 100644 index 0000000..b2edc33 --- /dev/null +++ b/internal/kernelversions/tables.go @@ -0,0 +1,295 @@ +// Code generated by internal/kernelversions/cmd/kvgen; DO NOT EDIT. +// +// BCC commit: 91c1e8ee5f5a5b85d3bfe8e35d11fa0a6d3b5e52 +// Kernel commit: c7e4e4d5f7dc2daa439303d1b5bf6bdfaa249f49 + +package kernelversions + +import ( + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" +) + +// HelperVersion maps each known eBPF helper to the kernel version that introduced it. +var HelperVersion = map[asm.BuiltinFunc]KernelVersion{ + asm.FnBind: {Major: 4, Minor: 17}, + asm.FnBprmOptsSet: {Major: 5, Minor: 11}, + asm.FnBtfFindByNameKind: {Major: 5, Minor: 14}, + asm.FnCgrpStorageDelete: {Major: 6, Minor: 2}, + asm.FnCgrpStorageGet: {Major: 6, Minor: 2}, + asm.FnCheckMtu: {Major: 5, Minor: 12}, + asm.FnCloneRedirect: {Major: 4, Minor: 2}, + asm.FnCopyFromUser: {Major: 5, Minor: 10}, + asm.FnCopyFromUserTask: {Major: 5, Minor: 18}, + asm.FnCsumDiff: {Major: 4, Minor: 6}, + asm.FnCsumLevel: {Major: 5, Minor: 7}, + asm.FnCsumUpdate: {Major: 4, Minor: 9}, + asm.FnCurrentTaskUnderCgroup: {Major: 4, Minor: 9}, + asm.FnDPath: {Major: 5, Minor: 10}, + asm.FnDynptrData: {Major: 5, Minor: 19}, + asm.FnDynptrFromMem: {Major: 5, Minor: 19}, + asm.FnDynptrRead: {Major: 5, Minor: 19}, + asm.FnDynptrWrite: {Major: 5, Minor: 19}, + asm.FnFibLookup: {Major: 4, Minor: 18}, + asm.FnFindVma: {Major: 5, Minor: 17}, + asm.FnForEachMapElem: {Major: 5, Minor: 13}, + asm.FnGetAttachCookie: {Major: 5, Minor: 15}, + asm.FnGetBranchSnapshot: {Major: 5, Minor: 16}, + asm.FnGetCgroupClassid: {Major: 4, Minor: 3}, + asm.FnGetCurrentAncestorCgroupId: {Major: 5, Minor: 6}, + asm.FnGetCurrentCgroupId: {Major: 4, Minor: 18}, + asm.FnGetCurrentComm: {Major: 4, Minor: 2}, + asm.FnGetCurrentPidTgid: {Major: 4, Minor: 2}, + asm.FnGetCurrentTask: {Major: 4, Minor: 8}, + asm.FnGetCurrentTaskBtf: {Major: 5, Minor: 11}, + asm.FnGetCurrentUidGid: {Major: 4, Minor: 2}, + asm.FnGetFuncArg: {Major: 5, Minor: 17}, + asm.FnGetFuncArgCnt: {Major: 5, Minor: 17}, + asm.FnGetFuncIp: {Major: 5, Minor: 15}, + asm.FnGetFuncRet: {Major: 5, Minor: 17}, + asm.FnGetHashRecalc: {Major: 4, Minor: 8}, + asm.FnGetListenerSock: {Major: 5, Minor: 1}, + asm.FnGetLocalStorage: {Major: 4, Minor: 19}, + asm.FnGetNetnsCookie: {Major: 5, Minor: 7}, + asm.FnGetNsCurrentPidTgid: {Major: 5, Minor: 7}, + asm.FnGetNumaNodeId: {Major: 4, Minor: 10}, + asm.FnGetPrandomU32: {Major: 4, Minor: 1}, + asm.FnGetRetval: {Major: 5, Minor: 18}, + asm.FnGetRouteRealm: {Major: 4, Minor: 4}, + asm.FnGetSmpProcessorId: {Major: 4, Minor: 1}, + asm.FnGetSocketCookie: {Major: 4, Minor: 12}, + asm.FnGetSocketUid: {Major: 4, Minor: 12}, + asm.FnGetStack: {Major: 4, Minor: 18}, + asm.FnGetStackid: {Major: 4, Minor: 6}, + asm.FnGetTaskStack: {Major: 5, Minor: 9}, + asm.FnGetsockopt: {Major: 4, Minor: 15}, + asm.FnImaFileHash: {Major: 5, Minor: 18}, + asm.FnImaInodeHash: {Major: 5, Minor: 11}, + asm.FnInodeStorageDelete: {Major: 5, Minor: 10}, + asm.FnInodeStorageGet: {Major: 5, Minor: 10}, + asm.FnJiffies64: {Major: 5, Minor: 5}, + asm.FnKallsymsLookupName: {Major: 5, Minor: 16}, + asm.FnKptrXchg: {Major: 5, Minor: 19}, + asm.FnKtimeGetBootNs: {Major: 5, Minor: 8}, + asm.FnKtimeGetCoarseNs: {Major: 5, Minor: 11}, + asm.FnKtimeGetNs: {Major: 4, Minor: 1}, + asm.FnKtimeGetTaiNs: {Major: 6, Minor: 1}, + asm.FnL3CsumReplace: {Major: 4, Minor: 1}, + asm.FnL4CsumReplace: {Major: 4, Minor: 1}, + asm.FnLoadHdrOpt: {Major: 5, Minor: 10}, + asm.FnLoop: {Major: 5, Minor: 17}, + asm.FnLwtPushEncap: {Major: 4, Minor: 18}, + asm.FnLwtSeg6Action: {Major: 4, Minor: 18}, + asm.FnLwtSeg6AdjustSrh: {Major: 4, Minor: 18}, + asm.FnLwtSeg6StoreBytes: {Major: 4, Minor: 18}, + asm.FnMapDeleteElem: {Major: 3, Minor: 19}, + asm.FnMapLookupElem: {Major: 3, Minor: 19}, + asm.FnMapLookupPercpuElem: {Major: 5, Minor: 19}, + asm.FnMapPeekElem: {Major: 4, Minor: 20}, + asm.FnMapPopElem: {Major: 4, Minor: 20}, + asm.FnMapPushElem: {Major: 4, Minor: 20}, + asm.FnMapUpdateElem: {Major: 3, Minor: 19}, + asm.FnMsgApplyBytes: {Major: 4, Minor: 17}, + asm.FnMsgCorkBytes: {Major: 4, Minor: 17}, + asm.FnMsgPopData: {Major: 5, Minor: 0}, + asm.FnMsgPullData: {Major: 4, Minor: 17}, + asm.FnMsgPushData: {Major: 4, Minor: 20}, + asm.FnMsgRedirectHash: {Major: 4, Minor: 18}, + asm.FnMsgRedirectMap: {Major: 4, Minor: 17}, + asm.FnOverrideReturn: {Major: 4, Minor: 16}, + asm.FnPerCpuPtr: {Major: 5, Minor: 10}, + asm.FnPerfEventOutput: {Major: 4, Minor: 4}, + asm.FnPerfEventRead: {Major: 4, Minor: 3}, + asm.FnPerfEventReadValue: {Major: 4, Minor: 15}, + asm.FnPerfProgReadValue: {Major: 4, Minor: 15}, + asm.FnProbeRead: {Major: 4, Minor: 1}, + asm.FnProbeReadKernel: {Major: 5, Minor: 5}, + asm.FnProbeReadKernelStr: {Major: 5, Minor: 5}, + asm.FnProbeReadStr: {Major: 4, Minor: 11}, + asm.FnProbeReadUser: {Major: 5, Minor: 5}, + asm.FnProbeReadUserStr: {Major: 5, Minor: 5}, + asm.FnProbeWriteUser: {Major: 4, Minor: 8}, + asm.FnRcKeydown: {Major: 4, Minor: 18}, + asm.FnRcPointerRel: {Major: 5, Minor: 0}, + asm.FnRcRepeat: {Major: 4, Minor: 18}, + asm.FnReadBranchRecords: {Major: 5, Minor: 6}, + asm.FnRedirect: {Major: 4, Minor: 4}, + asm.FnRedirectMap: {Major: 4, Minor: 14}, + asm.FnRedirectNeigh: {Major: 5, Minor: 10}, + asm.FnRedirectPeer: {Major: 5, Minor: 10}, + asm.FnReserveHdrOpt: {Major: 5, Minor: 10}, + asm.FnRingbufDiscard: {Major: 5, Minor: 8}, + asm.FnRingbufDiscardDynptr: {Major: 5, Minor: 19}, + asm.FnRingbufOutput: {Major: 5, Minor: 8}, + asm.FnRingbufQuery: {Major: 5, Minor: 8}, + asm.FnRingbufReserve: {Major: 5, Minor: 8}, + asm.FnRingbufReserveDynptr: {Major: 5, Minor: 19}, + asm.FnRingbufSubmit: {Major: 5, Minor: 8}, + asm.FnRingbufSubmitDynptr: {Major: 5, Minor: 19}, + asm.FnSendSignal: {Major: 5, Minor: 3}, + asm.FnSendSignalThread: {Major: 5, Minor: 5}, + asm.FnSeqPrintf: {Major: 5, Minor: 7}, + asm.FnSeqPrintfBtf: {Major: 5, Minor: 10}, + asm.FnSeqWrite: {Major: 5, Minor: 7}, + asm.FnSetHash: {Major: 4, Minor: 13}, + asm.FnSetHashInvalid: {Major: 4, Minor: 9}, + asm.FnSetRetval: {Major: 5, Minor: 18}, + asm.FnSetsockopt: {Major: 4, Minor: 13}, + asm.FnSkAncestorCgroupId: {Major: 5, Minor: 7}, + asm.FnSkAssign: {Major: 5, Minor: 6}, + asm.FnSkCgroupId: {Major: 5, Minor: 7}, + asm.FnSkFullsock: {Major: 5, Minor: 1}, + asm.FnSkLookupTcp: {Major: 4, Minor: 20}, + asm.FnSkLookupUdp: {Major: 4, Minor: 20}, + asm.FnSkRedirectHash: {Major: 4, Minor: 18}, + asm.FnSkRedirectMap: {Major: 4, Minor: 14}, + asm.FnSkRelease: {Major: 4, Minor: 20}, + asm.FnSkSelectReuseport: {Major: 4, Minor: 19}, + asm.FnSkStorageDelete: {Major: 5, Minor: 2}, + asm.FnSkStorageGet: {Major: 5, Minor: 2}, + asm.FnSkbAdjustRoom: {Major: 4, Minor: 13}, + asm.FnSkbAncestorCgroupId: {Major: 4, Minor: 19}, + asm.FnSkbCgroupClassid: {Major: 5, Minor: 10}, + asm.FnSkbCgroupId: {Major: 4, Minor: 18}, + asm.FnSkbChangeHead: {Major: 4, Minor: 10}, + asm.FnSkbChangeProto: {Major: 4, Minor: 8}, + asm.FnSkbChangeTail: {Major: 4, Minor: 9}, + asm.FnSkbChangeType: {Major: 4, Minor: 8}, + asm.FnSkbEcnSetCe: {Major: 5, Minor: 1}, + asm.FnSkbGetTunnelKey: {Major: 4, Minor: 3}, + asm.FnSkbGetTunnelOpt: {Major: 4, Minor: 6}, + asm.FnSkbGetXfrmState: {Major: 4, Minor: 18}, + asm.FnSkbLoadBytes: {Major: 4, Minor: 5}, + asm.FnSkbLoadBytesRelative: {Major: 4, Minor: 18}, + asm.FnSkbOutput: {Major: 5, Minor: 5}, + asm.FnSkbPullData: {Major: 4, Minor: 9}, + asm.FnSkbSetTstamp: {Major: 5, Minor: 18}, + asm.FnSkbSetTunnelKey: {Major: 4, Minor: 3}, + asm.FnSkbSetTunnelOpt: {Major: 4, Minor: 6}, + asm.FnSkbStoreBytes: {Major: 4, Minor: 1}, + asm.FnSkbUnderCgroup: {Major: 4, Minor: 8}, + asm.FnSkbVlanPop: {Major: 4, Minor: 3}, + asm.FnSkbVlanPush: {Major: 4, Minor: 3}, + asm.FnSkcLookupTcp: {Major: 5, Minor: 2}, + asm.FnSkcToTcp6Sock: {Major: 5, Minor: 9}, + asm.FnSkcToTcpRequestSock: {Major: 5, Minor: 9}, + asm.FnSkcToTcpSock: {Major: 5, Minor: 9}, + asm.FnSkcToTcpTimewaitSock: {Major: 5, Minor: 9}, + asm.FnSkcToUdp6Sock: {Major: 5, Minor: 9}, + asm.FnSkcToUnixSock: {Major: 5, Minor: 16}, + asm.FnSnprintf: {Major: 5, Minor: 13}, + asm.FnSnprintfBtf: {Major: 5, Minor: 10}, + asm.FnSockFromFile: {Major: 5, Minor: 11}, + asm.FnSockHashUpdate: {Major: 4, Minor: 18}, + asm.FnSockMapUpdate: {Major: 4, Minor: 14}, + asm.FnSockOpsCbFlagsSet: {Major: 4, Minor: 16}, + asm.FnSpinLock: {Major: 5, Minor: 1}, + asm.FnSpinUnlock: {Major: 5, Minor: 1}, + asm.FnStoreHdrOpt: {Major: 5, Minor: 10}, + asm.FnStrncmp: {Major: 5, Minor: 17}, + asm.FnStrtol: {Major: 5, Minor: 2}, + asm.FnStrtoul: {Major: 5, Minor: 2}, + asm.FnSysBpf: {Major: 5, Minor: 14}, + asm.FnSysClose: {Major: 5, Minor: 14}, + asm.FnSysctlGetCurrentValue: {Major: 5, Minor: 2}, + asm.FnSysctlGetName: {Major: 5, Minor: 2}, + asm.FnSysctlGetNewValue: {Major: 5, Minor: 2}, + asm.FnSysctlSetNewValue: {Major: 5, Minor: 2}, + asm.FnTailCall: {Major: 4, Minor: 2}, + asm.FnTaskPtRegs: {Major: 5, Minor: 15}, + asm.FnTaskStorageDelete: {Major: 5, Minor: 11}, + asm.FnTaskStorageGet: {Major: 5, Minor: 11}, + asm.FnTcpCheckSyncookie: {Major: 5, Minor: 2}, + asm.FnTcpGenSyncookie: {Major: 5, Minor: 3}, + asm.FnTcpRawCheckSyncookieIpv4: {Major: 6, Minor: 0}, + asm.FnTcpRawCheckSyncookieIpv6: {Major: 6, Minor: 0}, + asm.FnTcpRawGenSyncookieIpv4: {Major: 6, Minor: 0}, + asm.FnTcpRawGenSyncookieIpv6: {Major: 6, Minor: 0}, + asm.FnTcpSendAck: {Major: 5, Minor: 5}, + asm.FnTcpSock: {Major: 5, Minor: 1}, + asm.FnThisCpuPtr: {Major: 5, Minor: 10}, + asm.FnTimerCancel: {Major: 5, Minor: 15}, + asm.FnTimerInit: {Major: 5, Minor: 15}, + asm.FnTimerSetCallback: {Major: 5, Minor: 15}, + asm.FnTimerStart: {Major: 5, Minor: 15}, + asm.FnTracePrintk: {Major: 4, Minor: 1}, + asm.FnTraceVprintk: {Major: 5, Minor: 16}, + asm.FnUserRingbufDrain: {Major: 6, Minor: 1}, + asm.FnXdpAdjustHead: {Major: 4, Minor: 10}, + asm.FnXdpAdjustMeta: {Major: 4, Minor: 15}, + asm.FnXdpAdjustTail: {Major: 4, Minor: 18}, + asm.FnXdpGetBuffLen: {Major: 5, Minor: 18}, + asm.FnXdpLoadBytes: {Major: 5, Minor: 18}, + asm.FnXdpOutput: {Major: 5, Minor: 6}, + asm.FnXdpStoreBytes: {Major: 5, Minor: 18}, +} + +// ProgTypeVersion maps each known eBPF program type to the kernel version that introduced it. +var ProgTypeVersion = map[ebpf.ProgramType]KernelVersion{ + ebpf.CGroupDevice: {Major: 4, Minor: 15}, + ebpf.CGroupSKB: {Major: 4, Minor: 10}, + ebpf.CGroupSock: {Major: 4, Minor: 10}, + ebpf.CGroupSockopt: {Major: 5, Minor: 3}, + ebpf.CGroupSockAddr: {Major: 4, Minor: 17}, + ebpf.CGroupSysctl: {Major: 5, Minor: 2}, + ebpf.Extension: {Major: 5, Minor: 6}, + ebpf.FlowDissector: {Major: 4, Minor: 20}, + ebpf.Kprobe: {Major: 4, Minor: 1}, + ebpf.LircMode2: {Major: 4, Minor: 18}, + ebpf.LSM: {Major: 5, Minor: 7}, + ebpf.LWTIn: {Major: 4, Minor: 10}, + ebpf.LWTOut: {Major: 4, Minor: 10}, + ebpf.LWTSeg6Local: {Major: 4, Minor: 18}, + ebpf.LWTXmit: {Major: 4, Minor: 10}, + ebpf.PerfEvent: {Major: 4, Minor: 9}, + ebpf.RawTracepoint: {Major: 4, Minor: 17}, + ebpf.RawTracepointWritable: {Major: 5, Minor: 2}, + ebpf.SchedACT: {Major: 4, Minor: 1}, + ebpf.SchedCLS: {Major: 4, Minor: 1}, + ebpf.SkLookup: {Major: 5, Minor: 9}, + ebpf.SkMsg: {Major: 4, Minor: 17}, + ebpf.SkReuseport: {Major: 4, Minor: 19}, + ebpf.SkSKB: {Major: 4, Minor: 14}, + ebpf.SocketFilter: {Major: 3, Minor: 19}, + ebpf.SockOps: {Major: 4, Minor: 13}, + ebpf.StructOps: {Major: 5, Minor: 6}, + ebpf.Syscall: {Major: 5, Minor: 15}, + ebpf.TracePoint: {Major: 4, Minor: 7}, + ebpf.Tracing: {Major: 5, Minor: 5}, + ebpf.XDP: {Major: 4, Minor: 8}, +} + +// MapTypeVersion maps each known eBPF map type to the kernel version that introduced it. +var MapTypeVersion = map[ebpf.MapType]KernelVersion{ + ebpf.Array: {Major: 3, Minor: 19}, + ebpf.ArrayOfMaps: {Major: 4, Minor: 12}, + ebpf.BloomFilter: {Major: 5, Minor: 16}, + ebpf.CGroupArray: {Major: 4, Minor: 8}, + ebpf.CGroupStorage: {Major: 4, Minor: 19}, + ebpf.CPUMap: {Major: 4, Minor: 15}, + ebpf.DevMap: {Major: 4, Minor: 14}, + ebpf.DevMapHash: {Major: 5, Minor: 4}, + ebpf.Hash: {Major: 3, Minor: 19}, + ebpf.HashOfMaps: {Major: 4, Minor: 12}, + ebpf.InodeStorage: {Major: 5, Minor: 10}, + ebpf.LPMTrie: {Major: 4, Minor: 11}, + ebpf.LRUHash: {Major: 4, Minor: 10}, + ebpf.LRUCPUHash: {Major: 4, Minor: 10}, + ebpf.PerCPUArray: {Major: 4, Minor: 6}, + ebpf.PerCPUCGroupStorage: {Major: 4, Minor: 20}, + ebpf.PerCPUHash: {Major: 4, Minor: 6}, + ebpf.PerfEventArray: {Major: 4, Minor: 3}, + ebpf.ProgramArray: {Major: 4, Minor: 2}, + ebpf.Queue: {Major: 4, Minor: 20}, + ebpf.ReusePortSockArray: {Major: 4, Minor: 19}, + ebpf.RingBuf: {Major: 5, Minor: 8}, + ebpf.SkStorage: {Major: 5, Minor: 2}, + ebpf.SockHash: {Major: 4, Minor: 18}, + ebpf.SockMap: {Major: 4, Minor: 14}, + ebpf.Stack: {Major: 4, Minor: 20}, + ebpf.StackTrace: {Major: 4, Minor: 6}, + ebpf.StructOpsMap: {Major: 5, Minor: 6}, + ebpf.TaskStorage: {Major: 5, Minor: 11}, + ebpf.UserRingbuf: {Major: 6, Minor: 1}, + ebpf.XSKMap: {Major: 4, Minor: 18}, +} diff --git a/internal/tools/covercheck/main.go b/internal/tools/covercheck/main.go new file mode 100644 index 0000000..018d530 --- /dev/null +++ b/internal/tools/covercheck/main.go @@ -0,0 +1,305 @@ +// covercheck enforces a minimum per-file statement-coverage threshold +// on the files added (or otherwise gated) by a feature branch. +// +// It reads a Go coverage profile, scans the corresponding source files +// for `// coverage:ignore` markers attached to function declarations, +// excludes those functions from the denominator, and fails (exit 1) if +// any gated file's adjusted coverage is below the threshold. +// +// Usage: +// +// covercheck --profile= --threshold= [--file=...] +// +// `--file` may be repeated. Each value is a path glob matching the file +// portion of a coverage entry (e.g. `probe_elf*.go`). When no `--file` +// is supplied, every file in the profile is gated. +// +// The marker syntax is a single-line comment `// coverage:ignore` +// placed on its own line in the doc comment immediately above a +// `func` declaration. The marker excludes the entire function body +// (every statement attributed to the matching covered range) from +// both the numerator and the denominator. +// +// This tool is intended to be invoked from `make cover-check`. It is +// network-free and has no external dependencies beyond the Go +// standard library and `golang.org/x/tools/cover`. +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/cover" +) + +func main() { + profile := flag.String("profile", "", "path to the Go coverage profile (required)") + threshold := flag.Float64("threshold", 90.0, "minimum per-file coverage percentage") + var files multiFlag + flag.Var(&files, "file", "glob to gate (matches the file portion of a profile entry; may be repeated)") + flag.Parse() + + if *profile == "" { + fmt.Fprintln(os.Stderr, "covercheck: --profile is required") + os.Exit(2) + } + + profiles, err := cover.ParseProfiles(*profile) + if err != nil { + fmt.Fprintf(os.Stderr, "covercheck: parse profile: %v\n", err) + os.Exit(2) + } + + type fileResult struct { + Name string + Covered int + Total int + Ignored int + Threshold float64 + } + var results []fileResult + failures := 0 + + for _, p := range profiles { + if !matchesAny(p.FileName, files) { + continue + } + srcPath, err := resolveSource(p.FileName) + if err != nil { + fmt.Fprintf(os.Stderr, "covercheck: %s: %v\n", p.FileName, err) + os.Exit(2) + } + ignoredRanges, err := collectIgnoredRanges(srcPath) + if err != nil { + fmt.Fprintf(os.Stderr, "covercheck: %s: %v\n", p.FileName, err) + os.Exit(2) + } + + var covered, total, ignored int + for _, b := range p.Blocks { + if rangesContain(ignoredRanges, b.StartLine) { + ignored += b.NumStmt + continue + } + total += b.NumStmt + if b.Count > 0 { + covered += b.NumStmt + } + } + + results = append(results, fileResult{ + Name: p.FileName, + Covered: covered, + Total: total, + Ignored: ignored, + Threshold: *threshold, + }) + } + + sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name }) + + fmt.Println("File Covered Total Ignored %") + fmt.Println("----------------------------------------------------------- ------- ----- ------- -----") + for _, r := range results { + pct := 100.0 + if r.Total > 0 { + pct = 100.0 * float64(r.Covered) / float64(r.Total) + } + flag := " " + if r.Total > 0 && pct < r.Threshold { + flag = "x" + failures++ + } + fmt.Printf("%s %-58s %7d %5d %7d %5.1f\n", flag, shorten(r.Name), r.Covered, r.Total, r.Ignored, pct) + } + + if len(results) == 0 { + fmt.Fprintln(os.Stderr, "covercheck: no files matched the supplied --file globs") + os.Exit(2) + } + + if failures > 0 { + fmt.Fprintf(os.Stderr, "\ncovercheck: %d file(s) below %.1f%% threshold\n", failures, *threshold) + os.Exit(1) + } + fmt.Printf("\ncovercheck: all %d gated file(s) at or above %.1f%% threshold\n", len(results), *threshold) +} + +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } + +// matchesAny reports whether profileFile matches any of the supplied +// globs. An empty patterns list matches everything (so a bare +// `--profile` invocation is a full sweep). +func matchesAny(profileFile string, patterns multiFlag) bool { + if len(patterns) == 0 { + return true + } + base := filepath.Base(profileFile) + for _, pat := range patterns { + // Match against (a) the full profile path, (b) the bare + // basename for the `probe_elf*.go` shorthand, and (c) any + // path suffix so `internal/kernelversions/kernelversions.go` + // matches the module-prefixed profile entry without forcing + // callers to spell out the module path. + if ok, _ := filepath.Match(pat, profileFile); ok { + return true + } + if ok, _ := filepath.Match(pat, base); ok { + return true + } + if strings.HasSuffix(profileFile, "/"+pat) { + return true + } + } + return false +} + +// resolveSource turns a coverage profile's file label (always a Go +// import path joined with the file basename) into an absolute path +// rooted at the current working directory's module. +func resolveSource(profileFile string) (string, error) { + // The profile labels look like "github.com/leodido/kfeatures/foo.go". + // We want "./foo.go" (or "./internal/.../foo.go") relative to the + // module root, which is the working directory of `go test`. + // Strip the module path prefix; if it doesn't match, fall back to + // the basename (kvgen-style internal packages already include the + // subdir suffix). + mod, err := moduleImportPath() + if err != nil { + return "", err + } + rel := strings.TrimPrefix(profileFile, mod+"/") + if rel == profileFile { + return "", fmt.Errorf("profile entry %q does not start with module path %q", profileFile, mod) + } + if _, err := os.Stat(rel); err != nil { + return "", fmt.Errorf("source not found at %s: %w", rel, err) + } + return rel, nil +} + +// moduleImportPath reads go.mod and returns the declared module path. +func moduleImportPath() (string, error) { + body, err := os.ReadFile("go.mod") + if err != nil { + return "", err + } + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module")), nil + } + } + return "", fmt.Errorf("module directive not found in go.mod") +} + +// lineRange describes an inclusive [start, end] line range in a Go file. +type lineRange struct{ start, end int } + +// collectIgnoredRanges parses src and returns the line ranges of every +// function whose doc comment (or a free-floating comment immediately +// above the func keyword) contains a line equal to `// coverage:ignore`. +func collectIgnoredRanges(src string) ([]lineRange, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, src, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", src, err) + } + + // Index every comment group by the line *immediately following* its + // last line. This lets us look up "is there an ignore-marker + // comment directly above this function?" in O(1) per func. + commentsByNextLine := make(map[int]*ast.CommentGroup, len(f.Comments)) + for _, cg := range f.Comments { + nextLine := fset.Position(cg.End()).Line + 1 + commentsByNextLine[nextLine] = cg + } + + var out []lineRange + for _, decl := range f.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + line := fset.Position(d.Pos()).Line + group := d.Doc + if group == nil { + group = commentsByNextLine[line] + } + if group == nil || !hasIgnoreMarker(group) { + continue + } + out = append(out, lineRange{ + start: line, + end: fset.Position(d.End()).Line, + }) + case *ast.GenDecl: + // Cover `var foo = func(...) { ... }` (and the `const` + // equivalent) so test-time stubbed function variables can + // be opted out the same way as named functions. + if d.Tok != token.VAR && d.Tok != token.CONST { + continue + } + line := fset.Position(d.Pos()).Line + group := d.Doc + if group == nil { + group = commentsByNextLine[line] + } + if group == nil || !hasIgnoreMarker(group) { + continue + } + for _, sp := range d.Specs { + vs, ok := sp.(*ast.ValueSpec) + if !ok { + continue + } + for _, val := range vs.Values { + fn, ok := val.(*ast.FuncLit) + if !ok { + continue + } + out = append(out, lineRange{ + start: fset.Position(fn.Pos()).Line, + end: fset.Position(fn.End()).Line, + }) + } + } + } + } + return out, nil +} + +func hasIgnoreMarker(g *ast.CommentGroup) bool { + for _, c := range g.List { + text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + if text == "coverage:ignore" { + return true + } + } + return false +} + +func rangesContain(rs []lineRange, line int) bool { + for _, r := range rs { + if line >= r.start && line <= r.end { + return true + } + } + return false +} + +func shorten(name string) string { + const maxLen = 58 + if len(name) <= maxLen { + return name + } + return "..." + name[len(name)-maxLen+3:] +} diff --git a/makefile b/makefile index 900e1f6..4a97a22 100644 --- a/makefile +++ b/makefile @@ -12,7 +12,25 @@ TEST_FLAGS ?= .DEFAULT_GOAL := help -.PHONY: help deps verify-deps vet generate build test clean all +COVER_PROFILE ?= coverage.out +COVER_THRESHOLD ?= 90 +# Files gated by `cover-check`. Globs match the basename of each entry +# in the coverage profile; extend this list when a new feature ships +# with its own files (see CONTRIBUTING.md → Coverage gate). +# +# Scoping note: the gate covers public/library files only. Internal +# tools (kvgen, covercheck) have informational coverage via `make +# cover` but are not gated, because their happy paths are exercised by +# the scheduled refresh workflow against live network data. +COVER_FILES ?= \ + probe_elf.go \ + probe_elf_extract.go \ + probe_elf_core.go \ + probe_elf_warnings.go \ + requirement_min_kernel.go \ + internal/kernelversions/kernelversions.go + +.PHONY: help deps verify-deps vet generate build test clean all cover cover-check help: ## Show available targets. @if command -v $(AWK) >/dev/null 2>&1; then \ @@ -49,6 +67,15 @@ build: generate ## Build the CLI binary. test: ## Run the test suite. $(GO) test $(TEST_FLAGS) $(PKG) +cover: ## Produce a coverage profile at $(COVER_PROFILE). + $(GO) test -covermode=atomic -coverprofile=$(COVER_PROFILE) $(PKG) + +cover-check: cover ## Enforce per-file coverage threshold on gated files. + @$(GO) run ./internal/tools/covercheck \ + --profile=$(COVER_PROFILE) \ + --threshold=$(COVER_THRESHOLD) \ + $(foreach f,$(COVER_FILES),--file=$(f)) + clean: ## Remove build artifacts. rm -rf $(BIN_DIR) diff --git a/probe_elf.go b/probe_elf.go new file mode 100644 index 0000000..338fd68 --- /dev/null +++ b/probe_elf.go @@ -0,0 +1,233 @@ +package kfeatures + +import ( + "slices" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + + "github.com/leodido/kfeatures/internal/kernelversions" +) + +// ELFProbes is the descriptive ELF-side counterpart of [SystemFeatures]. +// +// It captures every signal that can be derived from a compiled BPF ELF +// without loading the program into the kernel: license, BTF presence and +// CO-RE relocation count, computed minimum kernel version, transports, +// per-program details, per-map metadata, deduplicated helper / program-type +// / map-type requirements with their introduction versions, and any +// warnings collected during the analysis pass. +// +// ELFProbes is descriptive only and never participates in [Check] +// evaluation. Use [(*ELFProbes).Requirements] when you want to derive a +// gating [FeatureGroup] from the same parse. +type ELFProbes struct { + Path string `json:"path"` + License string `json:"license"` + HasBTF bool `json:"hasBTF"` + CORERelocations int `json:"coreRelocations"` + MinKernel KernelVersion `json:"minKernel"` + Transport []string `json:"transport,omitempty"` + Programs []ELFProgram `json:"programs,omitempty"` + Maps []ELFMap `json:"maps,omitempty"` + Helpers []ELFHelperRequirement `json:"helpers,omitempty"` + ProgramTypes []ELFProgramTypeRequirement `json:"programTypes,omitempty"` + MapTypes []ELFMapTypeRequirement `json:"mapTypes,omitempty"` + Warnings []ELFWarning `json:"warnings,omitempty"` +} + +// KernelVersion is a major.minor Linux kernel version. +// +// It mirrors [kernelversions.KernelVersion] in the public API surface so +// that consumers can construct, compare and serialize values without +// importing the internal package. +type KernelVersion struct { + Major int `json:"major"` + Minor int `json:"minor"` +} + +// String renders the version as "major.minor". +func (v KernelVersion) String() string { + return kernelversions.KernelVersion{Major: v.Major, Minor: v.Minor}.String() +} + +// IsZero reports whether v is the zero value. +func (v KernelVersion) IsZero() bool { + return v.Major == 0 && v.Minor == 0 +} + +// less reports whether v is older than other. +func (v KernelVersion) less(other KernelVersion) bool { + if v.Major != other.Major { + return v.Major < other.Major + } + return v.Minor < other.Minor +} + +// maxKernelVersion returns the larger of two KernelVersion values. +func maxKernelVersion(a, b KernelVersion) KernelVersion { + if a.less(b) { + return b + } + return a +} + +// fromInternal converts the internal kernelversions.KernelVersion to the +// public type. +func fromInternal(v kernelversions.KernelVersion) KernelVersion { + return KernelVersion{Major: v.Major, Minor: v.Minor} +} + +// ELFProgram describes a single program inside an ELF object. +// +// MemoryAccesses is populated only when the caller passes [WithCOREChecks] +// to [ProbeELFWith]; otherwise it is the zero value. +// +// ProgramType carries the canonical cilium/ebpf constant; Type is the +// human-readable string form (the JSON shape uses Type for stable +// lower-camel output, while ProgramType drives Requirements()). +type ELFProgram struct { + Name string `json:"name"` + SectionName string `json:"sectionName"` + Type string `json:"type"` + ProgramType ebpf.ProgramType `json:"-"` + NumInsns int `json:"numInsns"` + CORERelocs int `json:"coreRelocs"` + Helpers []ELFHelperRequirement `json:"helpers,omitempty"` + MemoryAccesses MemoryAccessSummary `json:"memoryAccesses,omitzero"` +} + +// ELFMap describes a single map inside an ELF object. +type ELFMap struct { + Name string `json:"name"` + Type string `json:"type"` + KeySize uint32 `json:"keySize"` + ValueSize uint32 `json:"valueSize"` + MaxEntries uint32 `json:"maxEntries"` + Version KernelVersion `json:"version"` +} + +// ELFHelperRequirement describes a single helper invocation discovered in +// an ELF object, paired with the kernel version that introduced it. +type ELFHelperRequirement struct { + Name string `json:"name"` + Helper asm.BuiltinFunc `json:"-"` + Version KernelVersion `json:"version"` +} + +// ELFProgramTypeRequirement describes a program type referenced by an ELF +// object, paired with the kernel version that introduced it. +type ELFProgramTypeRequirement struct { + Name string `json:"name"` + Type ebpf.ProgramType `json:"-"` + Version KernelVersion `json:"version"` +} + +// ELFMapTypeRequirement describes a map type referenced by an ELF object, +// paired with the kernel version that introduced it. +type ELFMapTypeRequirement struct { + Name string `json:"name"` + Type ebpf.MapType `json:"-"` + Version KernelVersion `json:"version"` +} + +// MemoryAccessSummary captures the per-program register-classifier output +// of [WithCOREChecks]. +// +// All counts are zero when [WithCOREChecks] was not requested. +type MemoryAccessSummary struct { + Total int `json:"total"` + COREProtected int `json:"coreProtected"` + ContextSafe int `json:"contextSafe"` + MapValueSafe int `json:"mapValueSafe"` + KernelDirect int `json:"kernelDirect"` + Uncategorized int `json:"uncategorized"` +} + +// ELFWarning is a single diagnostic raised during ELF analysis. +// +// Severity is "warning" or "error". Program is the BPF program name in +// which the issue was detected (empty for object-wide warnings). File and +// Line carry BTF source-info location when available. +type ELFWarning struct { + Severity string `json:"severity"` + Program string `json:"program,omitempty"` + File string `json:"file,omitempty"` + Line uint32 `json:"line,omitempty"` + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +// Requirements derives the same [FeatureGroup] that [FromELF] would return +// for the same input, using the data already cached on p. The two paths +// are guaranteed to produce deep-equal output. +// +// Use this when you want both the diagnostic [ELFProbes] view and the +// gating [FeatureGroup] from a single ELF parse. +func (p *ELFProbes) Requirements() FeatureGroup { + if p == nil { + return nil + } + // Re-derive the deterministic ordering FromELF guarantees: program + // types sorted, then map types sorted, then helper-per-program pairs + // sorted by (programType, helper). Because ELFProbes already stores + // per-program helpers but FromELF emits the cross-program union, we + // rebuild the union here. + progTypes := make([]ebpf.ProgramType, 0, len(p.ProgramTypes)) + for _, pt := range p.ProgramTypes { + progTypes = append(progTypes, pt.Type) + } + slices.Sort(progTypes) + + mapTypes := make([]ebpf.MapType, 0, len(p.MapTypes)) + for _, mt := range p.MapTypes { + mapTypes = append(mapTypes, mt.Type) + } + slices.Sort(mapTypes) + + seenPair := make(map[ProgramHelperRequirement]struct{}) + pairs := make([]ProgramHelperRequirement, 0) + for _, prog := range p.Programs { + for _, h := range prog.Helpers { + pair := ProgramHelperRequirement{ + ProgramType: prog.ProgramType, + Helper: h.Helper, + } + if _, ok := seenPair[pair]; ok { + continue + } + seenPair[pair] = struct{}{} + pairs = append(pairs, pair) + } + } + slices.SortFunc(pairs, func(a, b ProgramHelperRequirement) int { + if a.ProgramType != b.ProgramType { + if a.ProgramType < b.ProgramType { + return -1 + } + return 1 + } + if a.Helper != b.Helper { + if a.Helper < b.Helper { + return -1 + } + return 1 + } + return 0 + }) + + out := make(FeatureGroup, 0, len(progTypes)+len(mapTypes)+len(pairs)+1) + for _, pt := range progTypes { + out = append(out, RequireProgramType(pt)) + } + for _, mt := range mapTypes { + out = append(out, RequireMapType(mt)) + } + for _, pair := range pairs { + out = append(out, pair) + } + if !p.MinKernel.IsZero() { + out = append(out, RequireMinKernel(p.MinKernel.Major, p.MinKernel.Minor)) + } + return out +} diff --git a/probe_elf_core.go b/probe_elf_core.go new file mode 100644 index 0000000..7669290 --- /dev/null +++ b/probe_elf_core.go @@ -0,0 +1,228 @@ +package kfeatures + +import ( + "fmt" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/btf" +) + +// Compile-time assertions: keep the unused import for btf clearly used +// (for CORERelocationMetadata). See lineInfo for why btf.LineInfoMetadata +// is not currently called. +var _ = btf.CORERelocationMetadata + +// regProvenance is the inferred origin of the value currently held by a +// register. The classifier tracks per-register provenance through a +// linear single-pass walk of the instruction stream. +// +// This is heuristic, not a verifier: branches and loops are conservatively +// flattened, and provenance inferred from a memory load whose source +// register is itself uncategorized propagates as uncategorized. The aim is +// to surface obvious "you forgot BPF_CORE_READ" mistakes, not to +// reproduce the in-kernel verifier's reachability analysis. +type regProvenance int + +const ( + provUnknown regProvenance = iota + provContext // R1 at function entry; pointer to ctx + provMapValue // pointer returned by bpf_map_lookup_elem and friends + provKernelDirect // pointer returned by helpers like bpf_get_current_task + provCOREProtected // pointer carried via a CORE-relocated load +) + +// memoryAccessKind labels what the classifier inferred about a single +// pointer dereference. accessNotLoad is the zero value and means the +// instruction at this position is not a memory load (the slot is skipped +// when summing per-program counters). +type memoryAccessKind int + +const ( + accessNotLoad memoryAccessKind = iota + accessUncategorized + accessContextSafe + accessMapValueSafe + accessKernelDirect + accessCOREProtected +) + +func init() { + classifyMemoryAccesses = computeMemoryAccessSummary + coreWarnings = computeCOREWarnings +} + +// computeMemoryAccessSummary walks the program once and returns the +// per-program counters surfaced via [MemoryAccessSummary]. +func computeMemoryAccessSummary(prog *ebpf.ProgramSpec) MemoryAccessSummary { + if prog == nil { + return MemoryAccessSummary{} + } + classes := classifyProgram(prog) + summary := MemoryAccessSummary{} + for _, k := range classes { + switch k { + case accessNotLoad: + continue + case accessContextSafe: + summary.ContextSafe++ + case accessMapValueSafe: + summary.MapValueSafe++ + case accessKernelDirect: + summary.KernelDirect++ + case accessCOREProtected: + summary.COREProtected++ + default: + summary.Uncategorized++ + } + summary.Total++ + } + return summary +} + +// computeCOREWarnings emits one warning per kernel-direct load that the +// classifier flagged. The intent is to nudge the user toward CO-RE. +func computeCOREWarnings(progName string, prog *ebpf.ProgramSpec) []ELFWarning { + if prog == nil { + return nil + } + classes := classifyProgram(prog) + var out []ELFWarning + for i, k := range classes { + if k != accessKernelDirect { + continue + } + // Look up source-info if the program carries it. cilium's + // LineInfo.Source() returns "" when no info attached, which is + // fine — we drop file/line in that case. + ins := prog.Instructions[i] + file, line := lineInfo(ins) + out = append(out, ELFWarning{ + Severity: "warning", + Program: progName, + File: file, + Line: line, + Message: fmt.Sprintf("kernel pointer dereferenced without CO-RE protection at instruction %d", i), + Detail: "consider using BPF_CORE_READ() / bpf_probe_read_kernel() to protect against kernel struct layout changes", + }) + } + return out +} + +// classifyProgram walks prog.Instructions once and returns a per-instruction +// classification slice. Non-load instructions classify as accessUncategorized +// (the value is meaningful only for memory-load opcodes; consumers iterate +// alongside the original instructions). +func classifyProgram(prog *ebpf.ProgramSpec) []memoryAccessKind { + insns := prog.Instructions + classes := make([]memoryAccessKind, len(insns)) + regs := make(map[asm.Register]regProvenance, 16) + regs[asm.R1] = provContext // BPF ABI: R1 holds *ctx at entry + + for i := range insns { + ins := insns[i] + op := ins.OpCode + // CO-RE-relocated load: mark dest as CORE-protected and + // classify this access as CORE-protected too. + if btf.CORERelocationMetadata(&insns[i]) != nil { + classes[i] = accessCOREProtected + regs[ins.Dst] = provCOREProtected + continue + } + // Memory load (LdXClass + MemMode). Classify by source-register + // provenance; propagate to dst. + if op.Class() == asm.LdXClass && op.Mode() == asm.MemMode { + classes[i] = classifyAccess(regs[ins.Src]) + regs[ins.Dst] = inheritFromSource(regs[ins.Src]) + continue + } + // Helper call: R0 receives helper return value; classify R0. + if ins.IsBuiltinCall() { + helper := asm.BuiltinFunc(ins.Constant) + regs[asm.R0] = provenanceForHelper(helper) + // R1-R5 are clobbered by the call (BPF ABI). + delete(regs, asm.R1) + delete(regs, asm.R2) + delete(regs, asm.R3) + delete(regs, asm.R4) + delete(regs, asm.R5) + continue + } + // Plain register-to-register move: propagate provenance. + if op.Class() == asm.ALU64Class && op.ALUOp() == asm.Mov && op.Source() == asm.RegSource { + regs[ins.Dst] = regs[ins.Src] + continue + } + if writesDst(op) { + delete(regs, ins.Dst) + } + } + return classes +} + +func writesDst(op asm.OpCode) bool { + switch class := op.Class(); { + case class == asm.LdClass || class == asm.LdXClass: + return true + case class.IsALU(): + return true + default: + return false + } +} + +// classifyAccess maps a source-register provenance to a memory-access +// classification. Always returns one of the load classifications (never +// accessNotLoad), since the caller has already determined this is a load +// instruction. +func classifyAccess(p regProvenance) memoryAccessKind { + switch p { + case provContext: + return accessContextSafe + case provMapValue: + return accessMapValueSafe + case provKernelDirect: + return accessKernelDirect + case provCOREProtected: + return accessCOREProtected + default: + return accessUncategorized + } +} + +// inheritFromSource decides what provenance to propagate to the +// destination register when loading from a typed source. The default is +// to keep the source classification, on the assumption that loading +// "ctx->skb->data" still yields a context-derived pointer; the kernel- +// direct chain remains kernel-direct so that follow-up loads continue +// to warn. +func inheritFromSource(p regProvenance) regProvenance { + switch p { + case provContext, provCOREProtected, provKernelDirect, provMapValue: + return p + default: + return provUnknown + } +} + +// provenanceForHelper assigns R0's provenance after a helper call. The +// table is intentionally small: the classifier degrades to provUnknown +// for any helper not enumerated here. +func provenanceForHelper(fn asm.BuiltinFunc) regProvenance { + switch fn { + case asm.FnMapLookupElem: + return provMapValue + case asm.FnGetCurrentTask, asm.FnGetCurrentTaskBtf: + return provKernelDirect + } + return provUnknown +} + +// lineInfo extracts file/line from BTF source-info metadata attached to +// ins. cilium/ebpf v0.20 does not yet expose per-instruction line info as +// public Metadata, so this returns empty values; once exposed, callers +// will get file:line in [ELFWarning] without further changes. +func lineInfo(ins asm.Instruction) (string, uint32) { + _ = ins + return "", 0 +} diff --git a/probe_elf_core_test.go b/probe_elf_core_test.go new file mode 100644 index 0000000..6e908df --- /dev/null +++ b/probe_elf_core_test.go @@ -0,0 +1,301 @@ +package kfeatures + +import ( + "strings" + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" +) + +func TestClassifyMemoryAccessesNil(t *testing.T) { + if got := computeMemoryAccessSummary(nil); got != (MemoryAccessSummary{}) { + t.Errorf("nil prog should yield zero summary, got %+v", got) + } +} + +func TestComputeCOREWarningsNil(t *testing.T) { + if got := computeCOREWarnings("p", nil); got != nil { + t.Errorf("nil prog should yield nil, got %+v", got) + } +} + +func TestClassifyContextSafeAccess(t *testing.T) { + // Load from R1 (context). Should be context-safe. + prog := &ebpf.ProgramSpec{ + Name: "ctx", + Type: ebpf.Kprobe, + Instructions: asm.Instructions{ + // R2 = *(u64 *)(R1 + 0) + asm.LoadMem(asm.R2, asm.R1, 0, asm.DWord), + asm.Return(), + }, + } + got := computeMemoryAccessSummary(prog) + if got.Total != 1 || got.ContextSafe != 1 { + t.Errorf("summary = %+v, want Total=1 ContextSafe=1", got) + } +} + +func TestClassifyMapValueSafeAccess(t *testing.T) { + prog := &ebpf.ProgramSpec{ + Name: "mv", + Type: ebpf.Kprobe, + Instructions: asm.Instructions{ + // R0 = bpf_map_lookup_elem(...) + asm.FnMapLookupElem.Call(), + // R1 = *(u64 *)(R0 + 0) + asm.LoadMem(asm.R1, asm.R0, 0, asm.DWord), + asm.Return(), + }, + } + got := computeMemoryAccessSummary(prog) + if got.Total != 1 || got.MapValueSafe != 1 { + t.Errorf("summary = %+v, want Total=1 MapValueSafe=1", got) + } +} + +func TestClassifyKernelDirectAccessAndWarning(t *testing.T) { + prog := &ebpf.ProgramSpec{ + Name: "kd", + Type: ebpf.Kprobe, + Instructions: asm.Instructions{ + // R0 = bpf_get_current_task() + asm.FnGetCurrentTask.Call(), + // R1 = *(u64 *)(R0 + 0) // unprotected kernel deref + asm.LoadMem(asm.R1, asm.R0, 0, asm.DWord), + asm.Return(), + }, + } + got := computeMemoryAccessSummary(prog) + if got.Total != 1 || got.KernelDirect != 1 { + t.Errorf("summary = %+v, want Total=1 KernelDirect=1", got) + } + warnings := computeCOREWarnings("kd", prog) + if len(warnings) != 1 { + t.Fatalf("warnings = %d, want 1: %+v", len(warnings), warnings) + } + w := warnings[0] + if w.Severity != "warning" || w.Program != "kd" { + t.Errorf("warning = %+v", w) + } + if !strings.Contains(w.Message, "kernel pointer dereferenced without CO-RE") { + t.Errorf("warning message = %q", w.Message) + } +} + +func TestClassifyUncategorizedAccess(t *testing.T) { + prog := &ebpf.ProgramSpec{ + Name: "u", + Type: ebpf.Kprobe, + Instructions: asm.Instructions{ + // Load a constant into R6, then dereference R6. + asm.LoadImm(asm.R6, 0xdeadbeef, asm.DWord), + asm.LoadMem(asm.R1, asm.R6, 0, asm.DWord), + asm.Return(), + }, + } + got := computeMemoryAccessSummary(prog) + if got.Total != 1 || got.Uncategorized != 1 { + t.Errorf("summary = %+v, want Total=1 Uncategorized=1", got) + } + if warnings := computeCOREWarnings("u", prog); len(warnings) != 0 { + t.Errorf("uncategorized should not warn, got %d", len(warnings)) + } +} + +func TestClassifyOverwritesClearProvenance(t *testing.T) { + cases := []struct { + name string + instructions asm.Instructions + }{ + { + name: "load-imm clears context", + instructions: asm.Instructions{ + asm.LoadImm(asm.R1, 0xdeadbeef, asm.DWord), + asm.LoadMem(asm.R2, asm.R1, 0, asm.DWord), + asm.Return(), + }, + }, + { + name: "alu clears context", + instructions: asm.Instructions{ + asm.Add.Imm(asm.R1, 1), + asm.LoadMem(asm.R2, asm.R1, 0, asm.DWord), + asm.Return(), + }, + }, + { + name: "load-imm clears kernel direct", + instructions: asm.Instructions{ + asm.FnGetCurrentTask.Call(), + asm.LoadImm(asm.R0, 0, asm.DWord), + asm.LoadMem(asm.R1, asm.R0, 0, asm.DWord), + asm.Return(), + }, + }, + { + name: "alu clears kernel direct", + instructions: asm.Instructions{ + asm.FnGetCurrentTask.Call(), + asm.Add.Imm(asm.R0, 1), + asm.LoadMem(asm.R1, asm.R0, 0, asm.DWord), + asm.Return(), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prog := &ebpf.ProgramSpec{ + Name: "overwrite", + Type: ebpf.Kprobe, + Instructions: tc.instructions, + } + got := computeMemoryAccessSummary(prog) + if got.Total != 1 || got.Uncategorized != 1 { + t.Errorf("summary = %+v, want Total=1 Uncategorized=1", got) + } + if warnings := computeCOREWarnings("overwrite", prog); len(warnings) != 0 { + t.Errorf("overwritten provenance should not warn, got %d", len(warnings)) + } + }) + } +} + +func TestClassifyMovPropagatesProvenance(t *testing.T) { + prog := &ebpf.ProgramSpec{ + Name: "mov", + Type: ebpf.Kprobe, + Instructions: asm.Instructions{ + // R6 = R1 (context) + asm.Mov.Reg(asm.R6, asm.R1), + // R2 = *(u64 *)(R6 + 0) // context-safe via mov + asm.LoadMem(asm.R2, asm.R6, 0, asm.DWord), + asm.Return(), + }, + } + got := computeMemoryAccessSummary(prog) + if got.ContextSafe != 1 { + t.Errorf("expected mov-propagated context-safe, got %+v", got) + } +} + +func TestClassifyHelperClobbersR1R5(t *testing.T) { + // After a helper call, R1-R5 must be cleared so subsequent loads + // from those registers are uncategorized (not context-safe). + prog := &ebpf.ProgramSpec{ + Name: "clobber", + Type: ebpf.Kprobe, + Instructions: asm.Instructions{ + asm.FnGetSmpProcessorId.Call(), + // R2 = *(u64 *)(R1 + 0). R1 was the helper's argv slot; now unknown. + asm.LoadMem(asm.R2, asm.R1, 0, asm.DWord), + asm.Return(), + }, + } + got := computeMemoryAccessSummary(prog) + if got.Uncategorized != 1 { + t.Errorf("expected R1 clobbered post-helper, got %+v", got) + } +} + +func TestLineInfoStubReturnsEmpty(t *testing.T) { + file, line := lineInfo(asm.Return()) + if file != "" || line != 0 { + t.Errorf("lineInfo stub = (%q, %d), want (\"\", 0)", file, line) + } +} + +func TestProvenanceForHelperUnknown(t *testing.T) { + // A helper not in the table degrades to provUnknown. + if got := provenanceForHelper(asm.FnTracePrintk); got != provUnknown { + t.Errorf("provenanceForHelper(FnTracePrintk) = %v, want provUnknown", got) + } +} + +func TestClassifyAccessAllProvenances(t *testing.T) { + cases := map[regProvenance]memoryAccessKind{ + provContext: accessContextSafe, + provMapValue: accessMapValueSafe, + provKernelDirect: accessKernelDirect, + provCOREProtected: accessCOREProtected, + provUnknown: accessUncategorized, + } + for p, want := range cases { + if got := classifyAccess(p); got != want { + t.Errorf("classifyAccess(%v) = %v, want %v", p, got, want) + } + } + // Sanity: accessNotLoad is the zero value and is never produced + // by classifyAccess (callers reserve that for non-load slots). + var zero memoryAccessKind + if zero != accessNotLoad { + t.Errorf("zero value of memoryAccessKind = %v, want accessNotLoad", zero) + } +} + +func TestInheritFromSourceAllProvenances(t *testing.T) { + for _, p := range []regProvenance{provContext, provMapValue, provKernelDirect, provCOREProtected} { + if got := inheritFromSource(p); got != p { + t.Errorf("inheritFromSource(%v) = %v, want %v", p, got, p) + } + } + if got := inheritFromSource(provUnknown); got != provUnknown { + t.Errorf("inheritFromSource(provUnknown) = %v", got) + } +} + +func TestWithCOREChecksEndToEnd(t *testing.T) { + // Drive through ProbeELFWith via probesFromCollectionSpec. + prog := &ebpf.ProgramSpec{ + Name: "drive", + Type: ebpf.Kprobe, + License: "GPL", + Instructions: asm.Instructions{ + asm.FnGetCurrentTask.Call(), + asm.LoadMem(asm.R1, asm.R0, 0, asm.DWord), + asm.Return(), + }, + } + spec := &ebpf.CollectionSpec{ + Programs: map[string]*ebpf.ProgramSpec{"drive": prog}, + } + probes, err := probesFromCollectionSpec(spec, &elfProbeConfig{withCORE: true}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + if probes.Programs[0].MemoryAccesses.KernelDirect != 1 { + t.Errorf("MemoryAccesses = %+v", probes.Programs[0].MemoryAccesses) + } + // Should have the CO-RE warning plus the FnGetCurrentTask superseded warning. + var hasCore, hasSuperseded bool + for _, w := range probes.Warnings { + if strings.Contains(w.Message, "kernel pointer dereferenced") { + hasCore = true + } + if strings.Contains(w.Message, "deprecated helper") { + hasSuperseded = true + } + } + if !hasCore { + t.Error("expected CO-RE warning") + } + if !hasSuperseded { + t.Error("expected superseded-helper warning") + } + + // Without WithCOREChecks(), MemoryAccesses must be zero and no CO-RE warning. + probes2, err := probesFromCollectionSpec(spec, &elfProbeConfig{}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + if probes2.Programs[0].MemoryAccesses != (MemoryAccessSummary{}) { + t.Errorf("MemoryAccesses without WithCOREChecks = %+v, want zero", probes2.Programs[0].MemoryAccesses) + } + for _, w := range probes2.Warnings { + if strings.Contains(w.Message, "kernel pointer dereferenced") { + t.Error("CO-RE warning leaked without WithCOREChecks()") + } + } +} diff --git a/probe_elf_extract.go b/probe_elf_extract.go new file mode 100644 index 0000000..355109b --- /dev/null +++ b/probe_elf_extract.go @@ -0,0 +1,384 @@ +package kfeatures + +import ( + "fmt" + "slices" + "strings" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/btf" + + "github.com/leodido/kfeatures/internal/kernelversions" +) + +// ELFProbeOption configures [ProbeELFWith]. +type ELFProbeOption func(*elfProbeConfig) + +// elfProbeConfig is the internal accumulator for ProbeELFWith options. +type elfProbeConfig struct { + withCORE bool +} + +// WithCOREChecks enables the heuristic CO-RE register-state classifier +// when probing the ELF. Without this option, [MemoryAccessSummary] is +// zero on every program and no CO-RE direct-access warnings are emitted. +// +// The classifier is heuristic: it has known false negatives on +// vmlinux.h-built programs (the warning may never fire) and rare false +// positives on hand-defined structs without +// `__attribute__((preserve_access_index))`. Treat its warnings as +// suggestions, not as hard verifier errors. +func WithCOREChecks() ELFProbeOption { + return func(c *elfProbeConfig) { c.withCORE = true } +} + +// ProbeELF returns descriptive ELF-derived signals about the program at +// path: license, BTF presence, CO-RE relocation count, computed minimum +// kernel version, transports, per-program / per-map metadata, deduplicated +// helper / program-type / map-type requirements with their introduction +// versions, and warnings (the "always-on" subset; see [ELFProbes.Warnings] +// and [ELFWarning]). +// +// ProbeELF never gates and never participates in [Check]. Use +// [(*ELFProbes).Requirements] to derive a [FeatureGroup] from the same +// parse if you also need the gate view. +func ProbeELF(path string) (*ELFProbes, error) { + return ProbeELFWith(path) +} + +// ProbeELFWith is the option-driven variant of [ProbeELF]. +// +// The success path requires a real eBPF object on disk (clang + +// libbpf), which is not available in the unit-test environment. The +// per-branch behaviour is exercised by the programmatic-fixture tests +// against [probesFromCollectionSpec] (the same code paths under a +// CollectionSpec built in memory) and by the integration tests with a +// `make build`-able fixture; the disk-load wrapper itself is excluded +// from the per-file coverage gate. +// +// coverage:ignore +func ProbeELFWith(path string, opts ...ELFProbeOption) (*ELFProbes, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("probe ELF: empty path") + } + cfg := &elfProbeConfig{} + for _, opt := range opts { + opt(cfg) + } + + spec, err := ebpf.LoadCollectionSpec(path) + if err != nil { + return nil, fmt.Errorf("probe ELF %q: load collection spec: %w", path, err) + } + probes, err := probesFromCollectionSpec(spec, cfg) + if err != nil { + return nil, fmt.Errorf("probe ELF %q: %w", path, err) + } + probes.Path = path + return probes, nil +} + +// probesFromCollectionSpec walks a *ebpf.CollectionSpec and populates an +// *ELFProbes value. It does not parse the ELF: it operates on an already- +// parsed CollectionSpec so that test fixtures can construct one directly +// without a .bpf.o file on disk. +func probesFromCollectionSpec(spec *ebpf.CollectionSpec, cfg *elfProbeConfig) (*ELFProbes, error) { + if spec == nil { + return nil, fmt.Errorf("nil collection spec") + } + out := &ELFProbes{ + HasBTF: spec.Types != nil, + } + + // Walk programs in deterministic order. + progNames := make([]string, 0, len(spec.Programs)) + for n := range spec.Programs { + progNames = append(progNames, n) + } + slices.Sort(progNames) + + seenProgTypes := make(map[ebpf.ProgramType]struct{}) + progTypesOrder := []ebpf.ProgramType{} + helperUnion := make(map[asm.BuiltinFunc]struct{}) + helperUnionOrder := []asm.BuiltinFunc{} + totalCORE := 0 + + for _, name := range progNames { + prog := spec.Programs[name] + if prog == nil { + return nil, fmt.Errorf("program %q: nil program spec", name) + } + if err := validateProgramType(prog.Type); err != nil { + return nil, fmt.Errorf("program %q: %w", name, err) + } + if _, ok := seenProgTypes[prog.Type]; !ok { + seenProgTypes[prog.Type] = struct{}{} + progTypesOrder = append(progTypesOrder, prog.Type) + } + + // Take the first non-empty license seen across programs. + if out.License == "" && prog.License != "" { + out.License = prog.License + } + + entry := ELFProgram{ + Name: name, + SectionName: prog.SectionName, + Type: prog.Type.String(), + ProgramType: prog.Type, + NumInsns: len(prog.Instructions), + } + + seenInProg := make(map[asm.BuiltinFunc]struct{}) + for i := range prog.Instructions { + ins := &prog.Instructions[i] + if btf.CORERelocationMetadata(ins) != nil { + entry.CORERelocs++ + totalCORE++ + } + if !ins.IsBuiltinCall() { + continue + } + helper, err := helperFromInstruction(*ins) + if err != nil { + return nil, fmt.Errorf("program %q: %w", name, err) + } + if err := validateProgramHelper(helper); err != nil { + return nil, fmt.Errorf("program %q: %w", name, err) + } + if _, ok := seenInProg[helper]; ok { + continue + } + seenInProg[helper] = struct{}{} + ver, err := helperKernelVersion(helper) + if err != nil { + return nil, fmt.Errorf("program %q: %w", name, err) + } + entry.Helpers = append(entry.Helpers, ELFHelperRequirement{ + Name: helper.String(), + Helper: helper, + Version: ver, + }) + if _, ok := helperUnion[helper]; !ok { + helperUnion[helper] = struct{}{} + helperUnionOrder = append(helperUnionOrder, helper) + } + } + // Stable per-program helper order: by helper id. + slices.SortFunc(entry.Helpers, func(a, b ELFHelperRequirement) int { + if a.Helper < b.Helper { + return -1 + } + if a.Helper > b.Helper { + return 1 + } + return 0 + }) + + if cfg.withCORE { + entry.MemoryAccesses = classifyMemoryAccesses(prog) + out.Warnings = append(out.Warnings, coreWarnings(name, prog)...) + } + out.Programs = append(out.Programs, entry) + } + out.CORERelocations = totalCORE + + // Maps in deterministic order. + mapNames := make([]string, 0, len(spec.Maps)) + for n := range spec.Maps { + mapNames = append(mapNames, n) + } + slices.Sort(mapNames) + seenMapTypes := make(map[ebpf.MapType]struct{}) + mapTypesOrder := []ebpf.MapType{} + for _, name := range mapNames { + m := spec.Maps[name] + if m == nil { + return nil, fmt.Errorf("map %q: nil map spec", name) + } + if err := validateMapType(m.Type); err != nil { + return nil, fmt.Errorf("map %q: %w", name, err) + } + if _, ok := seenMapTypes[m.Type]; !ok { + seenMapTypes[m.Type] = struct{}{} + mapTypesOrder = append(mapTypesOrder, m.Type) + } + ver, err := mapTypeKernelVersion(m.Type) + if err != nil { + return nil, fmt.Errorf("map %q: %w", name, err) + } + out.Maps = append(out.Maps, ELFMap{ + Name: name, + Type: m.Type.String(), + KeySize: m.KeySize, + ValueSize: m.ValueSize, + MaxEntries: m.MaxEntries, + Version: ver, + }) + } + + // Helper / program-type / map-type union, sorted for determinism. + slices.Sort(progTypesOrder) + for _, pt := range progTypesOrder { + ver, err := programTypeKernelVersion(pt) + if err != nil { + return nil, err + } + out.ProgramTypes = append(out.ProgramTypes, ELFProgramTypeRequirement{ + Name: pt.String(), + Type: pt, + Version: ver, + }) + } + slices.Sort(mapTypesOrder) + for _, mt := range mapTypesOrder { + ver, err := mapTypeKernelVersion(mt) + if err != nil { + return nil, err + } + out.MapTypes = append(out.MapTypes, ELFMapTypeRequirement{ + Name: mt.String(), + Type: mt, + Version: ver, + }) + } + slices.SortFunc(helperUnionOrder, func(a, b asm.BuiltinFunc) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 + }) + for _, h := range helperUnionOrder { + ver, err := helperKernelVersion(h) + if err != nil { + return nil, err + } + out.Helpers = append(out.Helpers, ELFHelperRequirement{ + Name: h.String(), + Helper: h, + Version: ver, + }) + } + // Sort helpers by version desc so consumers see the gating rows first. + slices.SortStableFunc(out.Helpers, func(a, b ELFHelperRequirement) int { + if a.Version.less(b.Version) { + return 1 + } + if b.Version.less(a.Version) { + return -1 + } + // Same version: stable secondary key for determinism. + if a.Helper < b.Helper { + return -1 + } + if a.Helper > b.Helper { + return 1 + } + return 0 + }) + + // Min kernel = max across all per-row versions. + for _, h := range out.Helpers { + out.MinKernel = maxKernelVersion(out.MinKernel, h.Version) + } + for _, pt := range out.ProgramTypes { + out.MinKernel = maxKernelVersion(out.MinKernel, pt.Version) + } + for _, mt := range out.MapTypes { + out.MinKernel = maxKernelVersion(out.MinKernel, mt.Version) + } + + // Transport detection: presence of RingBuf / PerfEventArray gives a + // human-readable hint similar to bpfvet's "Transport: …" line. + transports := transportsFor(out.MapTypes) + if len(transports) > 0 { + out.Transport = transports + } + + // Always-on warning subset (superseded helpers). The CO-RE category + // is appended above when WithCOREChecks() is set. + out.Warnings = append(out.Warnings, supersededHelperWarnings(out)...) + + return out, nil +} + +// transportsFor produces human-readable transport strings derived from the +// set of map types referenced by the ELF. Output is sorted for determinism. +func transportsFor(maps []ELFMapTypeRequirement) []string { + seen := map[string]struct{}{} + for _, mt := range maps { + switch mt.Type { + case ebpf.RingBuf: + seen["event streaming via RingBuf"] = struct{}{} + case ebpf.PerfEventArray: + seen["event streaming via PerfEventArray"] = struct{}{} + } + } + if len(seen) == 0 { + return nil + } + out := make([]string, 0, len(seen)) + for s := range seen { + out = append(out, s) + } + slices.Sort(out) + return out +} + +func helperKernelVersion(helper asm.BuiltinFunc) (KernelVersion, error) { + // ProbeELF fails closed on missing snapshot rows so MinKernel and Requirements stay trustworthy. + ver, ok := kernelversions.HelperKernelVersion(helper) + if !ok { + return KernelVersion{}, fmt.Errorf("helper %s has no kernel-version snapshot row", helper) + } + return fromInternal(ver), nil +} + +func programTypeKernelVersion(pt ebpf.ProgramType) (KernelVersion, error) { + ver, ok := kernelversions.ProgramTypeKernelVersion(pt) + if !ok { + return KernelVersion{}, fmt.Errorf("program type %s has no kernel-version snapshot row", pt) + } + return fromInternal(ver), nil +} + +func mapTypeKernelVersion(mt ebpf.MapType) (KernelVersion, error) { + ver, ok := kernelversions.MapTypeKernelVersion(mt) + if !ok { + return KernelVersion{}, fmt.Errorf("map type %s has no kernel-version snapshot row", mt) + } + return fromInternal(ver), nil +} + +// classifyMemoryAccesses is the CO-RE register-state classifier entry +// point. The full implementation lives in probe_elf_core.go (added in a +// later step); this stub keeps the extractor compilable in isolation. +// +// When the classifier is implemented, it walks prog.Instructions once and +// returns per-program counts. +// +// coverage:ignore +var classifyMemoryAccesses = func(prog *ebpf.ProgramSpec) MemoryAccessSummary { + return MemoryAccessSummary{} +} + +// coreWarnings emits CO-RE direct-access warnings for prog. Stub-only +// until step 5; implementation lives in probe_elf_core.go. +// +// coverage:ignore +var coreWarnings = func(progName string, prog *ebpf.ProgramSpec) []ELFWarning { + return nil +} + +// supersededHelperWarnings inspects out.Helpers and emits the always-on +// "deprecated helper" warning subset. Stub-only until step 4; the rules +// live in probe_elf_warnings.go. +// +// coverage:ignore +var supersededHelperWarnings = func(out *ELFProbes) []ELFWarning { + return nil +} diff --git a/probe_elf_extract_test.go b/probe_elf_extract_test.go new file mode 100644 index 0000000..2294dae --- /dev/null +++ b/probe_elf_extract_test.go @@ -0,0 +1,371 @@ +package kfeatures + +import ( + "reflect" + "strings" + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + + "github.com/leodido/kfeatures/internal/kernelversions" +) + +// makeFixtureSpec builds a minimal *ebpf.CollectionSpec without touching +// disk. The shape is chosen to exercise multi-program / multi-map +// deduplication and ordering. +func makeFixtureSpec() *ebpf.CollectionSpec { + kprobeInsns := asm.Instructions{ + asm.LoadImm(asm.R1, 0, asm.DWord), + asm.FnMapLookupElem.Call(), + asm.FnTracePrintk.Call(), + // Duplicate within program. + asm.FnMapLookupElem.Call(), + asm.Return(), + } + xdpInsns := asm.Instructions{ + asm.LoadImm(asm.R1, 0, asm.DWord), + asm.FnMapLookupElem.Call(), + asm.Return(), + } + return &ebpf.CollectionSpec{ + Maps: map[string]*ebpf.MapSpec{ + "events": { + Name: "events", + Type: ebpf.RingBuf, + MaxEntries: 4096, + }, + "hashy": { + Name: "hashy", + Type: ebpf.Hash, + KeySize: 4, + ValueSize: 8, + MaxEntries: 1024, + }, + }, + Programs: map[string]*ebpf.ProgramSpec{ + "kprobe_prog": { + Name: "kprobe_prog", + Type: ebpf.Kprobe, + SectionName: "kprobe/do_sys_openat2", + License: "GPL", + Instructions: kprobeInsns, + }, + "xdp_prog": { + Name: "xdp_prog", + Type: ebpf.XDP, + SectionName: "xdp/main", + License: "GPL", + Instructions: xdpInsns, + }, + }, + } +} + +func TestProbeFromCollectionSpec(t *testing.T) { + got, err := probesFromCollectionSpec(makeFixtureSpec(), &elfProbeConfig{}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + if got.License != "GPL" { + t.Errorf("License = %q, want GPL", got.License) + } + if got.HasBTF { + t.Errorf("HasBTF should be false for fixture without BTF") + } + if got.CORERelocations != 0 { + t.Errorf("CORERelocations = %d, want 0", got.CORERelocations) + } + // MinKernel = max across helpers/progtypes/maptypes. + // RingBuf was introduced in 5.8, which dominates everything else here. + want := KernelVersion{Major: 5, Minor: 8} + if got.MinKernel != want { + t.Errorf("MinKernel = %v, want %v", got.MinKernel, want) + } + // Transport detection. + if len(got.Transport) != 1 || got.Transport[0] != "event streaming via RingBuf" { + t.Errorf("Transport = %v, want [\"event streaming via RingBuf\"]", got.Transport) + } + // Programs are ordered alphabetically. + if len(got.Programs) != 2 || got.Programs[0].Name != "kprobe_prog" || got.Programs[1].Name != "xdp_prog" { + t.Fatalf("program order wrong: %+v", got.Programs) + } + if got.Programs[0].Type != ebpf.Kprobe.String() { + t.Errorf("kprobe program type = %q", got.Programs[0].Type) + } + if got.Programs[0].NumInsns != 5 { + t.Errorf("kprobe NumInsns = %d, want 5", got.Programs[0].NumInsns) + } + // Per-program helpers deduped. + if len(got.Programs[0].Helpers) != 2 { + t.Errorf("kprobe helpers = %d, want 2 (deduped)", len(got.Programs[0].Helpers)) + } + // Maps deterministic order (alphabetical). + if len(got.Maps) != 2 || got.Maps[0].Name != "events" || got.Maps[1].Name != "hashy" { + t.Fatalf("map order wrong: %+v", got.Maps) + } + if got.Maps[1].KeySize != 4 || got.Maps[1].ValueSize != 8 { + t.Errorf("hashy sizes = key=%d val=%d", got.Maps[1].KeySize, got.Maps[1].ValueSize) + } +} + +func TestRequirementsParityWithFromELF(t *testing.T) { + spec := makeFixtureSpec() + probes, err := probesFromCollectionSpec(spec, &elfProbeConfig{}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + want, err := requirementsFromCollectionSpec(spec) + if err != nil { + t.Fatalf("requirementsFromCollectionSpec: %v", err) + } + want = append(want, RequireMinKernel(probes.MinKernel.Major, probes.MinKernel.Minor)) + got := probes.Requirements() + if !reflect.DeepEqual(got, want) { + t.Fatalf("Requirements() != FromELF():\n got: %+v\nwant: %+v", got, want) + } +} + +func TestProbeELFEmptyPath(t *testing.T) { + _, err := ProbeELF("") + if err == nil { + t.Fatal("ProbeELF(\"\"): expected error") + } +} + +func TestProbeELFLoadFailure(t *testing.T) { + _, err := ProbeELF("/nonexistent/path/that/does/not/exist.bpf.o") + if err == nil { + t.Fatal("ProbeELF(missing): expected error") + } +} + +func TestProbeNilSpec(t *testing.T) { + _, err := probesFromCollectionSpec(nil, &elfProbeConfig{}) + if err == nil { + t.Fatal("nil spec: expected error") + } +} + +func TestProbeNilProgramSpec(t *testing.T) { + spec := &ebpf.CollectionSpec{ + Programs: map[string]*ebpf.ProgramSpec{ + "prog": nil, + }, + } + if _, err := probesFromCollectionSpec(spec, &elfProbeConfig{}); err == nil { + t.Fatal("nil program spec: expected error") + } +} + +func TestProbeNilMapSpec(t *testing.T) { + spec := &ebpf.CollectionSpec{ + Maps: map[string]*ebpf.MapSpec{ + "m": nil, + }, + } + if _, err := probesFromCollectionSpec(spec, &elfProbeConfig{}); err == nil { + t.Fatal("nil map spec: expected error") + } +} + +func TestProbeUnknownProgramType(t *testing.T) { + spec := &ebpf.CollectionSpec{ + Programs: map[string]*ebpf.ProgramSpec{ + "prog": { + Name: "prog", + Type: ebpf.UnspecifiedProgram, + Instructions: asm.Instructions{ + asm.Return(), + }, + }, + }, + } + if _, err := probesFromCollectionSpec(spec, &elfProbeConfig{}); err == nil { + t.Fatal("unspecified program type: expected error") + } +} + +func TestProbeUnknownMapType(t *testing.T) { + spec := &ebpf.CollectionSpec{ + Maps: map[string]*ebpf.MapSpec{ + "m": {Name: "m", Type: ebpf.UnspecifiedMap, MaxEntries: 1}, + }, + } + if _, err := probesFromCollectionSpec(spec, &elfProbeConfig{}); err == nil { + t.Fatal("unspecified map type: expected error") + } +} + +func TestProbeFailsClosedWhenHelperVersionMissing(t *testing.T) { + old, ok := kernelversions.HelperVersion[asm.FnTracePrintk] + if !ok { + t.Fatal("test setup: FnTracePrintk missing from kernel version snapshot") + } + delete(kernelversions.HelperVersion, asm.FnTracePrintk) + t.Cleanup(func() { kernelversions.HelperVersion[asm.FnTracePrintk] = old }) + + _, err := probesFromCollectionSpec(makeFixtureSpec(), &elfProbeConfig{}) + if err == nil { + t.Fatal("expected error for missing helper kernel-version row") + } + if !strings.Contains(err.Error(), "helper FnTracePrintk has no kernel-version snapshot row") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProbeFailsClosedWhenProgramTypeVersionMissing(t *testing.T) { + old, ok := kernelversions.ProgTypeVersion[ebpf.XDP] + if !ok { + t.Fatal("test setup: XDP missing from kernel version snapshot") + } + delete(kernelversions.ProgTypeVersion, ebpf.XDP) + t.Cleanup(func() { kernelversions.ProgTypeVersion[ebpf.XDP] = old }) + + _, err := probesFromCollectionSpec(makeFixtureSpec(), &elfProbeConfig{}) + if err == nil { + t.Fatal("expected error for missing program-type kernel-version row") + } + if !strings.Contains(err.Error(), "program type XDP has no kernel-version snapshot row") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProbeFailsClosedWhenMapTypeVersionMissing(t *testing.T) { + old, ok := kernelversions.MapTypeVersion[ebpf.RingBuf] + if !ok { + t.Fatal("test setup: RingBuf missing from kernel version snapshot") + } + delete(kernelversions.MapTypeVersion, ebpf.RingBuf) + t.Cleanup(func() { kernelversions.MapTypeVersion[ebpf.RingBuf] = old }) + + _, err := probesFromCollectionSpec(makeFixtureSpec(), &elfProbeConfig{}) + if err == nil { + t.Fatal("expected error for missing map-type kernel-version row") + } + if !strings.Contains(err.Error(), "map type RingBuf has no kernel-version snapshot row") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestProbeHelperSortBranches adds helpers in an order that forces both +// arms of the union-sort comparator and both arms of the version-desc +// stable sort comparator to fire (helpers introduced in different +// kernel versions, with at least one tie on the same version to take +// the secondary-key arms). +func TestProbeHelperSortBranches(t *testing.T) { + insns := asm.Instructions{ + // FnGetCurrentTaskBtf (5.11) > FnTracePrintk (4.1) > FnMapLookupElem (3.18) + // inserted in non-monotonic order. + asm.FnTracePrintk.Call(), + asm.FnGetCurrentTaskBtf.Call(), + asm.FnMapLookupElem.Call(), + // Two helpers added in the same kernel version (4.1) so the + // sort hits the equal-version secondary-key branch. + asm.FnGetCurrentPidTgid.Call(), + asm.FnGetCurrentUidGid.Call(), + asm.Return(), + } + spec := &ebpf.CollectionSpec{ + Programs: map[string]*ebpf.ProgramSpec{ + "p": { + Name: "p", + Type: ebpf.Kprobe, + License: "GPL", + Instructions: insns, + }, + }, + } + got, err := probesFromCollectionSpec(spec, &elfProbeConfig{}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + if len(got.Helpers) != 5 { + t.Fatalf("Helpers = %d, want 5", len(got.Helpers)) + } + // Verify version-desc ordering: FnGetCurrentTaskBtf (5.11) must be first. + if got.Helpers[0].Helper != asm.FnGetCurrentTaskBtf { + t.Errorf("Helpers[0] = %v, want FnGetCurrentTaskBtf", got.Helpers[0].Helper) + } +} + +func TestProbeWithCOREChecksHook(t *testing.T) { + // Swap the classifier hook for one that returns a non-zero summary + // so we exercise the cfg.withCORE branch end to end without taking + // a dependency on the real classifier (covered by its own tests). + prev := classifyMemoryAccesses + prevWarn := coreWarnings + defer func() { + classifyMemoryAccesses = prev + coreWarnings = prevWarn + }() + classifyMemoryAccesses = func(prog *ebpf.ProgramSpec) MemoryAccessSummary { + return MemoryAccessSummary{Total: 1, ContextSafe: 1} + } + coreWarnings = func(progName string, prog *ebpf.ProgramSpec) []ELFWarning { + return []ELFWarning{{Program: progName, Message: "synthetic"}} + } + got, err := probesFromCollectionSpec(makeFixtureSpec(), &elfProbeConfig{withCORE: true}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + if got.Programs[0].MemoryAccesses.Total != 1 { + t.Errorf("MemoryAccesses.Total = %d, want 1", got.Programs[0].MemoryAccesses.Total) + } + if len(got.Warnings) == 0 { + t.Errorf("expected synthetic warnings to be appended") + } +} + +func TestProbeNilProgram(t *testing.T) { + spec := &ebpf.CollectionSpec{ + Programs: map[string]*ebpf.ProgramSpec{"x": nil}, + } + _, err := probesFromCollectionSpec(spec, &elfProbeConfig{}) + if err == nil { + t.Fatal("nil program: expected error") + } +} + +func TestProbeNilMap(t *testing.T) { + spec := &ebpf.CollectionSpec{ + Maps: map[string]*ebpf.MapSpec{"x": nil}, + } + _, err := probesFromCollectionSpec(spec, &elfProbeConfig{}) + if err == nil { + t.Fatal("nil map: expected error") + } +} + +func TestTransportsFor(t *testing.T) { + cases := []struct { + name string + in []ELFMapTypeRequirement + want []string + }{ + {"empty", nil, nil}, + {"hash only", []ELFMapTypeRequirement{{Type: ebpf.Hash}}, nil}, + {"ringbuf", []ELFMapTypeRequirement{{Type: ebpf.RingBuf}}, []string{"event streaming via RingBuf"}}, + {"perfevent", []ELFMapTypeRequirement{{Type: ebpf.PerfEventArray}}, []string{"event streaming via PerfEventArray"}}, + {"both", []ELFMapTypeRequirement{{Type: ebpf.RingBuf}, {Type: ebpf.PerfEventArray}}, []string{"event streaming via PerfEventArray", "event streaming via RingBuf"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := transportsFor(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("transportsFor() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestWithCOREChecksOption(t *testing.T) { + cfg := &elfProbeConfig{} + if cfg.withCORE { + t.Fatal("expected withCORE false by default") + } + WithCOREChecks()(cfg) + if !cfg.withCORE { + t.Fatal("WithCOREChecks() did not enable withCORE") + } +} diff --git a/probe_elf_test.go b/probe_elf_test.go new file mode 100644 index 0000000..f1e855a --- /dev/null +++ b/probe_elf_test.go @@ -0,0 +1,131 @@ +package kfeatures + +import ( + "reflect" + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + + "github.com/leodido/kfeatures/internal/kernelversions" +) + +func TestKernelVersionPublic(t *testing.T) { + a := KernelVersion{Major: 5, Minor: 8} + if got := a.String(); got != "5.8" { + t.Errorf("String() = %q, want %q", got, "5.8") + } + if a.IsZero() { + t.Errorf("5.8 should not be IsZero") + } + if !(KernelVersion{}).IsZero() { + t.Errorf("zero should be IsZero") + } + if (KernelVersion{Major: 5, Minor: 8}).less(KernelVersion{Major: 5, Minor: 8}) { + t.Errorf("equal versions should not be less") + } + if !(KernelVersion{Major: 5, Minor: 8}).less(KernelVersion{Major: 6, Minor: 0}) { + t.Errorf("5.8 < 6.0") + } + if (KernelVersion{Major: 6, Minor: 0}).less(KernelVersion{Major: 5, Minor: 8}) { + t.Errorf("6.0 should not be less than 5.8") + } + if !(KernelVersion{Major: 5, Minor: 8}).less(KernelVersion{Major: 5, Minor: 9}) { + t.Errorf("5.8 < 5.9") + } +} + +func TestMaxKernelVersion(t *testing.T) { + a := KernelVersion{Major: 5, Minor: 8} + b := KernelVersion{Major: 6, Minor: 0} + if got := maxKernelVersion(a, b); got != b { + t.Errorf("max(5.8, 6.0) = %v, want %v", got, b) + } + if got := maxKernelVersion(b, a); got != b { + t.Errorf("max(6.0, 5.8) = %v, want %v", got, b) + } +} + +func TestFromInternal(t *testing.T) { + got := fromInternal(kernelversions.KernelVersion{Major: 5, Minor: 8}) + if got != (KernelVersion{Major: 5, Minor: 8}) { + t.Errorf("fromInternal = %v", got) + } +} + +func TestRequirementsFromHandBuiltProbes(t *testing.T) { + // Confirm Requirements() emits the same FeatureGroup shape as + // FromELF: program types sorted, then map types sorted, then + // program-helper pairs sorted by (programType, helper). + p := &ELFProbes{ + Programs: []ELFProgram{ + { + Name: "prog_kprobe", + Type: ebpf.Kprobe.String(), + ProgramType: ebpf.Kprobe, + Helpers: []ELFHelperRequirement{ + {Name: "FnMapLookupElem", Helper: asm.FnMapLookupElem}, + {Name: "FnTracePrintk", Helper: asm.FnTracePrintk}, + // Duplicate within the same program (different probe-of-the-same-helper) + {Name: "FnMapLookupElem", Helper: asm.FnMapLookupElem}, + }, + }, + { + Name: "prog_xdp", + Type: ebpf.XDP.String(), + ProgramType: ebpf.XDP, + Helpers: []ELFHelperRequirement{ + {Name: "FnMapLookupElem", Helper: asm.FnMapLookupElem}, + }, + }, + }, + ProgramTypes: []ELFProgramTypeRequirement{ + {Name: "XDP", Type: ebpf.XDP}, + {Name: "Kprobe", Type: ebpf.Kprobe}, + }, + MapTypes: []ELFMapTypeRequirement{ + {Name: "RingBuf", Type: ebpf.RingBuf}, + {Name: "Hash", Type: ebpf.Hash}, + }, + } + got := p.Requirements() + want := FeatureGroup{ + // program types sorted by enum value (Kprobe < XDP) + RequireProgramType(ebpf.Kprobe), + RequireProgramType(ebpf.XDP), + // map types sorted by enum value (Hash < RingBuf) + RequireMapType(ebpf.Hash), + RequireMapType(ebpf.RingBuf), + // program-helper pairs sorted by (ProgramType, Helper) + RequireProgramHelper(ebpf.Kprobe, asm.FnMapLookupElem), + RequireProgramHelper(ebpf.Kprobe, asm.FnTracePrintk), + RequireProgramHelper(ebpf.XDP, asm.FnMapLookupElem), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Requirements() mismatch:\n got: %+v\nwant: %+v", got, want) + } +} + +func TestRequirementsIncludesDerivedMinKernel(t *testing.T) { + p := &ELFProbes{MinKernel: KernelVersion{Major: 5, Minor: 8}} + got := p.Requirements() + want := FeatureGroup{RequireMinKernel(5, 8)} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Requirements() = %+v, want %+v", got, want) + } +} + +func TestRequirementsNilReceiver(t *testing.T) { + var p *ELFProbes + if got := p.Requirements(); got != nil { + t.Errorf("nil receiver should return nil, got %+v", got) + } +} + +func TestRequirementsEmptyProbes(t *testing.T) { + p := &ELFProbes{} + got := p.Requirements() + if len(got) != 0 { + t.Errorf("empty probes should yield empty FeatureGroup, got %+v", got) + } +} diff --git a/probe_elf_warnings.go b/probe_elf_warnings.go new file mode 100644 index 0000000..2c45bcd --- /dev/null +++ b/probe_elf_warnings.go @@ -0,0 +1,100 @@ +package kfeatures + +import ( + "fmt" + + "github.com/cilium/ebpf/asm" +) + +// supersededHelperRule names a deprecated/superseded helper and the +// preferred replacements with the kernel version those replacements +// landed in. +type supersededHelperRule struct { + deprecated asm.BuiltinFunc + replacements []string + since string // human-readable kernel version where replacements landed + rationale string // one-line motivation +} + +// supersededHelperRules enumerates the deprecated helpers we surface as +// always-on warnings. The list is intentionally small; new entries +// require an upstream citation in the PR description so reviewers can +// audit the rationale. +var supersededHelperRules = []supersededHelperRule{ + { + deprecated: asm.FnProbeRead, + replacements: []string{"FnProbeReadKernel", "FnProbeReadUser"}, + since: "5.5", + rationale: "bpf_probe_read does not distinguish kernel vs user address space; use the explicit per-space variants", + }, + { + deprecated: asm.FnProbeReadStr, + replacements: []string{"FnProbeReadKernelStr", "FnProbeReadUserStr"}, + since: "5.5", + rationale: "bpf_probe_read_str does not distinguish kernel vs user address space; use the explicit per-space variants", + }, + { + deprecated: asm.FnGetCurrentTask, + replacements: []string{"FnGetCurrentTaskBtf"}, + since: "5.11", + rationale: "bpf_get_current_task returns an opaque pointer; the BTF variant returns a typed struct task_struct", + }, +} + +func init() { + // Bind the always-on warning generator now that the rules are + // declared. Keeping this as a var assignment in init() (rather than + // at package scope) lets tests stub supersededHelperWarnings without + // import-cycle gymnastics. + supersededHelperWarnings = generateSupersededHelperWarnings +} + +// generateSupersededHelperWarnings returns one ELFWarning per deprecated +// helper detected in out.Programs, scoped per-program so that the user +// sees which call site needs attention. +func generateSupersededHelperWarnings(out *ELFProbes) []ELFWarning { + if out == nil || len(out.Programs) == 0 { + return nil + } + var warnings []ELFWarning + for _, prog := range out.Programs { + for _, h := range prog.Helpers { + rule, ok := lookupSupersededHelper(h.Helper) + if !ok { + continue + } + warnings = append(warnings, ELFWarning{ + Severity: "warning", + Program: prog.Name, + Message: fmt.Sprintf("uses deprecated helper %s", h.Name), + Detail: fmt.Sprintf("prefer %s (since %s); %s", joinReplacements(rule.replacements), rule.since, rule.rationale), + }) + } + } + return warnings +} + +func lookupSupersededHelper(fn asm.BuiltinFunc) (supersededHelperRule, bool) { + for _, r := range supersededHelperRules { + if r.deprecated == fn { + return r, true + } + } + return supersededHelperRule{}, false +} + +func joinReplacements(rs []string) string { + switch len(rs) { + case 0: + return "" + case 1: + return rs[0] + case 2: + return rs[0] + " or " + rs[1] + } + out := rs[0] + for i := 1; i < len(rs)-1; i++ { + out += ", " + rs[i] + } + return out + " or " + rs[len(rs)-1] +} diff --git a/probe_elf_warnings_test.go b/probe_elf_warnings_test.go new file mode 100644 index 0000000..e02e12f --- /dev/null +++ b/probe_elf_warnings_test.go @@ -0,0 +1,117 @@ +package kfeatures + +import ( + "strings" + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" +) + +func TestSupersededHelperWarningsNil(t *testing.T) { + if got := generateSupersededHelperWarnings(nil); got != nil { + t.Errorf("nil receiver = %+v, want nil", got) + } + if got := generateSupersededHelperWarnings(&ELFProbes{}); got != nil { + t.Errorf("empty programs = %+v, want nil", got) + } +} + +func TestSupersededHelperWarningsBindsViaInit(t *testing.T) { + if supersededHelperWarnings == nil { + t.Fatal("supersededHelperWarnings should be bound by init()") + } +} + +func TestSupersededHelperWarningsAllRules(t *testing.T) { + probes := &ELFProbes{ + Programs: []ELFProgram{ + { + Name: "p1", + Type: ebpf.Kprobe.String(), + ProgramType: ebpf.Kprobe, + Helpers: []ELFHelperRequirement{ + {Name: asm.FnProbeRead.String(), Helper: asm.FnProbeRead}, + {Name: asm.FnProbeReadStr.String(), Helper: asm.FnProbeReadStr}, + {Name: asm.FnGetCurrentTask.String(), Helper: asm.FnGetCurrentTask}, + // A non-deprecated helper to confirm we don't warn on it. + {Name: asm.FnMapLookupElem.String(), Helper: asm.FnMapLookupElem}, + }, + }, + }, + } + got := generateSupersededHelperWarnings(probes) + if len(got) != 3 { + t.Fatalf("expected 3 warnings, got %d: %+v", len(got), got) + } + for _, w := range got { + if w.Severity != "warning" { + t.Errorf("severity = %q, want warning", w.Severity) + } + if w.Program != "p1" { + t.Errorf("program = %q, want p1", w.Program) + } + if !strings.HasPrefix(w.Message, "uses deprecated helper ") { + t.Errorf("message = %q", w.Message) + } + if w.Detail == "" { + t.Error("detail should be set") + } + } +} + +func TestLookupSupersededHelperUnknown(t *testing.T) { + if _, ok := lookupSupersededHelper(asm.FnMapLookupElem); ok { + t.Errorf("FnMapLookupElem should not match a superseded rule") + } +} + +func TestJoinReplacements(t *testing.T) { + cases := []struct { + in []string + want string + }{ + {nil, ""}, + {[]string{}, ""}, + {[]string{"FnA"}, "FnA"}, + {[]string{"FnA", "FnB"}, "FnA or FnB"}, + {[]string{"FnA", "FnB", "FnC"}, "FnA, FnB or FnC"}, + {[]string{"FnA", "FnB", "FnC", "FnD"}, "FnA, FnB, FnC or FnD"}, + } + for _, tc := range cases { + if got := joinReplacements(tc.in); got != tc.want { + t.Errorf("joinReplacements(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSupersededWarningIntegration(t *testing.T) { + // A program that calls bpf_probe_read should produce a warning when + // the full ELFProbes view is built. + insns := asm.Instructions{ + asm.LoadImm(asm.R1, 0, asm.DWord), + asm.FnProbeRead.Call(), + asm.Return(), + } + spec := &ebpf.CollectionSpec{ + Programs: map[string]*ebpf.ProgramSpec{ + "oldprog": { + Name: "oldprog", + Type: ebpf.Kprobe, + License: "GPL", + Instructions: insns, + }, + }, + } + got, err := probesFromCollectionSpec(spec, &elfProbeConfig{}) + if err != nil { + t.Fatalf("probesFromCollectionSpec: %v", err) + } + if len(got.Warnings) != 1 { + t.Fatalf("expected 1 warning, got %d: %+v", len(got.Warnings), got.Warnings) + } + w := got.Warnings[0] + if !strings.Contains(w.Message, "bpf_probe_read") && !strings.Contains(w.Message, "FnProbeRead") { + t.Errorf("warning message = %q", w.Message) + } +} diff --git a/requirement_min_kernel.go b/requirement_min_kernel.go new file mode 100644 index 0000000..7a0d6e8 --- /dev/null +++ b/requirement_min_kernel.go @@ -0,0 +1,86 @@ +package kfeatures + +import ( + "fmt" + "strconv" + "strings" +) + +// MinKernelRequirement requires the running kernel to be at least +// Major.Minor. +// +// MinKernelRequirement is the gating counterpart of [ELFProbes.MinKernel]: +// callers that already derived a minimum kernel version from an ELF (or +// from any other source) can drop the value into [Check] without manually +// translating it into a helper / program-type / map-type set. +// +// Patch level is intentionally absent: BPF feature introductions are +// always pinned at major.minor in upstream documentation. +type MinKernelRequirement struct { + Major int + Minor int +} + +// RequireMinKernel creates a MinKernelRequirement gating on the supplied +// major.minor pair. +// +// Panics if either operand is negative; both indicate API misuse. +func RequireMinKernel(major, minor int) MinKernelRequirement { + if major < 0 || minor < 0 { + panic("kfeatures.RequireMinKernel: major and minor must be non-negative") + } + return MinKernelRequirement{Major: major, Minor: minor} +} + +// String renders the requirement as "kernel >= major.minor". +func (r MinKernelRequirement) String() string { + return fmt.Sprintf("kernel >= %d.%d", r.Major, r.Minor) +} + +func (MinKernelRequirement) isRequirement() {} + +// satisfiedBy reports whether release (a uname -r string) is >= the +// requirement's major.minor. Releases that fail to parse are reported as +// unsatisfied with an explanatory error. +func (r MinKernelRequirement) satisfiedBy(release string) error { + gotMajor, gotMinor, err := parseKernelRelease(release) + if err != nil { + return fmt.Errorf("could not parse running kernel release %q: %w", release, err) + } + if gotMajor > r.Major { + return nil + } + if gotMajor == r.Major && gotMinor >= r.Minor { + return nil + } + return fmt.Errorf("running kernel %d.%d is older than required %d.%d", gotMajor, gotMinor, r.Major, r.Minor) +} + +// parseKernelRelease extracts the leading major.minor from a uname -r +// string. Accepts "6.1", "6.1.0", "6.1.0-generic", "6.1.0-1.el9.x86_64". +func parseKernelRelease(release string) (int, int, error) { + release = strings.TrimSpace(release) + if release == "" { + return 0, 0, fmt.Errorf("empty release string") + } + // Trim trailing build qualifiers after a '-' so "6.1.0-generic" parses. + if i := strings.IndexByte(release, '-'); i >= 0 { + release = release[:i] + } + parts := strings.SplitN(release, ".", 3) + if len(parts) < 2 { + return 0, 0, fmt.Errorf("missing minor version separator") + } + maj, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("invalid major: %w", err) + } + min, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("invalid minor: %w", err) + } + if maj < 0 || min < 0 { + return 0, 0, fmt.Errorf("negative version components") + } + return maj, min, nil +} diff --git a/requirement_min_kernel_test.go b/requirement_min_kernel_test.go new file mode 100644 index 0000000..9b605c0 --- /dev/null +++ b/requirement_min_kernel_test.go @@ -0,0 +1,132 @@ +package kfeatures + +import ( + "strings" + "testing" +) + +func TestRequireMinKernel(t *testing.T) { + r := RequireMinKernel(5, 8) + if r != (MinKernelRequirement{Major: 5, Minor: 8}) { + t.Errorf("RequireMinKernel = %+v", r) + } + if r.String() != "kernel >= 5.8" { + t.Errorf("String() = %q", r.String()) + } +} + +func TestRequireMinKernelNegativePanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic on negative major") + } + }() + RequireMinKernel(-1, 0) +} + +func TestRequireMinKernelNegativeMinorPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic on negative minor") + } + }() + RequireMinKernel(5, -1) +} + +func TestMinKernelSatisfiedBy(t *testing.T) { + r := MinKernelRequirement{Major: 5, Minor: 8} + cases := []struct { + release string + ok bool + }{ + {"6.1.0-generic", true}, + {"5.8.0", true}, + {"5.8", true}, + {"5.10.0", true}, + {"5.7.0", false}, + {"4.19.300-1.el7", false}, + {"6.0.0-1.el9.x86_64", true}, + } + for _, tc := range cases { + err := r.satisfiedBy(tc.release) + if tc.ok && err != nil { + t.Errorf("satisfiedBy(%q) returned err: %v", tc.release, err) + } + if !tc.ok && err == nil { + t.Errorf("satisfiedBy(%q) returned nil, want err", tc.release) + } + } +} + +func TestMinKernelSatisfiedByParseErrors(t *testing.T) { + r := MinKernelRequirement{Major: 5, Minor: 8} + cases := []string{ + "", + " ", + "abc", + "5", + "5.x", + "x.5", + } + for _, in := range cases { + err := r.satisfiedBy(in) + if err == nil { + t.Errorf("satisfiedBy(%q) expected error, got nil", in) + continue + } + if !strings.Contains(err.Error(), "could not parse running kernel release") { + t.Errorf("satisfiedBy(%q) error = %v, want parse-error wrapper", in, err) + } + } +} + +func TestParseKernelRelease(t *testing.T) { + cases := []struct { + in string + major int + minor int + ok bool + }{ + {"6.1.0", 6, 1, true}, + {"6.1", 6, 1, true}, + {"6.1.0-generic", 6, 1, true}, + {"6.1.0-1.el9.x86_64", 6, 1, true}, + {"6.1-rc1", 6, 1, true}, + {" 5.10.0 ", 5, 10, true}, + {"", 0, 0, false}, + {"abc", 0, 0, false}, + {"5", 0, 0, false}, + {"5.x", 0, 0, false}, + {"x.5", 0, 0, false}, + } + for _, tc := range cases { + major, minor, err := parseKernelRelease(tc.in) + if tc.ok { + if err != nil { + t.Errorf("parseKernelRelease(%q) err: %v", tc.in, err) + continue + } + if major != tc.major || minor != tc.minor { + t.Errorf("parseKernelRelease(%q) = (%d, %d), want (%d, %d)", tc.in, major, minor, tc.major, tc.minor) + } + } else if err == nil { + t.Errorf("parseKernelRelease(%q) expected err", tc.in) + } + } +} + +func TestMinKernelRequirementImplementsRequirement(t *testing.T) { + var _ Requirement = MinKernelRequirement{} + var _ Requirement = RequireMinKernel(5, 8) +} + +func TestMinKernelDedupedInRequirementSet(t *testing.T) { + rs := normalizeRequirements([]Requirement{ + RequireMinKernel(5, 8), + RequireMinKernel(5, 8), // dup + RequireMinKernel(6, 0), + }) + if len(rs.minKernels) != 2 { + t.Errorf("expected 2 minKernels after dedup, got %d", len(rs.minKernels)) + } +} diff --git a/requirements.go b/requirements.go index 30abae6..7621fe8 100644 --- a/requirements.go +++ b/requirements.go @@ -107,12 +107,14 @@ type requirementSet struct { mapTypes []ebpf.MapType programHelpers []ProgramHelperRequirement mounts []MountRequirement + minKernels []MinKernelRequirement seenFeatures map[Feature]struct{} seenProgramTypes map[ebpf.ProgramType]struct{} seenMapTypes map[ebpf.MapType]struct{} seenProgramHelpers map[ProgramHelperRequirement]struct{} seenMounts map[MountRequirement]struct{} + seenMinKernels map[MinKernelRequirement]struct{} } func normalizeRequirements(required []Requirement) requirementSet { @@ -122,6 +124,7 @@ func normalizeRequirements(required []Requirement) requirementSet { seenMapTypes: map[ebpf.MapType]struct{}{}, seenProgramHelpers: map[ProgramHelperRequirement]struct{}{}, seenMounts: map[MountRequirement]struct{}{}, + seenMinKernels: map[MinKernelRequirement]struct{}{}, } for _, req := range required { rs.add(req) @@ -168,5 +171,11 @@ func (rs *requirementSet) add(req Requirement) { } rs.seenMounts[r] = struct{}{} rs.mounts = append(rs.mounts, r) + case MinKernelRequirement: + if _, ok := rs.seenMinKernels[r]; ok { + return + } + rs.seenMinKernels[r] = struct{}{} + rs.minKernels = append(rs.minKernels, r) } } diff --git a/test/cli_common.bats b/test/cli_common.bats index c53ca2b..3457e8f 100644 --- a/test/cli_common.bats +++ b/test/cli_common.bats @@ -18,7 +18,21 @@ setup_file() { @test "help: probe subcommand" { run "$KFEATURES_BIN" probe --help assert_success - assert_output --partial "Probe all kernel features" + assert_output --partial "probe host" + assert_output --partial "probe bpf" +} + +@test "help: probe host leaf" { + run "$KFEATURES_BIN" probe host --help + assert_success + assert_output --partial "Probe the running kernel" +} + +@test "help: probe bpf leaf" { + run "$KFEATURES_BIN" probe bpf --help + assert_success + assert_output --partial "Probe a compiled eBPF ELF object" + assert_output --partial "--with-core" } @test "help: check subcommand" { @@ -88,16 +102,23 @@ assert d['flag'] == 'require', d # the structcli exitcode package. These assertions lock the contract so # downstream agents can rely on it. -@test "errors: missing required flag emits structured JSON with exit code 10" { +@test "check --from-elf: missing file emits a clean error" { + run "$KFEATURES_BIN" check --from-elf /nonexistent/path.bpf.o + [[ "$status" -ne 0 ]] + echo "$output" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +assert 'from-elf' in d.get('message', ''), d +" +} + +@test "check: bare invocation requires --require or --from-elf" { run "$KFEATURES_BIN" check - [[ "$status" -eq 10 ]] + [[ "$status" -ne 0 ]] echo "$output" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) -assert d['error'] == 'missing_required_flag', d -assert d['exit_code'] == 10, d -assert d['flag'] == 'require', d -assert d['command'] == 'kfeatures check', d +assert 'no features specified' in d.get('message', ''), d " } diff --git a/test/cli_linux.bats b/test/cli_linux.bats index eb89f86..f5139ec 100644 --- a/test/cli_linux.bats +++ b/test/cli_linux.bats @@ -34,6 +34,32 @@ setup_file() { echo "$output" | python3 -c "import sys,json; json.load(sys.stdin)" } +@test "probe host: bare and explicit forms produce identical leading output" { + run "$KFEATURES_BIN" probe + assert_success + bare_first=$(echo "$output" | head -1) + run "$KFEATURES_BIN" probe host + assert_success + host_first=$(echo "$output" | head -1) + [[ "$bare_first" == "$host_first" ]] +} + +@test "probe host --json: produces valid JSON" { + run "$KFEATURES_BIN" probe host --json + assert_success + echo "$output" | python3 -c "import sys,json; json.load(sys.stdin)" +} + +@test "probe bpf: missing arg fails" { + run "$KFEATURES_BIN" probe bpf + [[ "$status" -ne 0 ]] +} + +@test "probe bpf: nonexistent file fails cleanly" { + run "$KFEATURES_BIN" probe bpf /nonexistent/path.bpf.o + [[ "$status" -ne 0 ]] +} + # --- check --- @test "check: bpf-syscall is satisfied" { diff --git a/test/cli_mcp.bats b/test/cli_mcp.bats index 55b28c5..6926b95 100644 --- a/test/cli_mcp.bats +++ b/test/cli_mcp.bats @@ -77,12 +77,15 @@ assert 'tools' in r['capabilities'], r '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}')" response="$(mcp_response_by_id 2 "$transcript")" - # probe / check / config exposed; version + completion-* excluded. + # check / config / probe-host / probe-bpf exposed; bare 'probe' is a + # parent with subcommands and is auto-excluded by structcli's MCP + # registry (see structcli mcp.go:309 shouldIncludeMCPCommand). + # version + completion-* are also excluded. echo "$response" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) names = sorted(t['name'] for t in d['result']['tools']) -assert names == ['check', 'config', 'probe'], names +assert names == ['check', 'config', 'probe-bpf', 'probe-host'], names " } @@ -143,7 +146,7 @@ assert d['error']['message'] == 'unknown tool', d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"check","arguments":{"require":"bpf-syscall","json":true}}}' \ '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"check","arguments":{"require":"nonexistent"}}}' \ - '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"probe","arguments":{"json":true}}}')" + '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"probe-host","arguments":{"json":true}}}')" # All three tools/call responses must be present and well-formed. for id in 2 3 4; do response="$(mcp_response_by_id "$id" "$transcript")" @@ -163,7 +166,7 @@ assert d['error']['message'] == 'unknown tool', d transcript="$(mcp_call \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"check","arguments":{"require":"bpf-syscall","json":true}}}' \ - '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"probe","arguments":{"json":true}}}')" + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"probe-host","arguments":{"json":true}}}')" printf "%s\n" "$transcript" | python3 -c " import sys, json for line in sys.stdin: