diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 271802f..a9267e2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,7 @@ name: Build & Test WASM on: push: branches: [main] + tags: ['v*'] pull_request: branches: [main] @@ -12,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Go uses: actions/setup-go@v5 @@ -44,6 +47,23 @@ jobs: fi GOOS=js GOARCH=wasm go test -exec="$EXEC" ./... + - name: Check if integration test needed + id: check-wasm + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif git diff --name-only HEAD~1 HEAD | grep -q '^kobopatch-wasm/'; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Integration test (full patching pipeline) + if: steps.check-wasm.outputs.run == 'true' + run: | + cd kobopatch-wasm + ./test-integration.sh + - name: Build WASM binary run: | cd kobopatch-wasm diff --git a/.gitignore b/.gitignore index bb5bec5..a1439cc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ kobopatch/ # Kobopatch WASM build artifacts kobopatch-wasm/kobopatch-src/ +kobopatch-wasm/testdata/ kobopatch-wasm/kobopatch.wasm kobopatch-wasm/wasm_exec.js diff --git a/README.md b/README.md index 3c45f59..4994068 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,14 @@ cd kobopatch-wasm python3 -m http.server -d src/public/ 8888 ``` +## Output validation + +The WASM patcher performs several checks on each patched binary before including it in the output `KoboRoot.tgz`: + +- **File size sanity check** — the patched binary must be exactly the same size as the input. kobopatch does in-place byte replacement, so any size change indicates corruption. +- **ELF header validation** — verifies the magic bytes (`\x7fELF`), 32-bit class, little-endian encoding, and ARM machine type (`0x28`) are intact after patching. +- **Archive consistency check** — after building the output tar.gz, re-reads the entire archive and verifies the sum of entry sizes matches what was written. + ## Credits kobopatch by [pgaskin](https://github.com/pgaskin/kobopatch). Patches from [MobileRead](https://www.mobileread.com/). diff --git a/kobopatch-wasm/integration_test.go b/kobopatch-wasm/integration_test.go new file mode 100644 index 0000000..9494b50 --- /dev/null +++ b/kobopatch-wasm/integration_test.go @@ -0,0 +1,175 @@ +//go:build js && wasm + +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha1" + "fmt" + "io" + "os" + "strings" + "testing" +) + +// TestIntegrationPatch runs the full patching pipeline with real patch files +// and validates SHA1 checksums of the patched binaries. +// +// Requires the firmware zip to be present at testdata/kobo-update-4.45.23646.zip +// (or the path set via FIRMWARE_ZIP env var). Run test-integration.sh to download +// the firmware and execute this test. +func TestIntegrationPatch(t *testing.T) { + firmwarePath := os.Getenv("FIRMWARE_ZIP") + if firmwarePath == "" { + firmwarePath = "testdata/kobo-update-4.45.23646.zip" + } + + firmwareZip, err := os.ReadFile(firmwarePath) + if err != nil { + t.Skipf("firmware zip not available at %s (run test-integration.sh to download): %v", firmwarePath, err) + } + + // Read patch files from the patches zip. + patchesZipPath := "../src/public/patches/patches_4.4523646.zip" + patchesZip, err := os.ReadFile(patchesZipPath) + if err != nil { + t.Fatalf("could not read patches zip: %v", err) + } + + patchFiles, err := extractPatchFiles(patchesZip) + if err != nil { + t.Fatalf("could not extract patch files: %v", err) + } + + // Config: all patches at their defaults, with one override enabled. + configYAML := ` +version: 4.45.23646 +in: unused +out: unused +log: unused + +patches: + src/nickel.yaml: usr/local/Kobo/nickel + src/nickel_custom.yaml: usr/local/Kobo/nickel + src/libadobe.so.yaml: usr/local/Kobo/libadobe.so + src/libnickel.so.1.0.0.yaml: usr/local/Kobo/libnickel.so.1.0.0 + src/librmsdk.so.1.0.0.yaml: usr/local/Kobo/librmsdk.so.1.0.0 + src/cloud_sync.yaml: usr/local/Kobo/libnickel.so.1.0.0 + +overrides: + src/nickel.yaml: + "Remove footer (row3) on new home screen": yes +` + + var logMessages []string + progress := func(msg string) { + logMessages = append(logMessages, msg) + } + + result, err := patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progress) + if err != nil { + t.Fatalf("patchFirmware failed: %v", err) + } + + if len(result.tgzBytes) == 0 { + t.Fatal("patchFirmware returned empty tgz") + } + + // Expected SHA1 checksums for Kobo Libra Color, firmware 4.45.23646, + // with only "Remove footer (row3) on new home screen" enabled. + expectedSHA1 := map[string]string{ + "usr/local/Kobo/libnickel.so.1.0.0": "ef64782895a47ac85f0829f06fffa4816d23512d", + "usr/local/Kobo/nickel": "80a607bac515457a6864be8be831df631a01005c", + "usr/local/Kobo/libadobe.so": "02dc99c71c4fef75401cd49ddc2e63f928a126e1", + "usr/local/Kobo/librmsdk.so.1.0.0": "e3819260c9fc539a53db47e9d3fe600ec11633d5", + } + + // Extract the output tgz and check SHA1 of each patched binary. + actualSHA1, err := extractTgzSHA1(result.tgzBytes) + if err != nil { + t.Fatalf("could not extract output tgz: %v", err) + } + + for name, expected := range expectedSHA1 { + actual, ok := actualSHA1[name] + if !ok { + // Try with ./ prefix (tar entries may vary). + actual, ok = actualSHA1["./"+name] + } + if !ok { + t.Errorf("missing binary in output: %s", name) + continue + } + if actual != expected { + t.Errorf("SHA1 mismatch for %s:\n expected: %s\n actual: %s", name, expected, actual) + } else { + t.Logf("OK %s = %s", name, actual) + } + } + + t.Logf("output tgz size: %d bytes", len(result.tgzBytes)) + t.Logf("log output:\n%s", result.log) +} + +// extractPatchFiles reads a patches zip and returns a map of filename -> contents +// for all src/*.yaml files. +func extractPatchFiles(zipData []byte) (map[string][]byte, error) { + r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return nil, err + } + + files := make(map[string][]byte) + for _, f := range r.File { + if !strings.HasPrefix(f.Name, "src/") || !strings.HasSuffix(f.Name, ".yaml") { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("open %s: %w", f.Name, err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + files[f.Name] = data + } + return files, nil +} + +// extractTgzSHA1 reads a tgz and returns a map of entry name -> SHA1 hex string. +func extractTgzSHA1(tgzData []byte) (map[string]string, error) { + gr, err := gzip.NewReader(bytes.NewReader(tgzData)) + if err != nil { + return nil, err + } + defer gr.Close() + + tr := tar.NewReader(gr) + sums := make(map[string]string) + + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if h.Typeflag != tar.TypeReg { + continue + } + + hasher := sha1.New() + if _, err := io.Copy(hasher, tr); err != nil { + return nil, fmt.Errorf("hash %s: %w", h.Name, err) + } + sums[h.Name] = fmt.Sprintf("%x", hasher.Sum(nil)) + } + + return sums, nil +} diff --git a/kobopatch-wasm/main.go b/kobopatch-wasm/main.go index 493a2f4..f601868 100644 --- a/kobopatch-wasm/main.go +++ b/kobopatch-wasm/main.go @@ -7,6 +7,7 @@ import ( "compress/gzip" "errors" "fmt" + "encoding/binary" "io" "path/filepath" "strings" @@ -260,6 +261,17 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[ } patchedBytes := pt.GetBytes() + + // Sanity check: patched binary must be the same size as the input. + if len(patchedBytes) != len(entryBytes) { + return nil, fmt.Errorf("size changed after patching '%s': was %d bytes, now %d bytes", h.Name, len(entryBytes), len(patchedBytes)) + } + + // Validate ELF header is intact after patching. + if err := validateELF(patchedBytes, h.Name); err != nil { + return nil, err + } + outTarExpectedSize += h.Size // Write patched file to output tar, preserving original attributes. @@ -321,6 +333,36 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[ }, nil } +// validateELF checks that the patched binary still has a valid ARM ELF header. +func validateELF(data []byte, name string) error { + if len(data) < 20 { + return fmt.Errorf("patched '%s' is too small to be a valid ELF binary (%d bytes)", name, len(data)) + } + + // ELF magic: \x7fELF + if data[0] != 0x7f || data[1] != 'E' || data[2] != 'L' || data[3] != 'F' { + return fmt.Errorf("patched '%s' has corrupted ELF magic bytes", name) + } + + // EI_CLASS: must be 32-bit (1) — Kobo uses ARM 32-bit binaries + if data[4] != 1 { + return fmt.Errorf("patched '%s' has wrong ELF class: expected 32-bit (1), got %d", name, data[4]) + } + + // EI_DATA: must be little-endian (1) + if data[5] != 1 { + return fmt.Errorf("patched '%s' has wrong ELF endianness: expected little-endian (1), got %d", name, data[5]) + } + + // e_machine at offset 18 (2 bytes LE): must be ARM (0x28 = 40) + machine := binary.LittleEndian.Uint16(data[18:20]) + if machine != 0x28 { + return fmt.Errorf("patched '%s' has wrong ELF machine type: expected ARM (0x28), got 0x%x", name, machine) + } + + return nil +} + func detectFormat(filename string) string { ext := strings.TrimLeft(filepath.Ext(filename), ".") ext = strings.ReplaceAll(ext, "patch", "patch32lsb") diff --git a/kobopatch-wasm/test-integration.sh b/kobopatch-wasm/test-integration.sh new file mode 100755 index 0000000..12cfbad --- /dev/null +++ b/kobopatch-wasm/test-integration.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail + +# Integration test: downloads firmware and runs the full patching pipeline +# with SHA1 checksum validation. +# +# Usage: ./test-integration.sh +# +# The firmware zip (~150MB) is cached in testdata/ to avoid re-downloading. + +FIRMWARE_VERSION="4.45.23646" +FIRMWARE_URL="https://ereaderfiles.kobo.com/firmwares/kobo13/Mar2026/kobo-update-${FIRMWARE_VERSION}.zip" +FIRMWARE_DIR="testdata" +FIRMWARE_FILE="${FIRMWARE_DIR}/kobo-update-${FIRMWARE_VERSION}.zip" + +cd "$(dirname "$0")" + +# Download firmware if not cached. +if [ ! -f "$FIRMWARE_FILE" ]; then + echo "Downloading firmware ${FIRMWARE_VERSION} (~150MB)..." + mkdir -p "$FIRMWARE_DIR" + curl -L --progress-bar -o "$FIRMWARE_FILE" "$FIRMWARE_URL" + echo "Downloaded to $FIRMWARE_FILE" +else + echo "Using cached firmware: $FIRMWARE_FILE" +fi + +# Find the WASM test executor. +GOROOT="$(go env GOROOT)" +if [ -f "$GOROOT/lib/wasm/go_js_wasm_exec" ]; then + EXEC="$GOROOT/lib/wasm/go_js_wasm_exec" +elif [ -f "$GOROOT/misc/wasm/go_js_wasm_exec" ]; then + EXEC="$GOROOT/misc/wasm/go_js_wasm_exec" +else + echo "ERROR: go_js_wasm_exec not found in GOROOT ($GOROOT)" + exit 1 +fi + +echo "Running integration test..." +FIRMWARE_ZIP="$FIRMWARE_FILE" GOOS=js GOARCH=wasm go test -v -run TestIntegrationPatch -timeout 300s -exec="$EXEC" .