Phase 4: remote catalog + banger kernel pull
Introduces the headline feature of the kernel catalog: pulling a kernel
bundle over HTTP without any local build step.
Catalog format (internal/kernelcat/catalog.go):
- Catalog { Version, Entries } + CatEntry { Name, Distro, Arch,
KernelVersion, TarballURL, TarballSHA256, SizeBytes, Description }.
- catalog.json is embedded via go:embed and ships with each banger
binary. It starts empty (Phase 5's CI pipeline will populate it).
- Lookup(name) returns the matching entry or os.ErrNotExist.
Fetch (internal/kernelcat/fetch.go):
- HTTP GET with streaming SHA256 over the response body.
- zstd-decode (github.com/klauspost/compress/zstd) -> tar extract into
<kernelsDir>/<name>/.
- Hardens against path-traversal tarball entries (members whose
normalised path escapes the target dir, and unsafe symlink
targets) and sha256-mismatch downloads; any failure removes the
partially-populated target dir.
- Regular files, directories, and safe symlinks are supported; other
tar types (hardlinks, devices, fifos) are silently skipped.
- After extraction, recomputes sha256 over the on-disk vmlinux and
writes the manifest with Source="pull:<url>".
Daemon methods (internal/daemon/kernels.go):
- KernelPull(ctx, {Name, Force}) - lookup in embedded catalog, refuse
overwrite unless Force, delegate to kernelcat.Fetch.
- KernelCatalog(ctx) - return the embedded catalog annotated per-entry
with whether it has been pulled locally.
RPC: kernel.pull, kernel.catalog dispatch cases.
CLI:
- `banger kernel pull <name> [--force]`.
- `banger kernel list --available` prints the catalog with a
pulled/available STATE column and a human-readable size.
Tests: fetch round-trip (extract + manifest + sha256), sha256 mismatch
rejection with cleanup, missing-vmlinux rejection, path-traversal
rejection, HTTP error propagation, catalog parsing, lookup,
pulled-status reconciliation. All 20 packages green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7192ba24ae
commit
f0668ee598
13 changed files with 711 additions and 4 deletions
198
internal/kernelcat/fetch_test.go
Normal file
198
internal/kernelcat/fetch_test.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package kernelcat
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// tarballFile describes one member of the test tarball.
|
||||
type tarballFile struct {
|
||||
name string
|
||||
mode int64
|
||||
data []byte
|
||||
link string // for symlinks
|
||||
dir bool
|
||||
}
|
||||
|
||||
func buildTestTarball(t *testing.T, files []tarballFile) ([]byte, string) {
|
||||
t.Helper()
|
||||
var tarBuf bytes.Buffer
|
||||
tw := tar.NewWriter(&tarBuf)
|
||||
for _, f := range files {
|
||||
hdr := &tar.Header{Name: f.name, Mode: f.mode}
|
||||
switch {
|
||||
case f.dir:
|
||||
hdr.Typeflag = tar.TypeDir
|
||||
hdr.Mode = 0o755
|
||||
case f.link != "":
|
||||
hdr.Typeflag = tar.TypeSymlink
|
||||
hdr.Linkname = f.link
|
||||
default:
|
||||
hdr.Typeflag = tar.TypeReg
|
||||
hdr.Size = int64(len(f.data))
|
||||
if hdr.Mode == 0 {
|
||||
hdr.Mode = 0o644
|
||||
}
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatalf("tar WriteHeader: %v", err)
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeReg {
|
||||
if _, err := tw.Write(f.data); err != nil {
|
||||
t.Fatalf("tar Write: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tar Close: %v", err)
|
||||
}
|
||||
|
||||
var compressed bytes.Buffer
|
||||
zw, err := zstd.NewWriter(&compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("zstd NewWriter: %v", err)
|
||||
}
|
||||
if _, err := zw.Write(tarBuf.Bytes()); err != nil {
|
||||
t.Fatalf("zstd Write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zstd Close: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(compressed.Bytes())
|
||||
return compressed.Bytes(), hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func serveTarball(t *testing.T, body []byte) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestFetchExtractsTarballAndWritesManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
body, sum := buildTestTarball(t, []tarballFile{
|
||||
{name: "vmlinux", data: []byte("kernel-bytes")},
|
||||
{name: "initrd.img", data: []byte("initrd-bytes")},
|
||||
{name: "modules", dir: true},
|
||||
{name: "modules/modules.dep", data: []byte("dep")},
|
||||
})
|
||||
srv := serveTarball(t, body)
|
||||
|
||||
kernelsDir := t.TempDir()
|
||||
stored, err := Fetch(context.Background(), nil, kernelsDir, CatEntry{
|
||||
Name: "void-6.12",
|
||||
Distro: "void",
|
||||
Arch: "x86_64",
|
||||
KernelVersion: "6.12.79_1",
|
||||
TarballURL: srv.URL + "/pkg.tar.zst",
|
||||
TarballSHA256: sum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if stored.Name != "void-6.12" || stored.Distro != "void" {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
if stored.SHA256 == "" {
|
||||
t.Errorf("SHA256 not populated")
|
||||
}
|
||||
|
||||
for _, rel := range []string{"vmlinux", "initrd.img", "modules/modules.dep", "manifest.json"} {
|
||||
if _, err := os.Stat(filepath.Join(kernelsDir, "void-6.12", rel)); err != nil {
|
||||
t.Errorf("expected %s in catalog: %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsShaMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
body, _ := buildTestTarball(t, []tarballFile{
|
||||
{name: "vmlinux", data: []byte("k")},
|
||||
})
|
||||
srv := serveTarball(t, body)
|
||||
|
||||
kernelsDir := t.TempDir()
|
||||
_, err := Fetch(context.Background(), nil, kernelsDir, CatEntry{
|
||||
Name: "void-6.12",
|
||||
TarballURL: srv.URL + "/pkg.tar.zst",
|
||||
TarballSHA256: "000000000000000000000000000000000000000000000000000000000000beef",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("expected sha256 mismatch, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(kernelsDir, "void-6.12")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("target dir should be cleaned up on mismatch: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsMissingKernel(t *testing.T) {
|
||||
t.Parallel()
|
||||
body, sum := buildTestTarball(t, []tarballFile{
|
||||
{name: "initrd.img", data: []byte("i")}, // no vmlinux
|
||||
})
|
||||
srv := serveTarball(t, body)
|
||||
kernelsDir := t.TempDir()
|
||||
_, err := Fetch(context.Background(), nil, kernelsDir, CatEntry{
|
||||
Name: "broken",
|
||||
TarballURL: srv.URL + "/pkg.tar.zst",
|
||||
TarballSHA256: sum,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing vmlinux") {
|
||||
t.Fatalf("expected missing vmlinux, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
body, sum := buildTestTarball(t, []tarballFile{
|
||||
{name: "vmlinux", data: []byte("k")},
|
||||
{name: "../escape", data: []byte("bad")},
|
||||
})
|
||||
srv := serveTarball(t, body)
|
||||
kernelsDir := t.TempDir()
|
||||
_, err := Fetch(context.Background(), nil, kernelsDir, CatEntry{
|
||||
Name: "bad-tarball",
|
||||
TarballURL: srv.URL + "/pkg.tar.zst",
|
||||
TarballSHA256: sum,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe path") {
|
||||
t.Fatalf("expected unsafe path error, got %v", err)
|
||||
}
|
||||
escapePath := filepath.Join(filepath.Dir(kernelsDir), "escape")
|
||||
if _, statErr := os.Stat(escapePath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("traversal escape file should not exist: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsHTTPError(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "nope", http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
kernelsDir := t.TempDir()
|
||||
_, err := Fetch(context.Background(), nil, kernelsDir, CatEntry{
|
||||
Name: "missing",
|
||||
TarballURL: srv.URL + "/pkg.tar.zst",
|
||||
TarballSHA256: "deadbeef",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Fatalf("expected HTTP 404, got %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue