Integration test (conditional)
Some checks failed
Build & Test WASM / build-and-test (push) Failing after 1m45s
Some checks failed
Build & Test WASM / build-and-test (push) Failing after 1m45s
This commit is contained in:
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -3,6 +3,7 @@ name: Build & Test WASM
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -44,6 +47,23 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
GOOS=js GOARCH=wasm go test -exec="$EXEC" ./...
|
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
|
- name: Build WASM binary
|
||||||
run: |
|
run: |
|
||||||
cd kobopatch-wasm
|
cd kobopatch-wasm
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ kobopatch/
|
|||||||
|
|
||||||
# Kobopatch WASM build artifacts
|
# Kobopatch WASM build artifacts
|
||||||
kobopatch-wasm/kobopatch-src/
|
kobopatch-wasm/kobopatch-src/
|
||||||
|
kobopatch-wasm/testdata/
|
||||||
kobopatch-wasm/kobopatch.wasm
|
kobopatch-wasm/kobopatch.wasm
|
||||||
kobopatch-wasm/wasm_exec.js
|
kobopatch-wasm/wasm_exec.js
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ cd kobopatch-wasm
|
|||||||
python3 -m http.server -d src/public/ 8888
|
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
|
## Credits
|
||||||
|
|
||||||
kobopatch by [pgaskin](https://github.com/pgaskin/kobopatch). Patches from [MobileRead](https://www.mobileread.com/).
|
kobopatch by [pgaskin](https://github.com/pgaskin/kobopatch). Patches from [MobileRead](https://www.mobileread.com/).
|
||||||
|
|||||||
175
kobopatch-wasm/integration_test.go
Normal file
175
kobopatch-wasm/integration_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"encoding/binary"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -260,6 +261,17 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
patchedBytes := pt.GetBytes()
|
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
|
outTarExpectedSize += h.Size
|
||||||
|
|
||||||
// Write patched file to output tar, preserving original attributes.
|
// Write patched file to output tar, preserving original attributes.
|
||||||
@@ -321,6 +333,36 @@ func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[
|
|||||||
}, nil
|
}, 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 {
|
func detectFormat(filename string) string {
|
||||||
ext := strings.TrimLeft(filepath.Ext(filename), ".")
|
ext := strings.TrimLeft(filepath.Ext(filename), ".")
|
||||||
ext = strings.ReplaceAll(ext, "patch", "patch32lsb")
|
ext = strings.ReplaceAll(ext, "patch", "patch32lsb")
|
||||||
|
|||||||
40
kobopatch-wasm/test-integration.sh
Executable file
40
kobopatch-wasm/test-integration.sh
Executable file
@@ -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" .
|
||||||
Reference in New Issue
Block a user