1
0
Files
kobopatch-webui/kobopatch-wasm/main.go
Nico Verbruggen 5e80cfd6d1
Some checks failed
Build & Test WASM / build-and-test (push) Failing after 1m45s
Integration test (conditional)
2026-03-16 11:41:35 +01:00

372 lines
10 KiB
Go

package main
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"errors"
"fmt"
"encoding/binary"
"io"
"path/filepath"
"strings"
"syscall/js"
"time"
"github.com/pgaskin/kobopatch/patchfile"
_ "github.com/pgaskin/kobopatch/patchfile/kobopatch"
_ "github.com/pgaskin/kobopatch/patchfile/patch32lsb"
"github.com/pgaskin/kobopatch/patchlib"
"gopkg.in/yaml.v3"
)
// Config mirrors the kobopatch config structure, but only the fields we need.
type Config struct {
Version string
In string // unused in WASM, but required by YAML schema
Out string // unused in WASM
Log string // unused in WASM
Patches map[string]string
Overrides map[string]map[string]bool
}
// patchResult holds the output of a patching operation.
type patchResult struct {
tgzBytes []byte
log string
}
func main() {
js.Global().Set("kobopatchVersion", js.ValueOf("wasm-1.0.0"))
js.Global().Set("patchFirmware", js.FuncOf(jsPatchFirmware))
// Keep the Go runtime alive.
select {}
}
// jsPatchFirmware is the JS-callable wrapper.
//
// Arguments:
//
// args[0]: configYAML (string) - the kobopatch.yaml config content
// args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes
// args[2]: patchFiles (Object) - map of filename -> Uint8Array patch file contents
// args[3]: onProgress (Function, optional) - callback(message string) for progress updates
//
// Returns: a Promise that resolves to { tgz: Uint8Array, log: string } or rejects with an error.
func jsPatchFirmware(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
result, err := runPatch(args)
if err != nil {
reject.Invoke(js.Global().Get("Error").New(err.Error()))
return
}
// Create Uint8Array for the tgz output.
tgzArray := js.Global().Get("Uint8Array").New(len(result.tgzBytes))
js.CopyBytesToJS(tgzArray, result.tgzBytes)
// Return { tgz: Uint8Array, log: string }
obj := js.Global().Get("Object").New()
obj.Set("tgz", tgzArray)
obj.Set("log", result.log)
resolve.Invoke(obj)
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}
func runPatch(args []js.Value) (*patchResult, error) {
if len(args) < 3 {
return nil, errors.New("patchFirmware requires 3 arguments: configYAML, firmwareZip, patchFiles")
}
// Parse arguments.
configYAML := args[0].String()
firmwareZipLen := args[1].Get("length").Int()
firmwareZip := make([]byte, firmwareZipLen)
js.CopyBytesToGo(firmwareZip, args[1])
patchFilesJS := args[2]
patchFileKeys := js.Global().Get("Object").Call("keys", patchFilesJS)
patchFiles := make(map[string][]byte)
for i := 0; i < patchFileKeys.Length(); i++ {
key := patchFileKeys.Index(i).String()
val := patchFilesJS.Get(key)
buf := make([]byte, val.Get("length").Int())
js.CopyBytesToGo(buf, val)
patchFiles[key] = buf
}
// Optional progress callback.
var progressFn func(string)
if len(args) >= 4 && args[3].Type() == js.TypeFunction {
cb := args[3]
progressFn = func(msg string) {
cb.Invoke(msg)
}
}
return patchFirmware([]byte(configYAML), firmwareZip, patchFiles, progressFn)
}
// patchFirmware runs the kobopatch patching pipeline entirely in memory.
func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte, progressFn func(string)) (*patchResult, error) {
var logBuf bytes.Buffer
logf := func(format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
logBuf.WriteString(msg + "\n")
if progressFn != nil {
progressFn(msg)
}
}
// Parse config.
var config Config
dec := yaml.NewDecoder(bytes.NewReader(configYAML))
if err := dec.Decode(&config); err != nil {
return nil, fmt.Errorf("could not parse config YAML: %w", err)
}
if config.Version == "" || len(config.Patches) == 0 {
return nil, errors.New("invalid config: version and patches are required")
}
// Open the firmware zip from memory.
logf("Opening firmware zip (%d MB)...", len(firmwareZip)/1024/1024)
zipReader, err := zip.NewReader(bytes.NewReader(firmwareZip), int64(len(firmwareZip)))
if err != nil {
return nil, fmt.Errorf("could not open firmware zip: %w", err)
}
// Find and extract KoboRoot.tgz from the zip.
logf("Extracting KoboRoot.tgz from firmware...")
var koboRootTgz io.ReadCloser
for _, f := range zipReader.File {
if f.Name == "KoboRoot.tgz" {
koboRootTgz, err = f.Open()
if err != nil {
return nil, fmt.Errorf("could not open KoboRoot.tgz in firmware zip: %w", err)
}
break
}
}
if koboRootTgz == nil {
return nil, errors.New("could not find KoboRoot.tgz in firmware zip")
}
defer koboRootTgz.Close()
gzReader, err := gzip.NewReader(koboRootTgz)
if err != nil {
return nil, fmt.Errorf("could not decompress KoboRoot.tgz: %w", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
// Prepare the output tar.gz in memory.
var outBuf bytes.Buffer
outGZ := gzip.NewWriter(&outBuf)
outTar := tar.NewWriter(outGZ)
var outTarExpectedSize int64
// Iterate over firmware tar entries and apply patches.
for {
h, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("could not read firmware tar entry: %w", err)
}
// Find which patch files target this entry.
var matchingPatchFiles []string
for patchFileName, targetPath := range config.Patches {
if h.Name == "./"+targetPath || h.Name == targetPath || filepath.Base(targetPath) == h.Name {
matchingPatchFiles = append(matchingPatchFiles, patchFileName)
}
}
if len(matchingPatchFiles) == 0 {
continue
}
if h.Typeflag != tar.TypeReg {
return nil, fmt.Errorf("could not patch '%s': not a regular file", h.Name)
}
logf("\nPatching %s", h.Name)
entryBytes, err := io.ReadAll(tarReader)
if err != nil {
return nil, fmt.Errorf("could not read '%s' from firmware: %w", h.Name, err)
}
pt := patchlib.NewPatcher(entryBytes)
for _, pfn := range matchingPatchFiles {
patchData, ok := patchFileContents[pfn]
if !ok {
return nil, fmt.Errorf("patch file '%s' not provided", pfn)
}
format := detectFormat(pfn)
formatFn, ok := patchfile.GetFormat(format)
if !ok {
return nil, fmt.Errorf("unknown patch format '%s' for file '%s'", format, pfn)
}
ps, err := formatFn(patchData)
if err != nil {
return nil, fmt.Errorf("could not parse patch file '%s': %w", pfn, err)
}
// Apply overrides.
if overrides, ok := config.Overrides[pfn]; ok {
logf(" Applying overrides")
for name, enabled := range overrides {
if err := ps.SetEnabled(name, enabled); err != nil {
return nil, fmt.Errorf("could not set override '%s' in '%s': %w", name, pfn, err)
}
if enabled {
logf(" ENABLE `%s`", name)
} else {
logf(" DISABLE `%s`", name)
}
}
}
if err := ps.Validate(); err != nil {
return nil, fmt.Errorf("invalid patch file '%s': %w", pfn, err)
}
// patchfile.Log is debug-level output (goes to log file in native kobopatch)
patchfile.Log = func(format string, a ...interface{}) {}
if err := ps.ApplyTo(pt); err != nil {
return nil, fmt.Errorf("error applying patches from '%s': %w", pfn, err)
}
}
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.
if err := outTar.WriteHeader(&tar.Header{
Typeflag: h.Typeflag,
Name: h.Name,
Mode: h.Mode,
Uid: h.Uid,
Gid: h.Gid,
ModTime: time.Now(),
Uname: h.Uname,
Gname: h.Gname,
PAXRecords: h.PAXRecords,
Size: int64(len(patchedBytes)),
Format: h.Format,
}); err != nil {
return nil, fmt.Errorf("could not write header for '%s': %w", h.Name, err)
}
if _, err := outTar.Write(patchedBytes); err != nil {
return nil, fmt.Errorf("could not write patched '%s': %w", h.Name, err)
}
}
// Finalize the output tar.gz.
if err := outTar.Close(); err != nil {
return nil, fmt.Errorf("could not finalize output tar: %w", err)
}
if err := outGZ.Close(); err != nil {
return nil, fmt.Errorf("could not finalize output gzip: %w", err)
}
// Verify consistency.
logf("\nChecking patched KoboRoot.tgz for consistency")
verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes()))
if err != nil {
return nil, fmt.Errorf("could not verify output: %w", err)
}
verifyTar := tar.NewReader(verifyReader)
var verifySum int64
for {
vh, err := verifyTar.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("output verification failed: %w", err)
}
verifySum += vh.Size
}
if verifySum != outTarExpectedSize {
return nil, fmt.Errorf("output size mismatch: expected %d, got %d", outTarExpectedSize, verifySum)
}
return &patchResult{
tgzBytes: outBuf.Bytes(),
log: logBuf.String(),
}, 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")
ext = strings.ReplaceAll(ext, "yaml", "kobopatch")
return ext
}