diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..312bc58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + go-version-file: go.mod + + - run: make + + - uses: actions/upload-artifact@v4 + with: + name: init-binaries + path: internal/initrd/embed/init-* + + vet: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + go-version-file: go.mod + + - uses: actions/download-artifact@v7 + with: + name: init-binaries + path: internal/initrd/embed/ + + - run: go vet ./... + + test: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + go-version-file: go.mod + + - uses: actions/download-artifact@v7 + with: + name: init-binaries + path: internal/initrd/embed/ + + - run: go test ./... + + gosec: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + go-version-file: go.mod + + - uses: actions/download-artifact@v7 + with: + name: init-binaries + path: internal/initrd/embed/ + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 + + - name: Run Gosec Security Scanner + run: gosec ./... diff --git a/.gitignore b/.gitignore index cb9760f..f1f2686 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,13 @@ go.work.sum # Build artifacts (wisp Makefile output) build/ -# Embedded init binary (cross-compiled by Makefile, not committed) -cmd/wisp/embed/init-arm64 +# Embedded init binaries (cross-compiled by Makefile, not committed) +internal/initrd/embed/init-arm64 +internal/initrd/embed/init-riscv64 +internal/initrd/embed/init-amd64 + +# Default wisp output directory +wisp-output/ # env file .env @@ -36,3 +41,7 @@ cmd/wisp/embed/init-arm64 # Editor/IDE # .idea/ # .vscode/ + + +# gosec output +results.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3b5d290..1427d99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,24 +43,29 @@ wisp is written in Go. The only external dependency is `golang.org/x/sys/unix` ```text wisp/ ├── cmd/ -│ ├── wisp/ # CLI entrypoint (host tool) -│ │ ├── main.go -│ │ ├── boards/ # Board profile JSONs (embedded via go:embed) -│ │ │ ├── pi5.json -│ │ │ └── qemu.json -│ │ └── embed/ # Pre-built init binary (embedded via go:embed) -│ │ └── init-arm64 # Cross-compiled by Makefile, not committed -│ ├── init/ # Init binary (cross-compiled, runs on target) -│ └── mkinitrd/ # Standalone initrd assembler (Makefile workflow) +│ ├── wisp/ # CLI entrypoint (thin wiring layer) +│ │ └── main.go +│ └── init/ # Init binary (cross-compiled, runs on target) ├── internal/ -│ ├── board/ # Board profile types and loader -│ ├── initrd/ # Initrd assembly (cpio archive creation) +│ ├── board/ # Board profile types, loader, and embedded profiles +│ │ └── boards/ # Board profile JSONs (embedded via go:embed) +│ │ ├── pi5.json +│ │ ├── qemu.json +│ │ └── raspi3b.json +│ ├── initrd/ # Initrd assembly, init binary embedding, build logic +│ │ ├── initrd.go # Low-level cpio archive writer +│ │ ├── initbin.go # Embedded init binaries + arch lookup +│ │ ├── build.go # High-level Build() assembling complete initrd +│ │ └── embed/ # Pre-built init binaries (cross-compiled by Makefile) +│ │ ├── init-arm64 +│ │ ├── init-riscv64 +│ │ └── init-amd64 │ ├── image/ # Disk image assembly (FAT32 boot partition) │ ├── fetch/ # Asset fetching and caching (kernels, firmware) │ └── validate/ # Binary validation (static ELF, correct arch) ├── testdata/ │ └── helloworld/ # Test HTTP server for QEMU integration tests -├── Makefile # Two-step build: init binary → wisp CLI +├── Makefile # Two-step build: init binaries → wisp CLI ├── DESIGN.md # Architecture and design decisions ├── README.md ├── CLAUDE.md # This file @@ -93,8 +98,8 @@ wisp validate --target pi5 --binary ./myservice # Build everything (cross-compile init, build wisp CLI, build test binary) make -# Quick QEMU test using the Makefile workflow (bypasses wisp CLI) -make qemu +# Quick QEMU test +wisp run --target qemu --binary ./build/helloworld --ip 10.0.2.15/24 --gateway 10.0.2.2 # Run all tests go test ./... @@ -105,8 +110,8 @@ make clean The Makefile performs a two-step build: -1. Cross-compile `cmd/init` → `cmd/wisp/embed/init-arm64` -2. Build `cmd/wisp` (which embeds the init binary and board profiles via +1. Cross-compile `cmd/init` → `internal/initrd/embed/init-{arm64,riscv64,amd64}` +2. Build `cmd/wisp` (which embeds the init binaries and board profiles via `go:embed`) ## Key constraints @@ -125,7 +130,7 @@ The Makefile performs a two-step build: ## Board profiles -Board profiles are JSON files in `cmd/wisp/boards/` that define everything +Board profiles are JSON files in `internal/board/boards/` that define everything board-specific: firmware URLs, DTB filename, kernel package source, required modules, network interface name, architecture. Embedded into the wisp binary via `go:embed` and parsed with `board.Parse()`. diff --git a/DESIGN.md b/DESIGN.md index d73de53..67d065d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -192,7 +192,7 @@ adds scope to v1 unnecessarily. ## Target board profiles -Each supported board has a JSON profile in `cmd/wisp/boards/` that defines everything +Each supported board has a JSON profile in `internal/board/boards/` that defines everything board-specific. Profiles are embedded into the wisp binary via `go:embed` and parsed with `board.Parse()`. Adding a new board means adding a new JSON file — no code changes required (ideally). @@ -209,8 +209,9 @@ Fields in a board profile: - **`boot_config`** — Content of `config.txt` written to the boot partition. - **`cmdline`** — Kernel command line written to `cmdline.txt`. - **`modules`** — List of kernel modules with `path` (in APK) and `name` (output filename). +- **`qemu`** — QEMU emulation config: `binary`, `machine`, `cpu`, `memory`, `accel`, `net_dev`, `extra`. Present only for QEMU-bootable targets. -Example (`cmd/wisp/boards/qemu.json`): +Example (`internal/board/boards/qemu.json`): ```json { @@ -229,7 +230,15 @@ Example (`cmd/wisp/boards/qemu.json`): { "path": "kernel/net/core/failover.ko.gz", "name": "failover.ko" }, { "path": "kernel/drivers/net/net_failover.ko.gz", "name": "net_failover.ko" }, { "path": "kernel/drivers/net/virtio_net.ko.gz", "name": "virtio_net.ko" } - ] + ], + "qemu": { + "binary": "qemu-system-aarch64", + "machine": "virt", + "cpu": "host", + "memory": "512M", + "accel": "hvf", + "net_dev": "virtio-net-pci" + } } ``` diff --git a/Makefile b/Makefile index b42225e..6d76702 100644 --- a/Makefile +++ b/Makefile @@ -1,121 +1,43 @@ BUILD_DIR := build -MODULES_DIR := $(BUILD_DIR)/modules -CONF_DIR := $(BUILD_DIR)/conf -EMBED_DIR := cmd/wisp/embed +EMBED_DIR := internal/initrd/embed -# Cross-compilation for aarch64 Linux (used for init and service binaries) -CROSS_ENV := GOOS=linux GOARCH=arm64 CGO_ENABLED=0 +.PHONY: all clean wisp kill -# Alpine kernel for QEMU (v3.23, kernel 6.18 LTS) -KERNEL_PKG := linux-virt-6.18.13-r0.apk -KERNEL_URL := https://dl-cdn.alpinelinux.org/alpine/v3.23/main/aarch64/$(KERNEL_PKG) -KERNEL_VER := 6.18.13-0-virt - -# Kernel modules needed for QEMU virtio networking (load order matters). -# virtio and virtio_pci are built-in; these three are modules. -QEMU_MODULES := \ - lib/modules/$(KERNEL_VER)/kernel/net/core/failover.ko.gz \ - lib/modules/$(KERNEL_VER)/kernel/drivers/net/net_failover.ko.gz \ - lib/modules/$(KERNEL_VER)/kernel/drivers/net/virtio_net.ko.gz - -# QEMU settings -QEMU_PORT := 18080 - -.PHONY: all clean initrd kernel qemu kill wisp - -# Two-step build: cross-compile init binary, then build wisp CLI (which embeds it). +# Two-step build: cross-compile init binaries, then build wisp CLI (which embeds them). all: wisp $(BUILD_DIR)/helloworld -# Step 1: Cross-compile the init binary into the embed directory. +# Step 1: Cross-compile init binaries for all supported architectures. $(EMBED_DIR)/init-arm64: cmd/init/main.go @mkdir -p $(EMBED_DIR) - $(CROSS_ENV) go build -o $@ ./cmd/init/ + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $@ ./cmd/init/ + +$(EMBED_DIR)/init-riscv64: cmd/init/main.go + @mkdir -p $(EMBED_DIR) + GOOS=linux GOARCH=riscv64 CGO_ENABLED=0 go build -o $@ ./cmd/init/ + +$(EMBED_DIR)/init-amd64: cmd/init/main.go + @mkdir -p $(EMBED_DIR) + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $@ ./cmd/init/ -# Step 2: Build the wisp CLI (embeds init binary and board profiles). +INIT_BINARIES := $(EMBED_DIR)/init-arm64 $(EMBED_DIR)/init-riscv64 $(EMBED_DIR)/init-amd64 + +# Step 2: Build the wisp CLI (embeds init binaries and board profiles). wisp: $(BUILD_DIR)/wisp -$(BUILD_DIR)/wisp: $(EMBED_DIR)/init-arm64 cmd/wisp/main.go +$(BUILD_DIR)/wisp: $(INIT_BINARIES) cmd/wisp/main.go @mkdir -p $(BUILD_DIR) go build -o $@ ./cmd/wisp/ -# Cross-compile the init binary to build/ (for manual Makefile-based workflow) -$(BUILD_DIR)/init: cmd/init/main.go - @mkdir -p $(BUILD_DIR) - $(CROSS_ENV) go build -o $@ ./cmd/init/ - # Cross-compile the test helloworld binary for aarch64 $(BUILD_DIR)/helloworld: testdata/helloworld/main.go @mkdir -p $(BUILD_DIR) - $(CROSS_ENV) go build -o $@ ./testdata/helloworld/ - -# Build mkinitrd for the host (not cross-compiled) -$(BUILD_DIR)/mkinitrd: cmd/mkinitrd/main.go internal/initrd/initrd.go - @mkdir -p $(BUILD_DIR) - go build -o $@ ./cmd/mkinitrd/ - -# Download Alpine virt kernel and extract modules (cached) -kernel: $(BUILD_DIR)/vmlinuz -$(BUILD_DIR)/vmlinuz: - @mkdir -p $(BUILD_DIR) - curl -sL -o $(BUILD_DIR)/$(KERNEL_PKG) "$(KERNEL_URL)" - cd $(BUILD_DIR) && tar -xzf $(KERNEL_PKG) boot/vmlinuz-virt $(QEMU_MODULES) 2>/dev/null - mv $(BUILD_DIR)/boot/vmlinuz-virt $@ - @mkdir -p $(MODULES_DIR) - for mod in $(QEMU_MODULES); do \ - gunzip -c $(BUILD_DIR)/$$mod > $(MODULES_DIR)/$$(basename $$mod .gz); \ - done - rm -rf $(BUILD_DIR)/boot $(BUILD_DIR)/lib $(BUILD_DIR)/$(KERNEL_PKG) - @echo "kernel: $@" - @ls -1 $(MODULES_DIR)/ - -# Generate config files for QEMU -$(CONF_DIR)/wisp.conf: - @mkdir -p $(CONF_DIR) - printf 'IFACE=eth0\nADDR=10.0.2.15/24\nGW=10.0.2.2\n' > $@ - -$(CONF_DIR)/resolv.conf: - @mkdir -p $(CONF_DIR) - printf 'nameserver 10.0.2.3\n' > $@ - -$(CONF_DIR)/modules: - @mkdir -p $(CONF_DIR) - printf 'failover.ko\nnet_failover.ko\nvirtio_net.ko\n' > $@ - -# Assemble the initrd using our Go cpio writer (no shelling out to cpio) -initrd: $(BUILD_DIR)/initrd.img -$(BUILD_DIR)/initrd.img: $(BUILD_DIR)/mkinitrd $(BUILD_DIR)/init $(BUILD_DIR)/helloworld $(BUILD_DIR)/vmlinuz $(CONF_DIR)/wisp.conf $(CONF_DIR)/resolv.conf $(CONF_DIR)/modules - $(BUILD_DIR)/mkinitrd \ - -o $@ \ - -init $(BUILD_DIR)/init \ - -service $(BUILD_DIR)/helloworld \ - -conf $(CONF_DIR)/wisp.conf \ - -resolv $(CONF_DIR)/resolv.conf \ - -modules-list $(CONF_DIR)/modules \ - -modules-dir $(MODULES_DIR) - -# Build everything and boot in QEMU (manual Makefile workflow) -qemu: initrd kernel - @echo "Booting QEMU (port forward: localhost:$(QEMU_PORT) -> guest:8080)" - @echo "Test: curl http://localhost:$(QEMU_PORT)/" - @echo "Quit: Ctrl-A X" - @echo "---" - qemu-system-aarch64 \ - -machine virt \ - -accel hvf \ - -cpu host \ - -m 512M \ - -kernel $(BUILD_DIR)/vmlinuz \ - -initrd $(BUILD_DIR)/initrd.img \ - -append "rdinit=/init console=ttyAMA0 net.ifnames=0 quiet" \ - -nographic \ - -netdev user,id=net0,hostfwd=tcp::$(QEMU_PORT)-:8080 \ - -device virtio-net-pci,netdev=net0 + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o $@ ./testdata/helloworld/ -# Kill any running QEMU instances (by name and by port) +# Kill any running QEMU instances kill: @pkill -f qemu-system-aarch64 2>/dev/null || true - @lsof -ti :$(QEMU_PORT) 2>/dev/null | xargs kill -9 2>/dev/null || true + @lsof -ti :18080 2>/dev/null | xargs kill -9 2>/dev/null || true @echo "killed" clean: rm -rf $(BUILD_DIR) - rm -f $(EMBED_DIR)/init-arm64 + rm -f $(EMBED_DIR)/init-arm64 $(EMBED_DIR)/init-riscv64 $(EMBED_DIR)/init-amd64 diff --git a/README.md b/README.md index 9489252..603e280 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,6 @@ make This cross-compiles the init binary for the target architecture, then builds the wisp CLI which embeds it. -Quick QEMU test without the wisp CLI (Makefile workflow): - -```sh -make qemu -``` - Run tests: ```sh diff --git a/cmd/init/main.go b/cmd/init/main.go index fb37a12..2365ea1 100644 --- a/cmd/init/main.go +++ b/cmd/init/main.go @@ -66,7 +66,7 @@ func mustf(err error, msg string) { // readConf reads a key=value config file. Blank lines and lines starting // with # are ignored. No quoting or escaping. func readConf(path string) (map[string]string, error) { - f, err := os.Open(path) + f, err := os.Open(path) //#nosec G304 -- hardcoded path from main(), no user input if err != nil { return nil, err } @@ -95,36 +95,43 @@ func readConf(path string) (map[string]string, error) { // must be listed before dependents. If the file doesn't exist, this is a no-op // (not all boards need modules). func loadModules(listPath string) { - f, err := os.Open(listPath) + f, err := os.Open(listPath) //#nosec G304 -- hardcoded path from main(), no user input if err != nil { return // no modules file — nothing to load } defer f.Close() + modRoot, err := os.OpenRoot("/lib/modules") + if err != nil { + fmt.Fprintf(os.Stderr, "init: open /lib/modules: %v\n", err) //#nosec G705 -- PID 1 stderr, not web output + return + } + defer modRoot.Close() + s := bufio.NewScanner(f) for s.Scan() { name := strings.TrimSpace(s.Text()) if name == "" || name[0] == '#' { continue } - path := "/lib/modules/" + name - if err := loadModule(path); err != nil { - fmt.Fprintf(os.Stderr, "init: load module %s: %v\n", name, err) + if err := loadModule(modRoot, name); err != nil { + fmt.Fprintf(os.Stderr, "init: load module %s: %v\n", name, err) //#nosec G705 -- PID 1 stderr, not web output } } } // loadModule loads a single kernel module using the finit_module(2) syscall. // This takes a file descriptor rather than a memory buffer, avoiding the need -// to read the entire module into memory first. -func loadModule(path string) error { - f, err := os.Open(path) +// to read the entire module into memory first. The root scopes access to +// /lib/modules/ preventing path traversal. +func loadModule(root *os.Root, name string) error { + f, err := root.Open(name) if err != nil { return err } defer f.Close() - return unix.FinitModule(int(f.Fd()), "", 0) + return unix.FinitModule(int(f.Fd()), "", 0) //#nosec G115 -- fd is a small non-negative int, fits in int } // --- Netlink helpers --- @@ -161,7 +168,7 @@ func waitForIface(name string, timeout time.Duration) error { // ifIndex reads the kernel interface index from sysfs. func ifIndex(name string) (int32, error) { - data, err := os.ReadFile("/sys/class/net/" + name + "/ifindex") + data, err := os.ReadFile("/sys/class/net/" + name + "/ifindex") //#nosec G304 -- sysfs path, name from embedded board config if err != nil { return 0, err } @@ -202,9 +209,9 @@ func addrAdd(name, cidr string) error { payload := nlSerialize(&unix.IfAddrmsg{ Family: unix.AF_INET, - Prefixlen: uint8(prefixLen), + Prefixlen: uint8(prefixLen), //#nosec G115 -- prefix length is 0-32 Scope: unix.RT_SCOPE_UNIVERSE, - Index: uint32(idx), + Index: uint32(idx), //#nosec G115 -- interface index from kernel, always positive }) payload = append(payload, nlattr(unix.IFA_LOCAL, ip)...) payload = append(payload, nlattr(unix.IFA_ADDRESS, ip)...) @@ -237,7 +244,7 @@ func routeAddGw(name, gw string) error { payload = append(payload, nlattr(unix.RTA_GATEWAY, []byte(gwIP))...) oif := make([]byte, 4) - binary.NativeEndian.PutUint32(oif, uint32(idx)) + binary.NativeEndian.PutUint32(oif, uint32(idx)) //#nosec G115 -- interface index from kernel, always positive payload = append(payload, nlattr(unix.RTA_OIF, oif)...) msg := nlmsg(unix.RTM_NEWROUTE, @@ -251,7 +258,7 @@ func routeAddGw(name, gw string) error { // nlSerialize encodes a struct to bytes using the native byte order. func nlSerialize(v any) []byte { var buf bytes.Buffer - binary.Write(&buf, binary.NativeEndian, v) + _ = binary.Write(&buf, binary.NativeEndian, v) return buf.Bytes() } @@ -259,7 +266,7 @@ func nlSerialize(v any) []byte { func nlmsg(typ uint16, flags uint16, payload []byte) []byte { total := nlmAlign(unix.SizeofNlMsghdr + len(payload)) hdr := unix.NlMsghdr{ - Len: uint32(total), + Len: uint32(total), //#nosec G115 -- netlink message, well under uint32 max Type: typ, Flags: flags, Seq: 1, @@ -279,7 +286,7 @@ func nlattr(typ uint16, data []byte) []byte { buf := make([]byte, total) copy(buf, nlSerialize(&unix.RtAttr{ - Len: uint16(attrLen), + Len: uint16(attrLen), //#nosec G115 -- netlink attribute, well under uint16 max Type: typ, })) copy(buf[unix.SizeofRtAttr:], data) @@ -313,15 +320,15 @@ func nlsend(msg []byte) error { return fmt.Errorf("netlink response too short: %d bytes", n) } var hdr unix.NlMsghdr - binary.Read(bytes.NewReader(buf[:unix.SizeofNlMsghdr]), binary.NativeEndian, &hdr) + _ = binary.Read(bytes.NewReader(buf[:unix.SizeofNlMsghdr]), binary.NativeEndian, &hdr) if hdr.Type == unix.NLMSG_ERROR { // The error payload is a 4-byte errno (negative on failure, 0 on success). errOff := unix.SizeofNlMsghdr if n >= errOff+4 { - errno := int32(binary.NativeEndian.Uint32(buf[errOff : errOff+4])) + errno := int32(binary.NativeEndian.Uint32(buf[errOff : errOff+4])) //#nosec G115 -- kernel errno, valid range if errno != 0 { - return unix.Errno(-errno) + return unix.Errno(-errno) //#nosec G115 -- negated errno to positive syscall.Errno } } } diff --git a/cmd/mkinitrd/main.go b/cmd/mkinitrd/main.go deleted file mode 100644 index 6bf6f83..0000000 --- a/cmd/mkinitrd/main.go +++ /dev/null @@ -1,128 +0,0 @@ -// mkinitrd assembles a wisp initrd image using the internal/initrd package. -// It reads file paths from flags, builds the cpio archive in pure Go, and -// writes the gzipped result to stdout or a file. -// -// Usage: -// -// mkinitrd -o initrd.img \ -// -init ./build/init \ -// -service ./build/helloworld \ -// -conf ./build/initrd/etc/wisp.conf \ -// -resolv ./build/initrd/etc/resolv.conf \ -// -modules-list ./build/initrd/etc/modules \ -// -modules-dir ./build/modules -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - - "github.com/funcimp/wisp/internal/initrd" -) - -func main() { - var ( - output = flag.String("o", "", "output file (required)") - initBin = flag.String("init", "", "path to init binary (required)") - serviceBin = flag.String("service", "", "path to service binary (required)") - confFile = flag.String("conf", "", "path to wisp.conf (required)") - resolvFile = flag.String("resolv", "", "path to resolv.conf (required)") - modulesList = flag.String("modules-list", "", "path to modules list file (optional)") - modulesDir = flag.String("modules-dir", "", "directory containing .ko files (optional)") - ) - flag.Parse() - - if *output == "" || *initBin == "" || *serviceBin == "" || *confFile == "" || *resolvFile == "" { - flag.Usage() - os.Exit(1) - } - - entries, err := buildEntries(*initBin, *serviceBin, *confFile, *resolvFile, *modulesList, *modulesDir) - if err != nil { - fmt.Fprintf(os.Stderr, "mkinitrd: %v\n", err) - os.Exit(1) - } - - f, err := os.Create(*output) - if err != nil { - fmt.Fprintf(os.Stderr, "mkinitrd: %v\n", err) - os.Exit(1) - } - defer f.Close() - - if err := initrd.Write(f, entries); err != nil { - os.Remove(*output) - fmt.Fprintf(os.Stderr, "mkinitrd: %v\n", err) - os.Exit(1) - } - - fmt.Fprintf(os.Stderr, "mkinitrd: wrote %s\n", *output) -} - -func buildEntries(initBin, serviceBin, confFile, resolvFile, modulesList, modulesDir string) ([]initrd.Entry, error) { - var entries []initrd.Entry - - // Empty mount-point directories. - for _, dir := range []string{"dev", "proc", "sys"} { - entries = append(entries, initrd.Entry{Path: dir, Mode: os.ModeDir | 0755}) - } - - // Init binary. - data, err := os.ReadFile(initBin) - if err != nil { - return nil, fmt.Errorf("read init: %w", err) - } - entries = append(entries, initrd.Entry{Path: "init", Data: data, Mode: 0755}) - - // Service binary. - data, err = os.ReadFile(serviceBin) - if err != nil { - return nil, fmt.Errorf("read service: %w", err) - } - entries = append(entries, initrd.Entry{Path: "service/run", Data: data, Mode: 0755}) - - // Config files. - data, err = os.ReadFile(confFile) - if err != nil { - return nil, fmt.Errorf("read conf: %w", err) - } - entries = append(entries, initrd.Entry{Path: "etc/wisp.conf", Data: data, Mode: 0644}) - - data, err = os.ReadFile(resolvFile) - if err != nil { - return nil, fmt.Errorf("read resolv: %w", err) - } - entries = append(entries, initrd.Entry{Path: "etc/resolv.conf", Data: data, Mode: 0644}) - - // Module list (optional). - if modulesList != "" { - data, err = os.ReadFile(modulesList) - if err != nil { - return nil, fmt.Errorf("read modules list: %w", err) - } - entries = append(entries, initrd.Entry{Path: "etc/modules", Data: data, Mode: 0644}) - } - - // Kernel modules (optional). - if modulesDir != "" { - mods, err := filepath.Glob(filepath.Join(modulesDir, "*.ko")) - if err != nil { - return nil, fmt.Errorf("glob modules: %w", err) - } - for _, mod := range mods { - data, err = os.ReadFile(mod) - if err != nil { - return nil, fmt.Errorf("read module %s: %w", mod, err) - } - entries = append(entries, initrd.Entry{ - Path: "lib/modules/" + filepath.Base(mod), - Data: data, - Mode: 0644, - }) - } - } - - return entries, nil -} diff --git a/cmd/wisp/main.go b/cmd/wisp/main.go index c475f04..83ca283 100644 --- a/cmd/wisp/main.go +++ b/cmd/wisp/main.go @@ -10,14 +10,12 @@ package main import ( - "bytes" "debug/elf" - "embed" "encoding/json" "flag" "fmt" + "io" "os" - "os/exec" "path/filepath" "strings" @@ -28,12 +26,6 @@ import ( "github.com/funcimp/wisp/internal/validate" ) -//go:embed embed/init-arm64 -var initBinary []byte - -//go:embed boards -var boardsFS embed.FS - func main() { if len(os.Args) < 2 { usage() @@ -59,7 +51,7 @@ func main() { os.Exit(1) } default: - fmt.Fprintf(os.Stderr, "wisp: unknown command %q\n", os.Args[1]) + fmt.Fprintf(os.Stderr, "wisp: unknown command %q\n", os.Args[1]) //#nosec G705 -- CLI stderr output, %q escapes special characters usage() os.Exit(1) } @@ -109,7 +101,7 @@ func parseFlags(args []string) (*config, error) { // If config file specified, load it. if configFile != "" { - data, err := os.ReadFile(configFile) + data, err := os.ReadFile(configFile) //#nosec G304 -- user-provided config file path if err != nil { return nil, fmt.Errorf("read config file: %w", err) } @@ -138,20 +130,15 @@ func parseFlags(args []string) (*config, error) { return &cfg, nil } -// loadBoard loads a board profile from the embedded filesystem. -func loadBoard(name string) (*board.Board, error) { - data, err := boardsFS.ReadFile("boards/" + name + ".json") - if err != nil { - return nil, fmt.Errorf("unknown target %q", name) - } - return board.Parse(data) -} - // archToELF maps board architecture strings to ELF machine types. func archToELF(arch string) elf.Machine { switch arch { case "aarch64": return elf.EM_AARCH64 + case "riscv64": + return elf.EM_RISCV + case "x86_64": + return elf.EM_X86_64 default: return elf.EM_NONE } @@ -163,7 +150,7 @@ func cmdBuild(args []string) error { return err } - b, err := loadBoard(cfg.Target) + b, err := board.Get(cfg.Target) if err != nil { return err } @@ -173,13 +160,13 @@ func cmdBuild(args []string) error { func build(cfg *config, b *board.Board) error { // Step 1: Validate binary. - fmt.Fprintf(os.Stderr, "validating binary %s\n", cfg.Binary) + fmt.Fprintf(os.Stderr, "validating binary %s\n", cfg.Binary) //#nosec G705 -- CLI stderr output if err := validate.Binary(cfg.Binary, archToELF(b.Arch), b.PageSize); err != nil { return fmt.Errorf("validate binary: %w", err) } // Step 2: Fetch kernel and modules. - fmt.Fprintf(os.Stderr, "fetching kernel (%s %s)\n", b.Kernel.Package, b.Kernel.Version) + fmt.Fprintf(os.Stderr, "fetching kernel (%s %s)\n", b.Kernel.Package, b.Kernel.Version) //#nosec G705 -- CLI stderr output kr, err := fetch.Kernel(b) if err != nil { return fmt.Errorf("fetch kernel: %w", err) @@ -197,107 +184,95 @@ func build(cfg *config, b *board.Board) error { // Step 4: Build initrd. fmt.Fprintf(os.Stderr, "building initrd\n") - initrdData, err := buildInitrd(cfg, b, kr) + net := initrd.NetworkConfig{ + Interface: b.NetworkInterface, + Address: cfg.IP, + Gateway: cfg.Gateway, + DNS: cfg.DNS, + } + var modules []initrd.KernelModule + for _, mp := range kr.ModulePaths { + modules = append(modules, initrd.KernelModule{HostPath: mp}) + } + initrdData, err := initrd.Build(b.Arch, cfg.Binary, net, modules) if err != nil { return fmt.Errorf("build initrd: %w", err) } - // Step 5: Create output. - if err := os.MkdirAll(cfg.Output, 0755); err != nil { + // Step 5: Create output directory scoped to the working directory. + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("working directory: %w", err) + } + wdRoot, err := os.OpenRoot(wd) + if err != nil { + return fmt.Errorf("open working directory: %w", err) + } + defer wdRoot.Close() + + if err := wdRoot.MkdirAll(cfg.Output, 0750); err != nil { return fmt.Errorf("create output dir: %w", err) } + outRoot, err := wdRoot.OpenRoot(cfg.Output) + if err != nil { + return fmt.Errorf("open output dir: %w", err) + } + defer outRoot.Close() if b.IsQEMU() { - return outputQEMU(cfg, kr, initrdData) + return outputQEMU(outRoot, cfg.Output, kr, initrdData) } - return outputImage(cfg, b, kr, fwPaths, initrdData) + return outputImage(outRoot, cfg.Output, b, kr, fwPaths, initrdData) } -// buildInitrd assembles the initrd cpio archive. -func buildInitrd(cfg *config, b *board.Board, kr *fetch.KernelResult) ([]byte, error) { - var entries []initrd.Entry - - // Mount-point directories. - for _, dir := range []string{"dev", "proc", "sys"} { - entries = append(entries, initrd.Entry{Path: dir, Mode: os.ModeDir | 0755}) +// readCacheFile reads a file from the wisp cache directory, using os.Root +// to scope access and prevent directory traversal. +func readCacheFile(absPath string) ([]byte, error) { + cacheDir, err := fetch.CacheDir() + if err != nil { + return nil, err } - - // Init binary (embedded in wisp). - entries = append(entries, initrd.Entry{Path: "init", Data: initBinary, Mode: 0755}) - - // Service binary. - serviceData, err := os.ReadFile(cfg.Binary) + root, err := os.OpenRoot(cacheDir) if err != nil { - return nil, fmt.Errorf("read binary: %w", err) - } - entries = append(entries, initrd.Entry{Path: "service/run", Data: serviceData, Mode: 0755}) - - // Network config. - iface := b.NetworkInterface - wispConf := fmt.Sprintf("IFACE=%s\nADDR=%s\nGW=%s\n", iface, cfg.IP, cfg.Gateway) - entries = append(entries, initrd.Entry{Path: "etc/wisp.conf", Data: []byte(wispConf), Mode: 0644}) - - // DNS config. - resolvConf := fmt.Sprintf("nameserver %s\n", cfg.DNS) - entries = append(entries, initrd.Entry{Path: "etc/resolv.conf", Data: []byte(resolvConf), Mode: 0644}) - - // Module list and module files. - if len(kr.ModulePaths) > 0 { - var moduleNames []string - for _, mp := range kr.ModulePaths { - name := filepath.Base(mp) - moduleNames = append(moduleNames, name) - - data, err := os.ReadFile(mp) - if err != nil { - return nil, fmt.Errorf("read module %s: %w", name, err) - } - entries = append(entries, initrd.Entry{ - Path: "lib/modules/" + name, - Data: data, - Mode: 0644, - }) - } - modulesList := strings.Join(moduleNames, "\n") + "\n" - entries = append(entries, initrd.Entry{Path: "etc/modules", Data: []byte(modulesList), Mode: 0644}) + return nil, err } + defer root.Close() - // Write to buffer. - var buf bytes.Buffer - if err := initrd.Write(&buf, entries); err != nil { + rel, err := filepath.Rel(cacheDir, absPath) + if err != nil { + return nil, fmt.Errorf("path not under cache: %w", err) + } + f, err := root.Open(rel) + if err != nil { return nil, err } - return buf.Bytes(), nil + defer f.Close() + return io.ReadAll(f) } // outputQEMU writes kernel and initrd to the output directory. -func outputQEMU(cfg *config, kr *fetch.KernelResult, initrdData []byte) error { - // Copy kernel. - kernelData, err := os.ReadFile(kr.KernelPath) +func outputQEMU(root *os.Root, outDir string, kr *fetch.KernelResult, initrdData []byte) error { + kernelData, err := readCacheFile(kr.KernelPath) if err != nil { return fmt.Errorf("read kernel: %w", err) } - kernelPath := filepath.Join(cfg.Output, "vmlinuz") - if err := os.WriteFile(kernelPath, kernelData, 0644); err != nil { + if err := root.WriteFile("vmlinuz", kernelData, 0600); err != nil { return err } - - // Write initrd. - initrdPath := filepath.Join(cfg.Output, "initrd.img") - if err := os.WriteFile(initrdPath, initrdData, 0644); err != nil { + if err := root.WriteFile("initrd.img", initrdData, 0600); err != nil { return err } - fmt.Fprintf(os.Stderr, "output: %s/vmlinuz, %s/initrd.img\n", cfg.Output, cfg.Output) + fmt.Fprintf(os.Stderr, "output: %s/vmlinuz, %s/initrd.img\n", outDir, outDir) //#nosec G705 -- CLI stderr output return nil } // outputImage builds a FAT32 disk image for hardware targets. -func outputImage(cfg *config, b *board.Board, kr *fetch.KernelResult, fwPaths map[string]string, initrdData []byte) error { +func outputImage(root *os.Root, outDir string, b *board.Board, kr *fetch.KernelResult, fwPaths map[string]string, initrdData []byte) error { var files []image.File // Kernel image. - kernelData, err := os.ReadFile(kr.KernelPath) + kernelData, err := readCacheFile(kr.KernelPath) if err != nil { return fmt.Errorf("read kernel: %w", err) } @@ -308,7 +283,7 @@ func outputImage(cfg *config, b *board.Board, kr *fetch.KernelResult, fwPaths ma // Firmware files. for dest, localPath := range fwPaths { - data, err := os.ReadFile(localPath) + data, err := readCacheFile(localPath) if err != nil { return fmt.Errorf("read firmware %s: %w", dest, err) } @@ -329,12 +304,16 @@ func outputImage(cfg *config, b *board.Board, kr *fetch.KernelResult, fwPaths ma files = append(files, image.File{Name: "cmdline.txt", Data: []byte(b.Cmdline + "\n")}) } - imgPath := filepath.Join(cfg.Output, b.Name+".img") - if err := image.Build(imgPath, files); err != nil { + imgName := b.Name + ".img" + imgData, err := image.Build(files) + if err != nil { return fmt.Errorf("build image: %w", err) } + if err := root.WriteFile(imgName, imgData, 0600); err != nil { + return fmt.Errorf("write image: %w", err) + } - fmt.Fprintf(os.Stderr, "output: %s\n", imgPath) + fmt.Fprintf(os.Stderr, "output: %s\n", filepath.Join(outDir, imgName)) //#nosec G705 -- CLI stderr output return nil } @@ -344,7 +323,7 @@ func cmdRun(args []string) error { return err } - b, err := loadBoard(cfg.Target) + b, err := board.Get(cfg.Target) if err != nil { return err } @@ -358,56 +337,56 @@ func cmdRun(args []string) error { return err } - // Launch QEMU. + // Print the QEMU command for the user to execute. kernelPath := filepath.Join(cfg.Output, "vmlinuz") initrdPath := filepath.Join(cfg.Output, "initrd.img") - port := "18080" - fmt.Fprintf(os.Stderr, "booting QEMU (port forward: localhost:%s -> guest:8080)\n", port) - fmt.Fprintf(os.Stderr, "test: curl http://localhost:%s/\n", port) - fmt.Fprintf(os.Stderr, "quit: Ctrl-A X\n") - fmt.Fprintf(os.Stderr, "---\n") - - cmd := exec.Command("qemu-system-aarch64", - "-machine", "virt", - "-accel", "hvf", - "-cpu", "host", - "-m", "512M", + + fmt.Println(qemuCommand(b, kernelPath, initrdPath, port)) + return nil +} + +// qemuCommand builds a copy-pasteable QEMU command line from a board's +// QEMU configuration. +func qemuCommand(b *board.Board, kernelPath, initrdPath, port string) string { + q := b.QEMU + args := []string{ + q.Binary, + "-machine", q.Machine, + "-m", q.Memory, "-kernel", kernelPath, "-initrd", initrdPath, - "-append", b.Cmdline, + "-append", shellQuote(b.Cmdline), "-nographic", - "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%s-:8080", port), - "-device", "virtio-net-pci,netdev=net0", - ) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() + } + if q.CPU != "" { + args = append(args, "-cpu", q.CPU) + } + if q.Accel != "" { + args = append(args, "-accel", q.Accel) + } + if q.NetDev != "" { + args = append(args, "-netdev", fmt.Sprintf("user,id=net0,hostfwd=tcp::%s-:8080", port)) + args = append(args, "-device", q.NetDev+",netdev=net0") + } + args = append(args, q.Extra...) + return strings.Join(args, " \\\n ") +} + +// shellQuote wraps s in single quotes for safe shell use. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } func cmdTargets() { - entries, err := boardsFS.ReadDir("boards") + boards, err := board.List() if err != nil { fmt.Fprintf(os.Stderr, "wisp targets: %v\n", err) os.Exit(1) } - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { - continue - } - name := strings.TrimSuffix(e.Name(), ".json") - data, err := boardsFS.ReadFile("boards/" + e.Name()) - if err != nil { - continue - } - b, err := board.Parse(data) - if err != nil { - continue - } - label := name + for _, b := range boards { + label := b.Name if b.IsQEMU() { label += " (QEMU)" } @@ -435,7 +414,7 @@ func cmdValidate(args []string) error { return fmt.Errorf("--binary is required") } - b, err := loadBoard(target) + b, err := board.Get(target) if err != nil { return err } diff --git a/cspell.json b/cspell.json index e916c87..e6eb432 100644 --- a/cspell.json +++ b/cspell.json @@ -21,7 +21,6 @@ "initrd", "GOARCH", "mkdosfs", - "mkinitrd", "testdata", "newc", "procfs", diff --git a/internal/board/board.go b/internal/board/board.go index 724930e..d1e85d4 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -4,23 +4,40 @@ package board import ( + "embed" "encoding/json" "fmt" "os" + "strings" ) +//go:embed boards +var boardsFS embed.FS + // Board defines a target board's hardware profile. type Board struct { - Name string `json:"name"` - Arch string `json:"arch"` - PageSize uint64 `json:"page_size"` - NetworkInterface string `json:"network_interface"` - Kernel Kernel `json:"kernel"` - Firmware []Asset `json:"firmware,omitempty"` - DTB string `json:"dtb,omitempty"` - BootConfig string `json:"boot_config,omitempty"` - Cmdline string `json:"cmdline"` - Modules []Module `json:"modules,omitempty"` + Name string `json:"name"` + Arch string `json:"arch"` + PageSize uint64 `json:"page_size"` + NetworkInterface string `json:"network_interface"` + Kernel Kernel `json:"kernel"` + Firmware []Asset `json:"firmware,omitempty"` + DTB string `json:"dtb,omitempty"` + BootConfig string `json:"boot_config,omitempty"` + Cmdline string `json:"cmdline"` + Modules []Module `json:"modules,omitempty"` + QEMU *QEMUConfig `json:"qemu,omitempty"` +} + +// QEMUConfig defines QEMU emulation parameters for a board target. +type QEMUConfig struct { + Binary string `json:"binary"` + Machine string `json:"machine"` + CPU string `json:"cpu,omitempty"` + Memory string `json:"memory"` + Accel string `json:"accel,omitempty"` + NetDev string `json:"net_dev,omitempty"` + Extra []string `json:"extra,omitempty"` } // Kernel describes where to fetch the kernel image. @@ -61,17 +78,52 @@ func Parse(data []byte) (*Board, error) { return &b, nil } -// Load reads a board profile from a JSON file. +// Load reads a board profile from a JSON file on disk. func Load(path string) (*Board, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //#nosec G304 -- user-provided board profile path if err != nil { return nil, fmt.Errorf("read board profile: %w", err) } return Parse(data) } -// IsQEMU returns true if this board runs under QEMU (no firmware, no DTB, -// no SD card image — just kernel + initrd). +// Get loads a built-in board profile by name from the embedded board +// definitions. +func Get(name string) (*Board, error) { + data, err := boardsFS.ReadFile("boards/" + name + ".json") + if err != nil { + return nil, fmt.Errorf("unknown target %q", name) + } + return Parse(data) +} + +// List returns all built-in board profiles, sorted by embedded directory +// order. +func List() ([]*Board, error) { + entries, err := boardsFS.ReadDir("boards") + if err != nil { + return nil, fmt.Errorf("read embedded boards: %w", err) + } + + var boards []*Board + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + data, err := boardsFS.ReadFile("boards/" + e.Name()) + if err != nil { + continue + } + b, err := Parse(data) + if err != nil { + continue + } + boards = append(boards, b) + } + return boards, nil +} + +// IsQEMU returns true if this board has QEMU emulation configuration. func (b *Board) IsQEMU() bool { - return len(b.Firmware) == 0 && b.DTB == "" && b.BootConfig == "" + return b.QEMU != nil } diff --git a/internal/board/board_test.go b/internal/board/board_test.go new file mode 100644 index 0000000..3506cdf --- /dev/null +++ b/internal/board/board_test.go @@ -0,0 +1,256 @@ +package board + +import ( + "testing" +) + +func TestIsQEMU(t *testing.T) { + tests := []struct { + name string + qemu *QEMUConfig + want bool + }{ + { + name: "nil QEMU config", + qemu: nil, + want: false, + }, + { + name: "with QEMU config", + qemu: &QEMUConfig{Binary: "qemu-system-aarch64", Machine: "virt", Memory: "512M"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &Board{QEMU: tt.qemu} + if got := b.IsQEMU(); got != tt.want { + t.Errorf("IsQEMU() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + check func(t *testing.T, b *Board) + }{ + { + name: "missing name", + input: `{"arch": "aarch64"}`, + wantErr: true, + }, + { + name: "missing arch", + input: `{"name": "test"}`, + wantErr: true, + }, + { + name: "minimal board without QEMU", + input: `{"name": "test", "arch": "aarch64"}`, + check: func(t *testing.T, b *Board) { + if b.QEMU != nil { + t.Error("expected QEMU to be nil") + } + if b.IsQEMU() { + t.Error("expected IsQEMU() to be false") + } + }, + }, + { + name: "board with QEMU config", + input: `{ + "name": "qemu", + "arch": "aarch64", + "qemu": { + "binary": "qemu-system-aarch64", + "machine": "virt", + "cpu": "host", + "memory": "512M", + "accel": "hvf", + "net_dev": "virtio-net-pci" + } + }`, + check: func(t *testing.T, b *Board) { + if b.QEMU == nil { + t.Fatal("expected QEMU to be non-nil") + } + if !b.IsQEMU() { + t.Error("expected IsQEMU() to be true") + } + if b.QEMU.Binary != "qemu-system-aarch64" { + t.Errorf("Binary = %q, want %q", b.QEMU.Binary, "qemu-system-aarch64") + } + if b.QEMU.Machine != "virt" { + t.Errorf("Machine = %q, want %q", b.QEMU.Machine, "virt") + } + if b.QEMU.CPU != "host" { + t.Errorf("CPU = %q, want %q", b.QEMU.CPU, "host") + } + if b.QEMU.Memory != "512M" { + t.Errorf("Memory = %q, want %q", b.QEMU.Memory, "512M") + } + if b.QEMU.Accel != "hvf" { + t.Errorf("Accel = %q, want %q", b.QEMU.Accel, "hvf") + } + if b.QEMU.NetDev != "virtio-net-pci" { + t.Errorf("NetDev = %q, want %q", b.QEMU.NetDev, "virtio-net-pci") + } + }, + }, + { + name: "board with QEMU extra args", + input: `{ + "name": "raspi3b", + "arch": "aarch64", + "qemu": { + "binary": "qemu-system-aarch64", + "machine": "raspi3b", + "cpu": "cortex-a53", + "memory": "1G", + "accel": "tcg", + "net_dev": "usb-net", + "extra": ["-usb", "-device", "usb-hub,bus=usb-bus.0"] + } + }`, + check: func(t *testing.T, b *Board) { + if b.QEMU == nil { + t.Fatal("expected QEMU to be non-nil") + } + if len(b.QEMU.Extra) != 3 { + t.Fatalf("Extra length = %d, want 3", len(b.QEMU.Extra)) + } + wantExtra := []string{"-usb", "-device", "usb-hub,bus=usb-bus.0"} + for i, want := range wantExtra { + if b.QEMU.Extra[i] != want { + t.Errorf("Extra[%d] = %q, want %q", i, b.QEMU.Extra[i], want) + } + } + }, + }, + { + name: "invalid JSON", + input: `{not json}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Parse([]byte(tt.input)) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, b) + } + }) + } +} + +func TestGet(t *testing.T) { + tests := []struct { + name string + target string + wantErr bool + check func(t *testing.T, b *Board) + }{ + { + name: "qemu target", + target: "qemu", + check: func(t *testing.T, b *Board) { + if b.Name != "qemu" { + t.Errorf("Name = %q, want %q", b.Name, "qemu") + } + if !b.IsQEMU() { + t.Error("expected IsQEMU() to be true") + } + if b.QEMU.Machine != "virt" { + t.Errorf("Machine = %q, want %q", b.QEMU.Machine, "virt") + } + }, + }, + { + name: "raspi3b target", + target: "raspi3b", + check: func(t *testing.T, b *Board) { + if b.Name != "raspi3b" { + t.Errorf("Name = %q, want %q", b.Name, "raspi3b") + } + if !b.IsQEMU() { + t.Error("expected IsQEMU() to be true") + } + if b.QEMU.Machine != "raspi3b" { + t.Errorf("Machine = %q, want %q", b.QEMU.Machine, "raspi3b") + } + }, + }, + { + name: "pi5 target", + target: "pi5", + check: func(t *testing.T, b *Board) { + if b.Name != "pi5" { + t.Errorf("Name = %q, want %q", b.Name, "pi5") + } + if b.IsQEMU() { + t.Error("expected IsQEMU() to be false") + } + }, + }, + { + name: "unknown target", + target: "nonexistent", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Get(tt.target) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, b) + } + }) + } +} + +func TestList(t *testing.T) { + boards, err := List() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(boards) < 3 { + t.Fatalf("expected at least 3 boards, got %d", len(boards)) + } + + names := make(map[string]bool) + for _, b := range boards { + names[b.Name] = true + } + + for _, want := range []string{"pi5", "qemu", "raspi3b"} { + if !names[want] { + t.Errorf("expected board %q in list", want) + } + } +} diff --git a/cmd/wisp/boards/pi5.json b/internal/board/boards/pi5.json similarity index 100% rename from cmd/wisp/boards/pi5.json rename to internal/board/boards/pi5.json diff --git a/cmd/wisp/boards/qemu.json b/internal/board/boards/qemu.json similarity index 76% rename from cmd/wisp/boards/qemu.json rename to internal/board/boards/qemu.json index 7be0bea..8e02dd1 100644 --- a/cmd/wisp/boards/qemu.json +++ b/internal/board/boards/qemu.json @@ -23,5 +23,13 @@ "path": "kernel/drivers/net/virtio_net.ko.gz", "name": "virtio_net.ko" } - ] + ], + "qemu": { + "binary": "qemu-system-aarch64", + "machine": "virt", + "cpu": "host", + "memory": "512M", + "accel": "hvf", + "net_dev": "virtio-net-pci" + } } diff --git a/internal/board/boards/raspi3b.json b/internal/board/boards/raspi3b.json new file mode 100644 index 0000000..f1e4c85 --- /dev/null +++ b/internal/board/boards/raspi3b.json @@ -0,0 +1,36 @@ +{ + "name": "raspi3b", + "arch": "aarch64", + "page_size": 4096, + "network_interface": "eth0", + "kernel": { + "source": "alpine", + "package": "linux-rpi", + "version": "6.6.31-r0", + "sha256": "" + }, + "cmdline": "rdinit=/init console=ttyAMA0 net.ifnames=0 quiet", + "modules": [ + { + "path": "kernel/drivers/usb/dwc2/dwc2.ko.gz", + "name": "dwc2.ko" + }, + { + "path": "kernel/drivers/net/usb/usbnet.ko.gz", + "name": "usbnet.ko" + }, + { + "path": "kernel/drivers/net/usb/smsc95xx.ko.gz", + "name": "smsc95xx.ko" + } + ], + "qemu": { + "binary": "qemu-system-aarch64", + "machine": "raspi3b", + "cpu": "cortex-a53", + "memory": "1G", + "accel": "tcg", + "net_dev": "usb-net", + "extra": ["-usb", "-device", "usb-hub,bus=usb-bus.0"] + } +} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index d5ab44a..c831ae7 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -1,6 +1,9 @@ // Package fetch downloads and caches board assets (kernel packages, firmware) // with optional SHA256 verification. Assets are cached in ~/.cache/wisp/ and // reused across builds when the version and checksum match. +// +// All file operations within the cache directory use os.Root to scope access +// and prevent directory traversal. package fetch import ( @@ -28,22 +31,39 @@ func CacheDir() (string, error) { return filepath.Join(home, ".cache", "wisp"), nil } -// Download fetches a URL to destPath. If wantSHA256 is non-empty, the download -// is verified against the expected checksum. If destPath already exists with the -// correct checksum, the download is skipped. -func Download(url, destPath, wantSHA256 string) error { +// openCacheRoot creates the cache directory if needed and returns an os.Root +// scoped to it, along with the absolute path for constructing return values. +func openCacheRoot() (*os.Root, string, error) { + cacheDir, err := CacheDir() + if err != nil { + return nil, "", err + } + if err := os.MkdirAll(cacheDir, 0750); err != nil { + return nil, "", fmt.Errorf("create cache dir: %w", err) + } + root, err := os.OpenRoot(cacheDir) + if err != nil { + return nil, "", fmt.Errorf("open cache root: %w", err) + } + return root, cacheDir, nil +} + +// download fetches a URL to relPath within root. If wantSHA256 is non-empty, +// the download is verified against the expected checksum. If the file already +// exists with the correct checksum, the download is skipped. +func download(root *os.Root, url, relPath, wantSHA256 string) error { // Check if cached file already matches. if wantSHA256 != "" { - if ok, _ := checksum(destPath, wantSHA256); ok { + if ok, _ := checksumFile(root, relPath, wantSHA256); ok { return nil } } - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + if err := root.MkdirAll(filepath.Dir(relPath), 0750); err != nil { return fmt.Errorf("create dir: %w", err) } - resp, err := http.Get(url) + resp, err := http.Get(url) //#nosec G107 -- URLs come from embedded board profiles, not user input if err != nil { return fmt.Errorf("download %s: %w", url, err) } @@ -53,7 +73,7 @@ func Download(url, destPath, wantSHA256 string) error { return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) } - f, err := os.Create(destPath) + f, err := root.Create(relPath) if err != nil { return err } @@ -62,20 +82,20 @@ func Download(url, destPath, wantSHA256 string) error { w := io.MultiWriter(f, h) if _, err := io.Copy(w, resp.Body); err != nil { - f.Close() - os.Remove(destPath) + _ = f.Close() + _ = root.Remove(relPath) return fmt.Errorf("download %s: %w", url, err) } if err := f.Close(); err != nil { - os.Remove(destPath) + _ = root.Remove(relPath) return err } if wantSHA256 != "" { got := hex.EncodeToString(h.Sum(nil)) if got != wantSHA256 { - os.Remove(destPath) + _ = root.Remove(relPath) return fmt.Errorf("checksum mismatch for %s: got %s, want %s", url, got, wantSHA256) } } @@ -83,12 +103,11 @@ func Download(url, destPath, wantSHA256 string) error { return nil } -// ExtractAPK extracts specific files from an Alpine APK package (tar.gz). -// The paths map keys are tar entry paths to match (e.g., -// "lib/modules/6.18.13-0-virt/kernel/net/core/failover.ko.gz") and values are -// destination file paths. Matched files are written to their destinations. -func ExtractAPK(apkPath string, paths map[string]string) error { - f, err := os.Open(apkPath) +// extractAPK extracts specific files from an Alpine APK package (tar.gz). +// apkRelPath is the APK location relative to root. The paths map keys are tar +// entry paths to match and values are destination paths relative to root. +func extractAPK(root *os.Root, apkRelPath string, paths map[string]string) error { + f, err := root.Open(apkRelPath) if err != nil { return fmt.Errorf("open apk: %w", err) } @@ -117,17 +136,17 @@ func ExtractAPK(apkPath string, paths map[string]string) error { continue } - if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + if err := root.MkdirAll(filepath.Dir(dest), 0750); err != nil { return fmt.Errorf("create dir for %s: %w", dest, err) } - out, err := os.Create(dest) + out, err := root.Create(dest) if err != nil { return fmt.Errorf("create %s: %w", dest, err) } - if _, err := io.Copy(out, tr); err != nil { - out.Close() + if _, err := io.Copy(out, tr); err != nil { //#nosec G110 -- decompressing SHA256-verified Alpine packages + _ = out.Close() return fmt.Errorf("extract %s: %w", hdr.Name, err) } @@ -144,7 +163,7 @@ func ExtractAPK(apkPath string, paths map[string]string) error { if found != len(paths) { var missing []string for p, dest := range paths { - if _, err := os.Stat(dest); err != nil { + if _, err := root.Stat(dest); err != nil { missing = append(missing, p) } } @@ -154,9 +173,9 @@ func ExtractAPK(apkPath string, paths map[string]string) error { return nil } -// DecompressGzip decompresses a gzip file to dest. -func DecompressGzip(src, dest string) error { - f, err := os.Open(src) +// decompressGzip decompresses a gzip file within root. +func decompressGzip(root *os.Root, srcRel, destRel string) error { + f, err := root.Open(srcRel) if err != nil { return err } @@ -168,13 +187,13 @@ func DecompressGzip(src, dest string) error { } defer gz.Close() - out, err := os.Create(dest) + out, err := root.Create(destRel) if err != nil { return err } - if _, err := io.Copy(out, gz); err != nil { - out.Close() + if _, err := io.Copy(out, gz); err != nil { //#nosec G110 -- decompressing SHA256-verified Alpine packages + _ = out.Close() return err } return out.Close() @@ -203,42 +222,46 @@ func kernelVersion(b *board.Board) string { // kernel image and modules, and returns paths to the extracted files. Results // are cached under ~/.cache/wisp//. func Kernel(b *board.Board) (*KernelResult, error) { - cacheBase, err := CacheDir() + root, cacheDir, err := openCacheRoot() if err != nil { return nil, err } - boardDir := filepath.Join(cacheBase, b.Name, b.Kernel.Version) + defer root.Close() + + boardDir := filepath.Join(b.Name, b.Kernel.Version) // Check if already cached. - kernelPath := filepath.Join(boardDir, "vmlinuz") - if _, err := os.Stat(kernelPath); err == nil { - // Verify modules are also present. + kernelRel := filepath.Join(boardDir, "vmlinuz") + if _, err := root.Stat(kernelRel); err == nil { allPresent := true var modPaths []string for _, m := range b.Modules { - mp := filepath.Join(boardDir, "modules", m.Name) - if _, err := os.Stat(mp); err != nil { + modRel := filepath.Join(boardDir, "modules", m.Name) + if _, err := root.Stat(modRel); err != nil { allPresent = false break } - modPaths = append(modPaths, mp) + modPaths = append(modPaths, filepath.Join(cacheDir, modRel)) } if allPresent { - return &KernelResult{KernelPath: kernelPath, ModulePaths: modPaths}, nil + return &KernelResult{ + KernelPath: filepath.Join(cacheDir, kernelRel), + ModulePaths: modPaths, + }, nil } } - if err := os.MkdirAll(boardDir, 0755); err != nil { + if err := root.MkdirAll(boardDir, 0750); err != nil { return nil, fmt.Errorf("create cache dir: %w", err) } // Construct the APK URL. - apkURL := fmt.Sprintf("https://dl-cdn.alpinelinux.org/alpine/v3.23/main/aarch64/%s-%s.apk", - b.Kernel.Package, b.Kernel.Version) - apkPath := filepath.Join(boardDir, filepath.Base(apkURL)) + apkURL := fmt.Sprintf("https://dl-cdn.alpinelinux.org/alpine/v3.23/main/%s/%s-%s.apk", + alpineArch(b.Arch), b.Kernel.Package, b.Kernel.Version) + apkRel := filepath.Join(boardDir, filepath.Base(apkURL)) // Download the APK. - if err := Download(apkURL, apkPath, b.Kernel.SHA256); err != nil { + if err := download(root, apkURL, apkRel, b.Kernel.SHA256); err != nil { return nil, fmt.Errorf("download kernel: %w", err) } @@ -250,42 +273,45 @@ func Kernel(b *board.Board) (*KernelResult, error) { vmlinuzInAPK := "boot/vmlinuz-" + flavor extractPaths := map[string]string{ - vmlinuzInAPK: kernelPath, + vmlinuzInAPK: kernelRel, } // Module paths inside the APK. - modulesDir := filepath.Join(boardDir, "modules") - if err := os.MkdirAll(modulesDir, 0755); err != nil { + modulesRel := filepath.Join(boardDir, "modules") + if err := root.MkdirAll(modulesRel, 0750); err != nil { return nil, fmt.Errorf("create modules dir: %w", err) } for _, m := range b.Modules { apkModPath := "lib/modules/" + kver + "/" + m.Path - destPath := filepath.Join(modulesDir, m.Name+".gz") - extractPaths[apkModPath] = destPath + destRel := filepath.Join(modulesRel, m.Name+".gz") + extractPaths[apkModPath] = destRel } // Extract files from APK. - if err := ExtractAPK(apkPath, extractPaths); err != nil { + if err := extractAPK(root, apkRel, extractPaths); err != nil { return nil, fmt.Errorf("extract kernel package: %w", err) } // Decompress .ko.gz modules to .ko. var modPaths []string for _, m := range b.Modules { - gzPath := filepath.Join(modulesDir, m.Name+".gz") - koPath := filepath.Join(modulesDir, m.Name) - if err := DecompressGzip(gzPath, koPath); err != nil { + gzRel := filepath.Join(modulesRel, m.Name+".gz") + koRel := filepath.Join(modulesRel, m.Name) + if err := decompressGzip(root, gzRel, koRel); err != nil { return nil, fmt.Errorf("decompress module %s: %w", m.Name, err) } - os.Remove(gzPath) - modPaths = append(modPaths, koPath) + _ = root.Remove(gzRel) + modPaths = append(modPaths, filepath.Join(cacheDir, koRel)) } // Clean up the APK. - os.Remove(apkPath) + _ = root.Remove(apkRel) - return &KernelResult{KernelPath: kernelPath, ModulePaths: modPaths}, nil + return &KernelResult{ + KernelPath: filepath.Join(cacheDir, kernelRel), + ModulePaths: modPaths, + }, nil } // Firmware downloads firmware files for the given board to the cache @@ -295,30 +321,48 @@ func Firmware(b *board.Board) (map[string]string, error) { return nil, nil } - cacheBase, err := CacheDir() + root, cacheDir, err := openCacheRoot() if err != nil { return nil, err } - fwDir := filepath.Join(cacheBase, b.Name, "firmware") - if err := os.MkdirAll(fwDir, 0755); err != nil { + defer root.Close() + + fwDir := filepath.Join(b.Name, "firmware") + if err := root.MkdirAll(fwDir, 0750); err != nil { return nil, fmt.Errorf("create firmware dir: %w", err) } result := make(map[string]string) for _, fw := range b.Firmware { - localPath := filepath.Join(fwDir, fw.Dest) - if err := Download(fw.URL, localPath, fw.SHA256); err != nil { + relPath := filepath.Join(fwDir, fw.Dest) + if err := download(root, fw.URL, relPath, fw.SHA256); err != nil { return nil, fmt.Errorf("download firmware %s: %w", fw.Dest, err) } - result[fw.Dest] = localPath + result[fw.Dest] = filepath.Join(cacheDir, relPath) } return result, nil } -// checksum verifies that the file at path has the expected SHA256 hex digest. -func checksum(path, want string) (bool, error) { - f, err := os.Open(path) +// alpineArch maps board architecture strings to Alpine APK repository +// architecture names. +func alpineArch(arch string) string { + switch arch { + case "aarch64": + return "aarch64" + case "armv7": + return "armhf" + case "x86_64": + return "x86_64" + default: + return arch + } +} + +// checksumFile verifies that the file at relPath within root has the expected +// SHA256 hex digest. +func checksumFile(root *os.Root, relPath, want string) (bool, error) { + f, err := root.Open(relPath) if err != nil { return false, err } diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go index 0f0a164..7e5c3f5 100644 --- a/internal/fetch/fetch_test.go +++ b/internal/fetch/fetch_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "testing" "github.com/funcimp/wisp/internal/board" @@ -46,8 +45,13 @@ func TestDownload(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dest := filepath.Join(t.TempDir(), "downloaded") - err := Download(srv.URL+"/kernel.apk", dest, tt.sha256) + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + err = download(root, srv.URL+"/kernel.apk", "downloaded", tt.sha256) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") @@ -58,7 +62,7 @@ func TestDownload(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - got, err := os.ReadFile(dest) + got, err := root.ReadFile("downloaded") if err != nil { t.Fatalf("read downloaded file: %v", err) } @@ -81,10 +85,14 @@ func TestDownloadCached(t *testing.T) { })) defer srv.Close() - dest := filepath.Join(t.TempDir(), "cached") + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() // First download. - if err := Download(srv.URL+"/file", dest, wantHash); err != nil { + if err := download(root, srv.URL+"/file", "cached", wantHash); err != nil { t.Fatalf("first download: %v", err) } if calls != 1 { @@ -92,7 +100,7 @@ func TestDownloadCached(t *testing.T) { } // Second download should use cache (checksum matches). - if err := Download(srv.URL+"/file", dest, wantHash); err != nil { + if err := download(root, srv.URL+"/file", "cached", wantHash); err != nil { t.Fatalf("second download: %v", err) } if calls != 1 { @@ -103,11 +111,11 @@ func TestDownloadCached(t *testing.T) { func TestExtractAPK(t *testing.T) { // Build a tar.gz in memory with a few test files. tmpDir := t.TempDir() - apkPath := filepath.Join(tmpDir, "test.apk") + apkPath := tmpDir + "/test.apk" files := map[string]string{ - "boot/vmlinuz-virt": "kernel-image-data", - "lib/modules/6.18.13-0-virt/kernel/net/core/failover.ko.gz": "failover-module", + "boot/vmlinuz-virt": "kernel-image-data", + "lib/modules/6.18.13-0-virt/kernel/net/core/failover.ko.gz": "failover-module", "lib/modules/6.18.13-0-virt/kernel/drivers/net/virtio_net.ko.gz": "virtio-module", ".PKGINFO": "pkgname=linux-virt", } @@ -116,21 +124,24 @@ func TestExtractAPK(t *testing.T) { t.Fatalf("create test APK: %v", err) } - // Extract specific files. - kernelDest := filepath.Join(tmpDir, "vmlinuz") - failoverDest := filepath.Join(tmpDir, "failover.ko.gz") + root, err := os.OpenRoot(tmpDir) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + // Extract specific files. paths := map[string]string{ - "boot/vmlinuz-virt": kernelDest, - "lib/modules/6.18.13-0-virt/kernel/net/core/failover.ko.gz": failoverDest, + "boot/vmlinuz-virt": "vmlinuz", + "lib/modules/6.18.13-0-virt/kernel/net/core/failover.ko.gz": "failover.ko.gz", } - if err := ExtractAPK(apkPath, paths); err != nil { + if err := extractAPK(root, "test.apk", paths); err != nil { t.Fatalf("extract: %v", err) } // Verify extracted files. - got, err := os.ReadFile(kernelDest) + got, err := root.ReadFile("vmlinuz") if err != nil { t.Fatalf("read kernel: %v", err) } @@ -138,7 +149,7 @@ func TestExtractAPK(t *testing.T) { t.Fatalf("kernel content: got %q, want %q", got, "kernel-image-data") } - got, err = os.ReadFile(failoverDest) + got, err = root.ReadFile("failover.ko.gz") if err != nil { t.Fatalf("read module: %v", err) } @@ -149,7 +160,7 @@ func TestExtractAPK(t *testing.T) { func TestExtractAPKMissingFile(t *testing.T) { tmpDir := t.TempDir() - apkPath := filepath.Join(tmpDir, "test.apk") + apkPath := tmpDir + "/test.apk" files := map[string]string{ "boot/vmlinuz-virt": "kernel", @@ -158,12 +169,18 @@ func TestExtractAPKMissingFile(t *testing.T) { t.Fatalf("create test APK: %v", err) } + root, err := os.OpenRoot(tmpDir) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + paths := map[string]string{ - "boot/vmlinuz-virt": filepath.Join(tmpDir, "vmlinuz"), - "missing/file": filepath.Join(tmpDir, "missing"), + "boot/vmlinuz-virt": "vmlinuz", + "missing/file": "missing", } - err := ExtractAPK(apkPath, paths) + err = extractAPK(root, "test.apk", paths) if err == nil { t.Fatal("expected error for missing file") } diff --git a/internal/image/image.go b/internal/image/image.go index d34cebd..9aba742 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -6,7 +6,6 @@ package image import ( "encoding/binary" "fmt" - "os" "path" "sort" "strings" @@ -33,10 +32,10 @@ const ( defaultImageSize = 64 * 1024 * 1024 ) -// Build creates a bootable FAT32 disk image at outputPath containing the -// given files. Files with paths containing "/" are placed in subdirectories -// (one level only, e.g. "overlays/foo.dtbo"). -func Build(outputPath string, files []File) error { +// Build creates a bootable FAT32 disk image containing the given files and +// returns the raw image bytes. Files with paths containing "/" are placed in +// subdirectories (one level only, e.g. "overlays/foo.dtbo"). +func Build(files []File) ([]byte, error) { imageSize := defaultImageSize totalSectors := imageSize / sectorSize @@ -137,7 +136,7 @@ func Build(outputPath string, files []File) error { off := clusterOffset(startCluster) copy(img[off:], data) - nextCluster += uint32(numClusters) + nextCluster += uint32(numClusters) //#nosec G115 -- image is 64MB; numClusters fits in uint32 return startCluster } @@ -147,7 +146,7 @@ func Build(outputPath string, files []File) error { // Write regular files in root directory. for _, f := range rootFiles { cluster := allocClusters(f.data) - entry := makeDirEntry(f.shortName, false, cluster, uint32(len(f.data))) + entry := makeDirEntry(f.shortName, false, cluster, uint32(len(f.data))) //#nosec G115 -- image is 64MB; file sizes fit in uint32 rootDirEntries = append(rootDirEntries, entry...) } @@ -181,7 +180,7 @@ func Build(outputPath string, files []File) error { // Write files in this subdirectory. for _, f := range dirFiles { cluster := allocClusters(f.data) - entry := makeDirEntry(f.shortName, false, cluster, uint32(len(f.data))) + entry := makeDirEntry(f.shortName, false, cluster, uint32(len(f.data))) //#nosec G115 -- image is 64MB; file sizes fit in uint32 subDirData = append(subDirData, entry...) } @@ -207,7 +206,7 @@ func Build(outputPath string, files []File) error { binary.LittleEndian.PutUint32(img[fsInfoOff+488:], freeCount) binary.LittleEndian.PutUint32(img[fsInfoOff+492:], nextCluster) - return os.WriteFile(outputPath, img, 0644) + return img, nil } // writeMBR writes a Master Boot Record with a single FAT32 partition entry. @@ -304,7 +303,7 @@ func makeDirEntry(name [11]byte, isDir bool, cluster uint32, size uint32) []byte // uppercased, padded with spaces, and the extension is placed in positions 8-10. func toShortName(name string) [11]byte { var sn [11]byte - for i := range sn { + for i := range sn { //#nosec G602 -- range produces valid indices sn[i] = ' ' } diff --git a/internal/image/image_test.go b/internal/image/image_test.go index 40fc51d..ddbaed8 100644 --- a/internal/image/image_test.go +++ b/internal/image/image_test.go @@ -1,8 +1,6 @@ package image import ( - "os" - "path/filepath" "testing" ) @@ -42,8 +40,7 @@ func TestBuild(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - outPath := filepath.Join(t.TempDir(), "test.img") - err := Build(outPath, tt.files) + imgData, err := Build(tt.files) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") @@ -54,11 +51,6 @@ func TestBuild(t *testing.T) { t.Fatalf("Build: %v", err) } - imgData, err := os.ReadFile(outPath) - if err != nil { - t.Fatalf("read image: %v", err) - } - if len(imgData) != defaultImageSize { t.Fatalf("image size: got %d, want %d", len(imgData), defaultImageSize) } @@ -96,14 +88,9 @@ func TestBuildFileContent(t *testing.T) { {Name: "config.txt", Data: content}, } - outPath := filepath.Join(t.TempDir(), "test.img") - if err := Build(outPath, files); err != nil { - t.Fatalf("Build: %v", err) - } - - imgData, err := os.ReadFile(outPath) + imgData, err := Build(files) if err != nil { - t.Fatalf("read image: %v", err) + t.Fatalf("Build: %v", err) } got, err := ReadFile(imgData, "config.txt") @@ -145,14 +132,9 @@ func TestBPBSignature(t *testing.T) { {Name: "test.txt", Data: []byte("test")}, } - outPath := filepath.Join(t.TempDir(), "test.img") - if err := Build(outPath, files); err != nil { - t.Fatalf("Build: %v", err) - } - - imgData, err := os.ReadFile(outPath) + imgData, err := Build(files) if err != nil { - t.Fatalf("read image: %v", err) + t.Fatalf("Build: %v", err) } // Check partition BPB signature. diff --git a/internal/initrd/build.go b/internal/initrd/build.go new file mode 100644 index 0000000..4c5668d --- /dev/null +++ b/internal/initrd/build.go @@ -0,0 +1,107 @@ +package initrd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +// NetworkConfig holds the network parameters baked into the initrd. +type NetworkConfig struct { + Interface string // network interface name, e.g. "eth0" + Address string // IP address with CIDR, e.g. "192.168.1.100/24" + Gateway string // default gateway, e.g. "192.168.1.1" + DNS string // DNS server, e.g. "192.168.1.1" +} + +// KernelModule identifies a kernel module to include in the initrd. +type KernelModule struct { + HostPath string // absolute path to the .ko file on the build host +} + +// Build assembles a complete initrd image. It embeds the init binary for the +// given architecture, reads the service binary and kernel modules from disk, +// generates configuration files, and returns the gzipped cpio archive. +func Build(arch, servicePath string, net NetworkConfig, modules []KernelModule) ([]byte, error) { + initData, err := InitBinary(arch) + if err != nil { + return nil, fmt.Errorf("init binary: %w", err) + } + + serviceData, err := os.ReadFile(servicePath) //#nosec G304 -- user-provided binary path + if err != nil { + return nil, fmt.Errorf("read binary: %w", err) + } + + var moduleData []moduleFile + for _, m := range modules { + data, err := os.ReadFile(m.HostPath) + if err != nil { + name := filepath.Base(m.HostPath) + return nil, fmt.Errorf("read module %s: %w", name, err) + } + moduleData = append(moduleData, moduleFile{ + name: filepath.Base(m.HostPath), + data: data, + }) + } + + entries := buildEntries(initData, serviceData, net, moduleData) + + var buf bytes.Buffer + if err := Write(&buf, entries); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// moduleFile holds a kernel module's filename and contents for entry assembly. +type moduleFile struct { + name string + data []byte +} + +// buildEntries assembles the initrd entry list from in-memory data. This is +// separated from Build so it can be tested without embedded binaries or disk +// I/O. +func buildEntries(initData, serviceData []byte, net NetworkConfig, modules []moduleFile) []Entry { + var entries []Entry + + // Mount-point directories. + for _, dir := range []string{"dev", "proc", "sys"} { + entries = append(entries, Entry{Path: dir, Mode: os.ModeDir | 0755}) + } + + // Init binary. + entries = append(entries, Entry{Path: "init", Data: initData, Mode: 0755}) + + // Service binary. + entries = append(entries, Entry{Path: "service/run", Data: serviceData, Mode: 0755}) + + // Network config. + wispConf := fmt.Sprintf("IFACE=%s\nADDR=%s\nGW=%s\n", net.Interface, net.Address, net.Gateway) + entries = append(entries, Entry{Path: "etc/wisp.conf", Data: []byte(wispConf), Mode: 0644}) + + // DNS config. + resolvConf := fmt.Sprintf("nameserver %s\n", net.DNS) + entries = append(entries, Entry{Path: "etc/resolv.conf", Data: []byte(resolvConf), Mode: 0644}) + + // Kernel modules. + if len(modules) > 0 { + var moduleNames []string + for _, m := range modules { + moduleNames = append(moduleNames, m.name) + entries = append(entries, Entry{ + Path: "lib/modules/" + m.name, + Data: m.data, + Mode: 0644, + }) + } + modulesList := strings.Join(moduleNames, "\n") + "\n" + entries = append(entries, Entry{Path: "etc/modules", Data: []byte(modulesList), Mode: 0644}) + } + + return entries +} diff --git a/internal/initrd/build_test.go b/internal/initrd/build_test.go new file mode 100644 index 0000000..ea3ea45 --- /dev/null +++ b/internal/initrd/build_test.go @@ -0,0 +1,117 @@ +package initrd + +import ( + "os" + "testing" +) + +func TestBuildEntries(t *testing.T) { + tests := []struct { + name string + initData []byte + svcData []byte + net NetworkConfig + modules []moduleFile + want map[string]struct { + isDir bool + data string + mode os.FileMode + } + }{ + { + name: "basic without modules", + initData: []byte("INIT"), + svcData: []byte("SERVICE"), + net: NetworkConfig{ + Interface: "eth0", + Address: "10.0.2.15/24", + Gateway: "10.0.2.2", + DNS: "10.0.2.3", + }, + modules: nil, + want: map[string]struct { + isDir bool + data string + mode os.FileMode + }{ + "dev": {isDir: true, mode: os.ModeDir | 0755}, + "proc": {isDir: true, mode: os.ModeDir | 0755}, + "sys": {isDir: true, mode: os.ModeDir | 0755}, + "init": {data: "INIT", mode: 0755}, + "service/run": {data: "SERVICE", mode: 0755}, + "etc/wisp.conf": {data: "IFACE=eth0\nADDR=10.0.2.15/24\nGW=10.0.2.2\n", mode: 0644}, + "etc/resolv.conf": {data: "nameserver 10.0.2.3\n", mode: 0644}, + }, + }, + { + name: "with kernel modules", + initData: []byte("INIT"), + svcData: []byte("SERVICE"), + net: NetworkConfig{ + Interface: "eth0", + Address: "192.168.1.100/24", + Gateway: "192.168.1.1", + DNS: "192.168.1.1", + }, + modules: []moduleFile{ + {name: "failover.ko", data: []byte("MOD1")}, + {name: "virtio_net.ko", data: []byte("MOD2")}, + }, + want: map[string]struct { + isDir bool + data string + mode os.FileMode + }{ + "dev": {isDir: true, mode: os.ModeDir | 0755}, + "proc": {isDir: true, mode: os.ModeDir | 0755}, + "sys": {isDir: true, mode: os.ModeDir | 0755}, + "init": {data: "INIT", mode: 0755}, + "service/run": {data: "SERVICE", mode: 0755}, + "etc/wisp.conf": {data: "IFACE=eth0\nADDR=192.168.1.100/24\nGW=192.168.1.1\n", mode: 0644}, + "etc/resolv.conf": {data: "nameserver 192.168.1.1\n", mode: 0644}, + "lib/modules/failover.ko": {data: "MOD1", mode: 0644}, + "lib/modules/virtio_net.ko": {data: "MOD2", mode: 0644}, + "etc/modules": {data: "failover.ko\nvirtio_net.ko\n", mode: 0644}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries := buildEntries(tt.initData, tt.svcData, tt.net, tt.modules) + + got := make(map[string]Entry) + for _, e := range entries { + got[e.Path] = e + } + + for path, want := range tt.want { + e, ok := got[path] + if !ok { + t.Errorf("missing entry: %s", path) + continue + } + if want.isDir { + if e.Mode != want.mode { + t.Errorf("%s: mode = %v, want %v", path, e.Mode, want.mode) + } + if e.Data != nil { + t.Errorf("%s: dir has non-nil data", path) + } + } else { + if string(e.Data) != want.data { + t.Errorf("%s: data = %q, want %q", path, e.Data, want.data) + } + if e.Mode != want.mode { + t.Errorf("%s: mode = %v, want %v", path, e.Mode, want.mode) + } + } + delete(got, path) + } + + for path := range got { + t.Errorf("unexpected entry: %s", path) + } + }) + } +} diff --git a/cmd/wisp/embed/.gitkeep b/internal/initrd/embed/.gitkeep similarity index 100% rename from cmd/wisp/embed/.gitkeep rename to internal/initrd/embed/.gitkeep diff --git a/internal/initrd/initbin.go b/internal/initrd/initbin.go new file mode 100644 index 0000000..f76d76f --- /dev/null +++ b/internal/initrd/initbin.go @@ -0,0 +1,43 @@ +package initrd + +import ( + "embed" + "fmt" +) + +// Embedded init binaries for each supported architecture. The embed directive +// lists files explicitly so that go build fails fast if any binary is missing +// (forces make to run first). +// +//go:embed embed/init-arm64 embed/init-riscv64 embed/init-amd64 +var initFS embed.FS + +// InitBinary returns the embedded init binary for the given board architecture +// string (e.g., "aarch64", "riscv64", "x86_64"). Callers use the board profile +// arch value directly — the GOARCH mapping is handled internally. +func InitBinary(arch string) ([]byte, error) { + ga, err := goarch(arch) + if err != nil { + return nil, err + } + data, err := initFS.ReadFile("embed/init-" + ga) + if err != nil { + return nil, fmt.Errorf("read embedded init binary for %s: %w", arch, err) + } + return data, nil +} + +// goarch maps board architecture strings to Go architecture names used in the +// embedded binary filenames. +func goarch(boardArch string) (string, error) { + switch boardArch { + case "aarch64": + return "arm64", nil + case "riscv64": + return "riscv64", nil + case "x86_64": + return "amd64", nil + default: + return "", fmt.Errorf("unsupported architecture: %q", boardArch) + } +} diff --git a/internal/initrd/initbin_test.go b/internal/initrd/initbin_test.go new file mode 100644 index 0000000..bfbde86 --- /dev/null +++ b/internal/initrd/initbin_test.go @@ -0,0 +1,35 @@ +package initrd + +import "testing" + +func TestGoarch(t *testing.T) { + tests := []struct { + boardArch string + want string + wantErr bool + }{ + {boardArch: "aarch64", want: "arm64"}, + {boardArch: "riscv64", want: "riscv64"}, + {boardArch: "x86_64", want: "amd64"}, + {boardArch: "mips64", wantErr: true}, + {boardArch: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.boardArch, func(t *testing.T) { + got, err := goarch(tt.boardArch) + if tt.wantErr { + if err == nil { + t.Fatalf("goarch(%q) = %q, want error", tt.boardArch, got) + } + return + } + if err != nil { + t.Fatalf("goarch(%q) error: %v", tt.boardArch, err) + } + if got != tt.want { + t.Errorf("goarch(%q) = %q, want %q", tt.boardArch, got, tt.want) + } + }) + } +} diff --git a/testdata/helloworld/main.go b/testdata/helloworld/main.go index e9e515f..325166d 100644 --- a/testdata/helloworld/main.go +++ b/testdata/helloworld/main.go @@ -11,7 +11,7 @@ func main() { fmt.Fprintln(w, "hello from wisp") }) fmt.Fprintln(os.Stderr, "helloworld: listening on :8080") - if err := http.ListenAndServe(":8080", nil); err != nil { + if err := http.ListenAndServe(":8080", nil); err != nil { //#nosec G114 -- this is just a test server fmt.Fprintln(os.Stderr, "helloworld:", err) os.Exit(1) }